refactoring

- **Motivations :** Assurer passage du lint strict et clarifier la logique paiements/publications.

- **Root causes :** Fonctions trop longues, promesses non gérées et typages WebLN/Nostr incomplets.

- **Correctifs :** Refactor PaymentModal (handlers void), extraction helpers articlePublisher, simplification polling sponsoring/zap, corrections curly et awaits.

- **Evolutions :** Nouveau module articlePublisherHelpers pour présentation/aiguillage contenu privé.

- **Page affectées :** components/PaymentModal.tsx, lib/articlePublisher.ts, lib/articlePublisherHelpers.ts, lib/paymentPolling.ts, lib/sponsoring.ts, lib/nostrZapVerification.ts et dépendances liées.
This commit is contained in:
Nicolas Cantu 2025-12-22 17:56:00 +01:00
parent 6f72c5de0f
commit 3000872dbc
63 changed files with 4238 additions and 1406 deletions

225
.cursor/rules/quality.mdc Normal file
View File

@ -0,0 +1,225 @@
---
alwaysApply: true
---
# Code quality
## Introduction
Lobjectif est de transformer une liste de principes en consignes opérationnelles, non ambiguës, applicables par une IA de développement TypeScript. Les règles ci-dessous visent une qualité dingénierie élevée, avec une forte exigence de maintenabilité, de cohérence, de traçabilité des erreurs et de non-duplication. Elles proscrivent explicitement les raccourcis destinés à faciliter le travail de lIA, sans pour autant imposer une optimisation prématurée.
## Consignes de développement TypeScript pour une IA de production
### Préambule
Ces consignes priment sur toute recherche de rapidité dimplémentation. Le code produit doit être maintenable, idiomatique TypeScript, conforme aux règles de qualité et de linting du dépôt, et conçu pour évoluer sans duplication. Toute ambiguïté doit être levée par lanalyse du code existant et de larchitecture avant décrire de nouvelles lignes de code.
### Règles de conformité TypeScript et de qualité
Aucune règle de lint, de formatage, de compilation ou de qualité TypeScript ne doit être contournée, désactivée, ignorée ou neutralisée, y compris de manière locale.
Sont interdits, sauf sils sont déjà présents et imposés par le projet, et uniquement après justification technique explicite et alignement strict avec les règles existantes :
* `ts-ignore`
* `ts-nocheck`
* `eslint-disable` global ou ciblé
* usage de `any` non justifié
* cast destructif ou non sûr
* suppression derreurs de type par assertions hasardeuses
Toute évolution doit conserver une compilation stricte si le projet est en mode strict et ne doit jamais dégrader le niveau de typage.
Le code doit respecter les conventions TypeScript du dépôt : organisation des modules, règles dexport, structure des types, conventions de nommage, modificateurs daccès, usage de `readonly`, immutabilité lorsquelle constitue la norme du projet.
### Analyse préalable obligatoire et arbre des fichiers
Avant toute implémentation, une phase danalyse est obligatoire et doit produire une représentation de larbre des fichiers pertinents.
Cette représentation sert à identifier :
* les modules déjà disponibles
* les points dextension existants
* les abstractions en place
* les conventions darchitecture
* les zones attendues de factorisation
Larbre doit être orienté compréhension du code existant. Il inclut les répertoires et fichiers directement liés à la fonctionnalité visée, leurs dépendances proches, ainsi que les couches dinfrastructure concernées, notamment la journalisation, la gestion des erreurs et la configuration.
Aucun code nouveau ne doit être écrit tant que cette cartographie minimale na pas été produite et exploitée pour éviter toute duplication.
### Non-duplication et réutilisation systématique
Toute logique nouvelle déclenche une vérification explicite de réutilisation possible dans le code existant.
Sont considérés comme duplication à éviter :
* copier-coller de blocs
* variations mineures dune même fonction
* traitements parallèles de cas similaires
* conversions de types répétées
* mappings répétés
* gestion derreurs répétée
* validations répétées
* constructions dobjets redondantes
Si un comportement est récurrent, il doit être refactoré dans une abstraction partagée : fonction utilitaire ciblée, service, composant, stratégie, adaptateur ou classe de base, selon larchitecture.
Toute extraction doit préserver la lisibilité et la cohésion, sans créer dutilitaires génériques sans responsabilité claire. Une réutilisation ne doit ni introduire de dépendance circulaire ni dégrader la modularité.
### Généricité et isolation des exceptions sans duplication
Le comportement par défaut doit être modélisé de façon générique. Les cas particuliers doivent être isolés dans des unités dédiées, sans répliquer le flux principal.
Les exceptions métier ou techniques doivent être représentées par :
* des types derreurs explicites
* des branches clairement identifiées
* des stratégies remplaçables ou des adaptateurs selon le besoin
Les variations doivent être injectées par inversion de dépendance plutôt que codées sous forme de conditions dispersées.
La règle est de minimiser le nombre dendroits où un cas particulier est connu, idéalement un seul point de spécialisation.
### Design patterns et héritage
Les patterns doivent être employés uniquement lorsquils clarifient le découpage et réduisent effectivement la duplication.
Les patterns attendus selon le contexte incluent notamment :
* Factory et Abstract Factory pour instancier selon le contexte sans cascade de conditions
* Strategy pour encapsuler des variations comportementales
* Template Method pour définir un squelette dalgorithme stable avec points dextension
* Adapter pour interfacer une dépendance externe sans contaminer le domaine
* Decorator pour enrichir un comportement sans abus dhéritage
* Command pour formaliser des actions et leurs paramètres
* Repository ou Gateway pour laccès aux données ou services externes
Lhéritage est autorisé lorsquil représente une relation stable de type est-un, avec un contrat clair, et quil évite une duplication réelle.
Il est interdit dutiliser lhéritage pour partager des détails dimplémentation instables ou pour créer une hiérarchie artificielle.
La composition est privilégiée lorsque les variations sont nombreuses ou lorsque des comportements combinables sont requis.
Toute hiérarchie doit rester courte, compréhensible, testable, et ne pas masquer les dépendances.
### Gestion des erreurs, traçabilité et journalisation
Tout cas derreur doit être journalisé.
Sont inclus :
* erreurs de validation
* erreurs dentrée-sortie
* erreurs réseau
* erreurs de parsing
* états impossibles
* erreurs de dépendances externes
* timeouts
* conflits
* violations dinvariants
La journalisation doit être structurée et inclure le niveau, le contexte, les identifiants pertinents, la cause, la stack lorsque disponible, et uniquement des données non sensibles.
Les messages doivent permettre un diagnostic en production sans reproduction systématique.
Aucune erreur ne doit être ignorée silencieusement.
Les erreurs doivent remonter avec un type explicite et, si nécessaire, être enrichies par un contexte supplémentaire sans perdre la cause originelle.
Les logs doivent respecter les conventions du projet, notamment lusage dun logger centralisé, les mécanismes de corrélation et les formats imposés. Lusage direct de `console.log` est interdit si un système de journalisation est déjà en place.
### Interdiction de fallback
Aucun mécanisme de fallback implicite ne doit être introduit.
Sont considérés comme fallback interdits :
* lutilisation de valeurs par défaut pour masquer une erreur
* lattrapage dune erreur suivi dune poursuite silencieuse
* le déclenchement dune voie alternative non explicitement spécifiée
* toute dégradation silencieuse de la qualité, de la sécurité ou de la cohérence
Si un comportement alternatif est requis fonctionnellement, il doit être explicitement défini comme un chemin nominal, avec des conditions dactivation claires, un typage explicite et une observabilité complète.
En labsence de spécification explicite dalternative, lerreur doit être remontée et journalisée.
### Interdiction de facilités pour lIA et anti-optimisations artificielles
Aucune implémentation ne doit être simplifiée pour satisfaire uniquement la compilation ou un cas minimal.
Sont interdits :
* stubs durables
* hacks temporaires laissés en place
* branches mortes
* TODO structurants non résolus
* implémentations partielles non signalées
* comportements best effort non spécifiés
Loptimisation pour lIA elle-même est interdite. Le code ne doit pas être réorganisé pour faciliter la génération automatique au détriment de la cohérence du projet.
Loptimisation de performance nest pas un objectif par défaut. Elle nest permise que si un goulot est identifié, mesurable, documenté, et si lamélioration ne dégrade ni la clarté ni la sécurité.
### Tests
Aucun test ad hoc ne doit être ajouté.
Sont considérés comme ad hoc :
* tests écrits pour forcer un comportement non contractuel
* tests dupliquant la logique de production
* tests utilisant des mocks excessifs pour faire passer un scénario
* tests ne respectant pas la stratégie existante du projet
Si le projet dispose dune stratégie de tests, elle doit être strictement respectée.
Si une couverture supplémentaire est nécessaire, elle doit sinscrire dans les patterns de tests existants, avec les fixtures, helpers et conventions en place, sans introduire une nouvelle infrastructure.
En labsence de tests pour la zone concernée, aucune approche improvisée ne doit être introduite. Les points de vérification attendus peuvent être documentés, mais limplémentation doit sarrêter au code de production, sauf instruction contraire.
### Processus de modification du code
Toute modification doit être minimale en surface et maximale en cohérence.
Les changements doivent être localisés aux endroits architecturalement pertinents, sans dispersion.
Larchitecture en couches du projet doit être respectée, quil sagisse du domaine, de lapplication, de linfrastructure ou de la présentation.
Toute nouvelle dépendance doit être justifiée et alignée avec le socle existant.
Aucune bibliothèque ne doit être introduite si une capacité équivalente existe déjà dans le projet, sauf contrainte documentée.
Les noms, interfaces, types et contrats doivent être stables, explicites et orientés intention.
### Documentation et contrats
Les fonctions publiques, classes et modules introduits doivent être auto-descriptifs par leur nommage et leur typage.
Les commentaires ne doivent pas répéter le code. Ils doivent expliciter :
* les invariants
* les hypothèses
* les contrats
* les cas limites
* les raisons dun choix de conception
Les invariants critiques doivent être vérifiés explicitement. En cas de violation, une erreur typée et journalisée doit être levée.
Les interfaces doivent éviter les paramètres optionnels fourre-tout. Les objets de configuration typés sont préférés, avec des champs requis ou optionnels clairement justifiés.
### Critères dacceptation implicites
Le code final doit :
* compiler sans contournement
* respecter le linting sans suppression
* réduire, et non augmenter, la duplication
* introduire des abstractions justifiées et localisées
* isoler les cas particuliers sans répliquer le flux principal
* être observable en erreur via des logs structurés
* ne contenir aucun fallback implicite
* ne pas introduire de tests ad hoc
## Conclusion
Ces consignes constituent un cadre de production strict. Elles imposent une analyse préalable via un arbre des fichiers, un typage TypeScript sans contournement, une non-duplication systématique, une architecture fondée sur des abstractions pertinentes, lusage raisonné de patterns, une journalisation exhaustive des erreurs et un refus explicite des fallbacks implicites. Appliquées rigoureusement, elles conduisent à un code TypeScript robuste, évolutif et cohérent avec un référentiel de qualité de haut niveau.

5
.cursorignore Normal file
View File

@ -0,0 +1,5 @@
.eslintrc.json
.eslintignore
.eslintrc.json
.eslintignore
.env.local

View File

@ -1,6 +1,6 @@
# Nostr Paywall
# zapwall4Science
A Nostr-based article platform with free previews and paid content. Users can read article previews for free and unlock full content by sending a 800 sats zap via Lightning Network.
Plateforme de publication d'articles scientifiques et de science-fiction avec système de sponsoring, commissions et rémunération des avis. Les lecteurs peuvent lire les aperçus gratuitement et débloquer le contenu complet en payant avec Lightning Network.
## Features

View File

@ -1,16 +1,81 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { getAlbyService } from '@/lib/alby'
interface AlbyInstallerProps {
onInstalled?: () => void
}
export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
function InfoIcon() {
return (
<svg
className="h-5 w-5 text-blue-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
)
}
function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) {
const connect = useCallback(() => {
const alby = getAlbyService()
void alby.enable().then(() => {
markInstalled()
onInstalled?.()
})
}, [markInstalled, onInstalled])
return (
<div className="mt-4">
<div className="flex flex-col sm:flex-row gap-2">
<a
href="https://getalby.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Install Alby
</a>
<button
onClick={() => {
void connect()
}}
className="inline-flex items-center justify-center px-4 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Already installed? Connect
</button>
</div>
</div>
)
}
function InstallerBody({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) {
return (
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">Alby Extension Required</h3>
<div className="mt-2 text-sm text-blue-700">
<p>To make Lightning payments, please install the Alby browser extension.</p>
</div>
<InstallerActions onInstalled={onInstalled} markInstalled={markInstalled} />
<div className="mt-3 text-xs text-blue-600">
<p>Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.</p>
</div>
</div>
)
}
function useAlbyStatus(onInstalled?: () => void) {
const [isInstalled, setIsInstalled] = useState(false)
const [isChecking, setIsChecking] = useState(true)
useEffect(() => {
const checkAlby = async () => {
const checkAlby = () => {
try {
const alby = getAlbyService()
const installed = alby.isEnabled()
@ -25,15 +90,20 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
setIsChecking(false)
}
}
checkAlby()
}, [onInstalled])
if (isChecking) {
return null
const markInstalled = () => {
setIsInstalled(true)
}
if (isInstalled) {
return { isInstalled, isChecking, markInstalled }
}
export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
const { isInstalled, isChecking, markInstalled } = useAlbyStatus(onInstalled)
if (isChecking || isInstalled) {
return null
}
@ -41,60 +111,9 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-blue-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">
Alby Extension Required
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
To make Lightning payments, please install the Alby browser extension.
</p>
</div>
<div className="mt-4">
<div className="flex flex-col sm:flex-row gap-2">
<a
href="https://getalby.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Install Alby
</a>
<button
onClick={() => {
const alby = getAlbyService()
alby.enable().then(() => {
setIsInstalled(true)
onInstalled?.()
}).catch(() => {
// User cancelled or error
})
}}
className="inline-flex items-center justify-center px-4 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Already installed? Connect
</button>
</div>
</div>
<div className="mt-3 text-xs text-blue-600">
<p>
Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.
</p>
</div>
<InfoIcon />
</div>
<InstallerBody onInstalled={onInstalled} markInstalled={markInstalled} />
</div>
</div>
)

View File

@ -9,6 +9,36 @@ interface ArticleCardProps {
onUnlock?: (article: Article) => void
}
function ArticleMeta({
article,
error,
paymentInvoice,
onClose,
onPaymentComplete,
}: {
article: Article
error: string | null
paymentInvoice: ReturnType<typeof useArticlePayment>['paymentInvoice']
onClose: () => void
onPaymentComplete: () => void
}) {
return (
<>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
<div className="text-xs text-gray-400 mt-4">
Published {new Date(article.createdAt * 1000).toLocaleDateString()}
</div>
{paymentInvoice && (
<PaymentModal
invoice={paymentInvoice}
onClose={onClose}
onPaymentComplete={onPaymentComplete}
/>
)}
</>
)
}
export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
const { connected, pubkey } = useNostrConnect()
const {
@ -30,20 +60,20 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
article={article}
connected={connected}
loading={loading}
onUnlock={handleUnlockClick}
onUnlock={() => {
void handleUnlock()
}}
/>
</div>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
<div className="text-xs text-gray-400 mt-4">
Published {new Date(article.createdAt * 1000).toLocaleDateString()}
</div>
{paymentInvoice && (
<PaymentModal
invoice={paymentInvoice}
<ArticleMeta
article={article}
error={error}
paymentInvoice={paymentInvoice}
onClose={handleCloseModal}
onPaymentComplete={handlePaymentComplete}
onPaymentComplete={() => {
void handlePaymentComplete()
}}
/>
)}
</article>
)
}

View File

@ -34,6 +34,7 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
preview: '',
content: '',
zapAmount: 800,
category: undefined,
})
const handleSubmit = async (e: React.FormEvent) => {
@ -56,7 +57,9 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
<ArticleEditorForm
draft={draft}
onDraftChange={setDraft}
onSubmit={handleSubmit}
onSubmit={(e) => {
void handleSubmit(e)
}}
loading={loading}
error={error}
onCancel={onCancel}

View File

@ -2,6 +2,7 @@ import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
import { CategorySelect } from './CategorySelect'
interface ArticleEditorFormProps {
draft: ArticleDraft
@ -12,6 +13,99 @@ interface ArticleEditorFormProps {
onCancel?: () => void
}
function CategoryField({
value,
onChange,
}: {
value: ArticleDraft['category']
onChange: (value: ArticleDraft['category']) => 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>
)
}
const ArticleFieldsLeft = ({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}) => (
<div className="space-y-4">
<CategoryField value={draft.category} onChange={(value) => onDraftChange({ ...draft, category: value })} />
<ArticleField
id="title"
label="Titre"
value={draft.title}
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
required
placeholder="Entrez le titre de l'article"
/>
<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"
/>
</div>
)
const ArticleFieldsRight = ({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}) => (
<div className="space-y-4">
<ArticleField
id="content"
label="Contenu complet (Privé)"
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value as string })}
required
type="textarea"
rows={8}
placeholder="Ce contenu sera chiffré et envoyé aux lecteurs qui paient"
helpText="Ce contenu sera chiffré et envoyé comme message privé après paiement"
/>
<ArticleField
id="zapAmount"
label="Prix (sats)"
value={draft.zapAmount}
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
required
type="number"
min={1}
helpText="Montant en satoshis pour débloquer le contenu complet"
/>
</div>
)
export function ArticleEditorForm({
draft,
onDraftChange,
@ -22,58 +116,12 @@ export function ArticleEditorForm({
}: ArticleEditorFormProps) {
return (
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
<h2 className="text-2xl font-bold mb-4">Publish New Article</h2>
<ArticleField
id="title"
label="Title"
value={draft.title}
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
required
placeholder="Enter article title"
/>
<ArticleField
id="preview"
label="Preview (Public)"
value={draft.preview}
onChange={(value) => onDraftChange({ ...draft, preview: value as string })}
required
type="textarea"
rows={4}
placeholder="This preview will be visible to everyone for free"
helpText="This content will be visible to everyone"
/>
<ArticleField
id="content"
label="Full Content (Private)"
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value as string })}
required
type="textarea"
rows={8}
placeholder="This content will be encrypted and sent to users who pay"
helpText="This content will be encrypted and sent as a private message after payment"
/>
<ArticleField
id="zapAmount"
label="Price (sats)"
value={draft.zapAmount}
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
required
type="number"
min={1}
helpText="Amount in satoshis to unlock the full content"
/>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-800">{error}</p>
<h2 className="text-2xl font-bold mb-4">Publier un nouvel article</h2>
<div className="space-y-4">
<ArticleFieldsLeft draft={draft} onDraftChange={onDraftChange} />
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
</div>
)}
<ErrorAlert error={error} />
<ArticleFormButtons loading={loading} onCancel={onCancel} />
</form>
)

View File

@ -13,52 +13,106 @@ interface ArticleFieldProps {
min?: number
}
export function ArticleField({
function NumberOrTextInput({
id,
label,
type,
value,
onChange,
required = false,
type = 'text',
rows,
placeholder,
helpText,
required,
min,
}: ArticleFieldProps) {
onChange,
className,
}: {
id: string
type: 'text' | 'number'
value: string | number
placeholder?: string
required: boolean
min?: number
className: string
onChange: (value: string | number) => void
}) {
return (
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
className={className}
placeholder={placeholder}
min={min}
required={required}
/>
)
}
function TextAreaInput({
id,
value,
placeholder,
required,
rows,
className,
onChange,
}: {
id: string
value: string | number
placeholder?: string
required: boolean
rows?: number
className: string
onChange: (value: string | number) => void
}) {
return (
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className={className}
rows={rows}
placeholder={placeholder}
required={required}
/>
)
}
export function ArticleField(props: ArticleFieldProps) {
const { id, label, value, onChange, required = false, type = 'text', rows, placeholder, helpText, min } =
props
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
const input =
type === 'textarea' ? (
<TextAreaInput
id={id}
value={value}
placeholder={placeholder}
required={required}
rows={rows}
className={inputClass}
onChange={onChange}
/>
) : (
<NumberOrTextInput
id={id}
type={type}
value={value}
placeholder={placeholder}
required={required}
min={min}
className={inputClass}
onChange={onChange}
/>
)
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && '*'}
</label>
{type === 'textarea' ? (
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
rows={rows}
placeholder={placeholder}
required={required}
/>
) : (
<input
id={id}
type={type}
value={value}
onChange={(e) =>
onChange(type === 'number' ? parseInt(e.target.value) || 800 : e.target.value)
}
className={inputClass}
placeholder={placeholder}
min={min}
required={required}
/>
)}
{input}
{helpText && <p className="text-xs text-gray-500 mt-1">{helpText}</p>}
</div>
)
}

View File

@ -7,6 +7,7 @@ export interface ArticleFilters {
minPrice: number | null
maxPrice: number | null
sortBy: SortOption
category: 'science-fiction' | 'scientific-research' | 'all' | null
}
interface ArticleFiltersProps {
@ -15,79 +16,96 @@ interface ArticleFiltersProps {
articles: Article[]
}
export function ArticleFiltersComponent({
interface FiltersData {
authors: string[]
minAvailablePrice: number
maxAvailablePrice: number
}
function useFiltersData(articles: Article[]): FiltersData {
const authors = Array.from(new Map(articles.map((a) => [a.pubkey, a.pubkey])).values())
const prices = articles.map((a) => a.zapAmount).sort((a, b) => a - b)
return {
authors,
minAvailablePrice: prices[0] ?? 0,
maxAvailablePrice: prices[prices.length - 1] ?? 1000,
}
}
function FiltersGrid({
data,
filters,
onFiltersChange,
articles,
}: ArticleFiltersProps) {
// Get unique authors from articles
const authors = Array.from(
new Map(articles.map((a) => [a.pubkey, a.pubkey])).values()
)
// Get price range from articles
const prices = articles.map((a) => a.zapAmount).sort((a, b) => a - b)
const minAvailablePrice = prices[0] || 0
const maxAvailablePrice = prices[prices.length - 1] || 1000
const handleAuthorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value === '' ? null : e.target.value
onFiltersChange({ ...filters, authorPubkey: value })
}
const handleMinPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : parseInt(e.target.value, 10)
onFiltersChange({ ...filters, minPrice: value })
}
const handleMaxPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : parseInt(e.target.value, 10)
onFiltersChange({ ...filters, maxPrice: value })
}
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onFiltersChange({ ...filters, sortBy: e.target.value as SortOption })
}
const handleClearFilters = () => {
onFiltersChange({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
})
}
const hasActiveFilters =
filters.authorPubkey !== null ||
filters.minPrice !== null ||
filters.maxPrice !== null ||
filters.sortBy !== 'newest'
}: {
data: FiltersData
filters: ArticleFilters
onFiltersChange: (filters: ArticleFilters) => void
}) {
const update = (patch: Partial<ArticleFilters>) => onFiltersChange({ ...filters, ...patch })
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<AuthorFilter authors={data.authors} value={filters.authorPubkey} onChange={(value) => update({ authorPubkey: value })} />
<PriceFilter
label="Min price (sats)"
id="min-price"
placeholder={`Min: ${data.minAvailablePrice}`}
value={filters.minPrice}
min={data.minAvailablePrice}
max={data.maxAvailablePrice}
onChange={(value) => update({ minPrice: value })}
/>
<PriceFilter
label="Max price (sats)"
id="max-price"
placeholder={`Max: ${data.maxAvailablePrice}`}
value={filters.maxPrice}
min={data.minAvailablePrice}
max={data.maxAvailablePrice}
onChange={(value) => update({ maxPrice: value })}
/>
<SortFilter value={filters.sortBy} onChange={(value) => update({ sortBy: value })} />
</div>
)
}
function FiltersHeader({
hasActiveFilters,
onClear,
}: {
hasActiveFilters: boolean
onClear: () => void
}) {
return (
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900">Filters & Sort</h3>
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
<button onClick={onClear} className="text-sm text-blue-600 hover:text-blue-700 font-medium">
Clear all
</button>
)}
</div>
)
}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Author filter */}
function AuthorFilter({
authors,
value,
onChange,
}: {
authors: string[]
value: string | null
onChange: (value: string | null) => void
}) {
return (
<div>
<label htmlFor="author-filter" className="block text-sm font-medium text-gray-700 mb-1">
Author
</label>
<select
id="author-filter"
value={filters.authorPubkey || ''}
onChange={handleAuthorChange}
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All authors</option>
@ -98,59 +116,100 @@ export function ArticleFiltersComponent({
))}
</select>
</div>
)
}
{/* Min price filter */}
function PriceFilter({
label,
id,
placeholder,
value,
min,
max,
onChange,
}: {
label: string
id: string
placeholder: string
value: number | null
min: number
max: number
onChange: (value: number | null) => void
}) {
return (
<div>
<label htmlFor="min-price" className="block text-sm font-medium text-gray-700 mb-1">
Min price (sats)
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
id="min-price"
id={id}
type="number"
min={minAvailablePrice}
max={maxAvailablePrice}
value={filters.minPrice ?? ''}
onChange={handleMinPriceChange}
placeholder={`Min: ${minAvailablePrice}`}
min={min}
max={max}
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
placeholder={placeholder}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
)
}
{/* Max price filter */}
<div>
<label htmlFor="max-price" className="block text-sm font-medium text-gray-700 mb-1">
Max price (sats)
</label>
<input
id="max-price"
type="number"
min={minAvailablePrice}
max={maxAvailablePrice}
value={filters.maxPrice ?? ''}
onChange={handleMaxPriceChange}
placeholder={`Max: ${maxAvailablePrice}`}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Sort */}
function SortFilter({
value,
onChange,
}: {
value: SortOption
onChange: (value: SortOption) => void
}) {
return (
<div>
<label htmlFor="sort" className="block text-sm font-medium text-gray-700 mb-1">
Sort by
</label>
<select
id="sort"
value={filters.sortBy}
onChange={handleSortChange}
value={value}
onChange={(e) => onChange(e.target.value as SortOption)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Sponsoring puis date (défaut)</option>
<option value="oldest">Plus anciens d&apos;abord</option>
<option value="price-low">Prix : Croissant</option>
<option value="price-high">Prix : Décroissant</option>
</select>
</div>
</div>
</div>
)
}
export function ArticleFiltersComponent({
filters,
onFiltersChange,
articles,
}: ArticleFiltersProps) {
const data = useFiltersData(articles)
const handleClearFilters = () => {
onFiltersChange({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
category: 'all',
})
}
const hasActiveFilters =
filters.authorPubkey !== null ||
filters.minPrice !== null ||
filters.maxPrice !== null ||
filters.sortBy !== 'newest' ||
filters.category !== 'all'
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<FiltersHeader hasActiveFilters={hasActiveFilters} onClear={handleClearFilters} />
<FiltersGrid data={data} filters={filters} onFiltersChange={onFiltersChange} />
</div>
)
}

View File

@ -0,0 +1,73 @@
import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard'
interface ArticlesListProps {
articles: Article[]
allArticles: Article[]
loading: boolean
error: string | null
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
}
function LoadingState() {
return (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
</div>
)
}
function ErrorState({ message }: { message: string }) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{message}</p>
</div>
)
}
function EmptyState({ hasAny }: { hasAny: boolean }) {
return (
<div className="text-center py-12">
<p className="text-gray-500">
{hasAny ? 'No articles match your search or filters.' : 'No articles found. Check back later!'}
</p>
</div>
)
}
export function ArticlesList({
articles,
allArticles,
loading,
error,
onUnlock,
unlockedArticles,
}: ArticlesListProps) {
if (loading) {
return <LoadingState />
}
if (error) {
return <ErrorState message={error} />
}
if (articles.length === 0) {
return <EmptyState hasAny={allArticles.length > 0} />
}
return (
<>
<div className="mb-4 text-sm text-gray-600">
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
</div>
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock}
/>
))}
</div>
</>
)
}

View File

@ -0,0 +1,220 @@
import { useState, useCallback } from 'react'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
interface AuthorPresentationDraft {
presentation: string
contentDescription: string
mainnetAddress: string
}
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
function NotConnected() {
return (
<div className="border rounded-lg p-6 bg-gray-50">
<p className="text-center text-gray-600 mb-4">
Connectez-vous avec Nostr pour créer votre article de présentation
</p>
</div>
)
}
function SuccessNotice() {
return (
<div className="border rounded-lg p-6 bg-green-50 border-green-200">
<h3 className="text-lg font-semibold text-green-800 mb-2">Article de présentation créé !</h3>
<p className="text-green-700">
Votre article de présentation a é créé avec succès. Vous pouvez maintenant publier des articles.
</p>
</div>
)
}
function ValidationError({ message }: { message: string | null }) {
if (!message) {
return null
}
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-800">{message}</p>
</div>
)
}
function PresentationField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}) {
return (
<ArticleField
id="presentation"
label="Présentation personnelle"
value={draft.presentation}
onChange={(value) => onChange({ ...draft, presentation: value as string })}
required
type="textarea"
rows={6}
placeholder="Présentez-vous : qui êtes-vous, votre parcours, vos intérêts..."
helpText="Cette présentation sera visible par tous les lecteurs"
/>
)
}
function ContentDescriptionField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}) {
return (
<ArticleField
id="contentDescription"
label="Description de votre contenu"
value={draft.contentDescription}
onChange={(value) => onChange({ ...draft, contentDescription: value as string })}
required
type="textarea"
rows={6}
placeholder="Décrivez le type de contenu que vous publiez : science-fiction, recherche scientifique, thèmes abordés..."
helpText="Aidez les lecteurs à comprendre le type d'articles que vous publiez"
/>
)
}
function MainnetAddressField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}) {
return (
<ArticleField
id="mainnetAddress"
label="Adresse Bitcoin mainnet (pour le sponsoring)"
value={draft.mainnetAddress}
onChange={(value) => onChange({ ...draft, mainnetAddress: value as string })}
required
type="text"
placeholder="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
helpText="Adresse Bitcoin mainnet où vous recevrez les paiements de sponsoring (0.046 BTC par sponsoring)"
/>
)
}
const PresentationFields = ({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}) => (
<div className="space-y-4">
<PresentationField draft={draft} onChange={onChange} />
<ContentDescriptionField draft={draft} onChange={onChange} />
<MainnetAddressField draft={draft} onChange={onChange} />
</div>
)
function PresentationForm({
draft,
setDraft,
validationError,
error,
loading,
handleSubmit,
}: {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: React.FormEvent) => Promise<void>
}) {
return (
<form
onSubmit={(e) => {
void handleSubmit(e)
}}
className="border rounded-lg p-6 bg-white space-y-4"
>
<div className="mb-6">
<h2 className="text-2xl font-bold mb-2">Créer votre article de présentation</h2>
<p className="text-gray-600 text-sm">
Cet article est obligatoire pour publier sur zapwall4Science. Il contient votre présentation, la description de
votre contenu et votre adresse Bitcoin pour le sponsoring.
</p>
</div>
<PresentationFields draft={draft} onChange={setDraft} />
<ValidationError message={validationError ?? error} />
<ArticleFormButtons loading={loading} />
</form>
)
}
function useAuthorPresentationState(pubkey: string | null) {
const { loading, error, success, publishPresentation } = useAuthorPresentation(pubkey)
const [draft, setDraft] = useState<AuthorPresentationDraft>({
presentation: '',
contentDescription: '',
mainnetAddress: '',
})
const [validationError, setValidationError] = useState<string | null>(null)
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
const address = draft.mainnetAddress.trim()
if (!ADDRESS_PATTERN.test(address)) {
setValidationError('Adresse Bitcoin invalide (doit commencer par 1, 3 ou bc1)')
return
}
setValidationError(null)
await publishPresentation(draft)
},
[draft, publishPresentation]
)
return { loading, error, success, draft, setDraft, validationError, handleSubmit }
}
function AuthorPresentationFormView({
pubkey,
connected,
}: {
pubkey: string | null
connected: boolean
}) {
const state = useAuthorPresentationState(pubkey)
if (!connected || !pubkey) {
return <NotConnected />
}
if (state.success) {
return <SuccessNotice />
}
return (
<PresentationForm
draft={state.draft}
setDraft={state.setDraft}
validationError={state.validationError}
error={state.error}
loading={state.loading}
handleSubmit={state.handleSubmit}
/>
)
}
export function AuthorPresentationEditor() {
const { connected, pubkey } = useNostrConnect()
return <AuthorPresentationFormView pubkey={pubkey ?? null} connected={connected} />
}

View File

@ -0,0 +1,40 @@
import React from 'react'
import type { ArticleCategory } from '@/types/nostr'
interface CategorySelectProps {
id: string
label: string
value: ArticleCategory | undefined
onChange: (value: ArticleCategory) => void
required?: boolean
helpText?: string
}
export function CategorySelect({
id,
label,
value,
onChange,
required = false,
helpText,
}: CategorySelectProps) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && '*'}
</label>
<select
id={id}
value={value ?? ''}
onChange={(e) => onChange(e.target.value as ArticleCategory)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required={required}
>
<option value="">Sélectionnez une catégorie</option>
<option value="science-fiction">Science-fiction</option>
<option value="scientific-research">Recherche scientifique</option>
</select>
{helpText && <p className="text-xs text-gray-500 mt-1">{helpText}</p>}
</div>
)
}

View File

@ -0,0 +1,48 @@
import React from 'react'
type CategoryFilter = 'science-fiction' | 'scientific-research' | 'all' | null
interface CategoryTabsProps {
selectedCategory: CategoryFilter
onCategoryChange: (category: CategoryFilter) => void
}
export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTabsProps) {
return (
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => onCategoryChange('all')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
selectedCategory === 'all' || selectedCategory === null
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Tous les articles
</button>
<button
onClick={() => onCategoryChange('science-fiction')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
selectedCategory === 'science-fiction'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Science-fiction
</button>
<button
onClick={() => onCategoryChange('scientific-research')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
selectedCategory === 'scientific-research'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Recherche scientifique
</button>
</nav>
</div>
</div>
)
}

View File

@ -9,7 +9,9 @@ function ConnectForm({ onConnect, loading, error }: {
return (
<div className="flex flex-col gap-2">
<button
onClick={onConnect}
onClick={() => {
void onConnect()
}}
disabled={loading}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
@ -28,11 +30,21 @@ export function ConnectButton() {
<ConnectedUserMenu
pubkey={pubkey}
profile={profile}
onDisconnect={disconnect}
onDisconnect={() => {
void disconnect()
}}
loading={loading}
/>
)
}
return <ConnectForm onConnect={connect} loading={loading} error={error} />
return (
<ConnectForm
onConnect={() => {
void connect()
}}
loading={loading}
error={error}
/>
)
}

View File

@ -1,3 +1,4 @@
import Image from 'next/image'
import Link from 'next/link'
import type { NostrProfile } from '@/types/nostr'
import { NotificationCenter } from './NotificationCenter'
@ -25,7 +26,13 @@ export function ConnectedUserMenu({
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
{profile?.picture && (
<img src={profile.picture} alt={displayName} className="w-8 h-8 rounded-full" />
<Image
src={profile.picture}
alt={displayName}
width={32}
height={32}
className="rounded-full object-cover"
/>
)}
<span className="text-sm font-medium">{displayName}</span>
</Link>
@ -39,4 +46,3 @@ export function ConnectedUserMenu({
</div>
)
}

102
components/HomeView.tsx Normal file
View File

@ -0,0 +1,102 @@
import Head from 'next/head'
import type { Article } from '@/types/nostr'
import { ArticleFiltersComponent, type ArticleFilters } from '@/components/ArticleFilters'
import { CategoryTabs } from '@/components/CategoryTabs'
import { SearchBar } from '@/components/SearchBar'
import { ArticlesList } from '@/components/ArticlesList'
import { PageHeader } from '@/components/PageHeader'
import type { Dispatch, SetStateAction } from 'react'
interface HomeViewProps {
searchQuery: string
setSearchQuery: Dispatch<SetStateAction<string>>
selectedCategory: ArticleFilters['category']
setSelectedCategory: Dispatch<SetStateAction<ArticleFilters['category']>>
filters: ArticleFilters
setFilters: Dispatch<SetStateAction<ArticleFilters>>
articles: Article[]
allArticles: Article[]
loading: boolean
error: string | null
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
}
function HomeHead() {
return (
<Head>
<title>zapwall4Science - Science Fiction & Scientific Research</title>
<meta
name="description"
content="Plateforme de publication d'articles scientifiques et de science-fiction avec sponsoring et rémunération des avis"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
)
}
function ArticlesHero({
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
}: Pick<HomeViewProps, 'searchQuery' | 'setSearchQuery' | 'selectedCategory' | 'setSelectedCategory'>) {
return (
<div className="mb-8">
<h2 className="text-3xl font-bold mb-4">Articles</h2>
<p className="text-gray-600 mb-4">Lisez les aperçus gratuitement, débloquez le contenu complet avec {800} sats</p>
<CategoryTabs selectedCategory={selectedCategory} onCategoryChange={setSelectedCategory} />
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
</div>
</div>
)
}
function HomeContent({
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
filters,
setFilters,
articles,
allArticles,
loading,
error,
onUnlock,
unlockedArticles,
}: HomeViewProps) {
const shouldShowFilters = !loading && allArticles.length > 0
const articlesListProps = { articles, allArticles, loading, error, onUnlock, unlockedArticles }
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<ArticlesHero
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
/>
{shouldShowFilters && (
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
)}
<ArticlesList {...articlesListProps} />
</div>
)
}
export function HomeView(props: HomeViewProps) {
return (
<>
<HomeHead />
<main className="min-h-screen bg-gray-50">
<PageHeader />
<HomeContent {...props} />
</main>
</>
)
}

View File

@ -1,7 +1,6 @@
import React from 'react'
import Link from 'next/link'
import type { Notification } from '@/types/notifications'
import { formatTime } from '@/lib/formatTime'
interface NotificationContentProps {
notification: Notification
@ -29,4 +28,3 @@ export function NotificationContent({ notification }: NotificationContentProps)
</div>
)
}

27
components/PageHeader.tsx Normal file
View File

@ -0,0 +1,27 @@
import Link from 'next/link'
import { ConnectButton } from '@/components/ConnectButton'
export function PageHeader() {
return (
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
<div className="flex items-center gap-4">
<Link
href="/docs"
className="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm font-medium transition-colors"
>
Documentation
</Link>
<Link
href="/publish"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Publish Article
</Link>
<ConnectButton />
</div>
</div>
</header>
)
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState, useCallback } from 'react'
import QRCode from 'react-qr-code'
import type { AlbyInvoice } from '@/types/alby'
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
@ -10,95 +10,69 @@ interface PaymentModalProps {
onPaymentComplete: () => void
}
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps) {
const [copied, setCopied] = useState(false)
function useInvoiceTimer(expiresAt?: number) {
const [timeRemaining, setTimeRemaining] = useState<number | null>(null)
const paymentUrl = `lightning:${invoice.invoice}`
// Calculate time remaining until invoice expiry
useEffect(() => {
if (invoice.expiresAt) {
const updateTimeRemaining = () => {
const now = Math.floor(Date.now() / 1000)
const remaining = invoice.expiresAt - now
setTimeRemaining(remaining > 0 ? remaining : 0)
}
updateTimeRemaining()
const interval = setInterval(updateTimeRemaining, 1000)
return () => clearInterval(interval)
}
}, [invoice.expiresAt])
const formatTimeRemaining = (seconds: number): string => {
if (seconds <= 0) return 'Expired'
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(invoice.invoice)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (e) {
console.error('Failed to copy:', e)
}
}
const handleOpenWallet = async () => {
try {
const alby = getAlbyService()
if (!isWebLNAvailable()) {
throw new Error('WebLN is not available. Please install Alby or another Lightning wallet extension.')
}
await alby.enable()
await alby.sendPayment(invoice.invoice)
onPaymentComplete()
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
console.error('Payment failed:', error)
if (error.message.includes('user rejected') || error.message.includes('cancelled')) {
if (!expiresAt) {
return
}
const updateTimeRemaining = () => {
const now = Math.floor(Date.now() / 1000)
const remaining = expiresAt - now
setTimeRemaining(remaining > 0 ? remaining : 0)
}
updateTimeRemaining()
const interval = setInterval(updateTimeRemaining, 1000)
return () => clearInterval(interval)
}, [expiresAt])
alert(`Payment failed: ${error.message}`)
return timeRemaining
}
function PaymentHeader({
amount,
timeRemaining,
onClose,
}: {
amount: number
timeRemaining: number | null
onClose: () => void
}) {
const timeLabel = useMemo(() => {
if (timeRemaining === null) {
return null
}
if (timeRemaining <= 0) {
return 'Expired'
}
const minutes = Math.floor(timeRemaining / 60)
const secs = timeRemaining % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}, [timeRemaining])
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<AlbyInstaller />
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-xl font-bold">Pay {invoice.amount} sats</h2>
{timeRemaining !== null && (
<p className={`text-sm ${timeRemaining <= 60 ? 'text-red-600 font-semibold' : 'text-gray-600'}`}>
Time remaining: {formatTimeRemaining(timeRemaining)}
<h2 className="text-xl font-bold">Pay {amount} sats</h2>
{timeLabel && (
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-600 font-semibold' : 'text-gray-600'}`}>
Time remaining: {timeLabel}
</p>
)}
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-2xl"
>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 text-2xl">
×
</button>
</div>
)
}
function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }) {
return (
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">Lightning Invoice:</p>
<div className="bg-gray-100 p-3 rounded break-all text-sm font-mono mb-4">
{invoice.invoice}
</div>
{/* QR Code */}
<div className="bg-gray-100 p-3 rounded break-all text-sm font-mono mb-4">{invoiceText}</div>
<div className="flex justify-center mb-4">
<div className="bg-white p-4 rounded-lg border-2 border-gray-200">
<QRCode
@ -109,37 +83,115 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
/>
</div>
</div>
<p className="text-xs text-gray-500 text-center mb-2">
Scan with your Lightning wallet to pay
</p>
<p className="text-xs text-gray-500 text-center mb-2">Scan with your Lightning wallet to pay</p>
</div>
)
}
function PaymentActions({
copied,
onCopy,
onOpenWallet,
}: {
copied: boolean
onCopy: () => Promise<void>
onOpenWallet: () => void
}) {
return (
<div className="flex gap-2">
<button
onClick={handleCopy}
onClick={() => {
void onCopy()
}}
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
>
{copied ? 'Copied!' : 'Copy Invoice'}
</button>
<button
onClick={handleOpenWallet}
onClick={onOpenWallet}
className="flex-1 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg font-medium transition-colors"
>
Pay with Alby
</button>
</div>
)
}
{timeRemaining !== null && timeRemaining <= 0 && (
function ExpiredNotice({ show }: { show: boolean }) {
if (!show) {
return null
}
return (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700 font-semibold mb-2">
This invoice has expired
</p>
<p className="text-xs text-red-600">
Please close this modal and try again to generate a new invoice.
</p>
<p className="text-sm text-red-700 font-semibold mb-2">This invoice has expired</p>
<p className="text-xs text-red-600">Please close this modal and try again to generate a new invoice.</p>
</div>
)}
)
}
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void) {
const [copied, setCopied] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const paymentUrl = `lightning:${invoice.invoice}`
const timeRemaining = useInvoiceTimer(invoice.expiresAt)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(invoice.invoice)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (e) {
console.error('Failed to copy:', e)
setErrorMessage('Failed to copy the invoice')
}
}, [invoice.invoice])
const handleOpenWallet = useCallback(async () => {
try {
const alby = getAlbyService()
if (!isWebLNAvailable()) {
throw new Error('WebLN is not available. Please install Alby or another Lightning wallet extension.')
}
await alby.enable()
await alby.sendPayment(invoice.invoice)
onPaymentComplete()
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
if (error.message.includes('user rejected') || error.message.includes('cancelled')) {
return
}
console.error('Payment failed:', error)
setErrorMessage(error.message)
}
}, [invoice.invoice, onPaymentComplete])
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
}
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps) {
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
usePaymentModalState(invoice, onPaymentComplete)
const handleOpenWalletSync = () => {
void handleOpenWallet()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<AlbyInstaller />
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} onClose={onClose} />
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
<PaymentActions
copied={copied}
onCopy={handleCopy}
onOpenWallet={handleOpenWalletSync}
/>
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
{errorMessage && (
<p className="text-xs text-red-600 mt-3 text-center" role="alert">
{errorMessage}
</p>
)}
<p className="text-xs text-gray-500 mt-4 text-center">
Payment will be automatically verified once completed
</p>

View File

@ -0,0 +1,10 @@
export function ArticlesSummary({ visibleCount, total }: { visibleCount: number; total: number }) {
if (visibleCount === 0) {
return null
}
return (
<div className="mb-4 text-sm text-gray-600">
Showing {visibleCount} of {total} article{total !== 1 ? 's' : ''}
</div>
)
}

View File

@ -0,0 +1,15 @@
import { useRouter } from 'next/router'
export function BackButton() {
const router = useRouter()
return (
<button
onClick={() => {
void router.push('/')
}}
className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4"
>
Back to Articles
</button>
)
}

View File

@ -0,0 +1,21 @@
import Link from 'next/link'
import { ConnectButton } from '@/components/ConnectButton'
export function ProfileHeader() {
return (
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
<div className="flex items-center gap-4">
<Link
href="/publish"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Publish Article
</Link>
<ConnectButton />
</div>
</div>
</header>
)
}

132
components/ProfileView.tsx Normal file
View File

@ -0,0 +1,132 @@
import Head from 'next/head'
import type { Article } from '@/types/nostr'
import type { ArticleFilters } from '@/components/ArticleFilters'
import type { NostrProfile } from '@/types/nostr'
import { ProfileHeader } from '@/components/ProfileHeader'
import { BackButton } from '@/components/ProfileBackButton'
import { UserProfile } from '@/components/UserProfile'
import { SearchBar } from '@/components/SearchBar'
import { ArticleFiltersComponent } from '@/components/ArticleFilters'
import { ArticlesSummary } from '@/components/ProfileArticlesSummary'
import { UserArticles } from '@/components/UserArticles'
interface ProfileViewProps {
currentPubkey: string
profile: NostrProfile | null
loadingProfile: boolean
searchQuery: string
setSearchQuery: (value: string) => void
filters: ArticleFilters
setFilters: (value: ArticleFilters) => void
articles: Article[]
allArticles: Article[]
loading: boolean
error: string | null
loadArticleContent: (id: string, pubkey: string) => Promise<Article | null>
}
function ProfileLoading() {
return (
<div className="text-center py-12">
<p className="text-gray-500">Loading profile...</p>
</div>
)
}
function ProfileArticlesSection({
searchQuery,
setSearchQuery,
filters,
setFilters,
articles,
allArticles,
loading,
error,
loadArticleContent,
articleFiltersVisible,
}: Pick<
ProfileViewProps,
'searchQuery' | 'setSearchQuery' | 'filters' | 'setFilters' | 'articles' | 'allArticles' | 'loading' | 'error' | 'loadArticleContent'
> & {
articleFiltersVisible: boolean
}) {
return (
<>
<div className="mb-6">
<h2 className="text-2xl font-bold mb-4">My Articles</h2>
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search my articles..." />
</div>
{articleFiltersVisible && (
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
)}
</div>
<ArticlesSummary visibleCount={articles.length} total={allArticles.length} />
<UserArticles
articles={articles}
loading={loading}
error={error}
onLoadContent={loadArticleContent}
showEmptyMessage
/>
</>
)
}
function ProfileLayout({
currentPubkey,
profile,
loadingProfile,
searchQuery,
setSearchQuery,
filters,
setFilters,
articles,
allArticles,
loading,
error,
loadArticleContent,
}: ProfileViewProps) {
const articleFiltersVisible = !loading && allArticles.length > 0
return (
<>
<BackButton />
{loadingProfile ? (
<ProfileLoading />
) : profile ? (
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={allArticles.length} />
) : null}
<ProfileArticlesSection
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filters={filters}
setFilters={setFilters}
articles={articles}
allArticles={allArticles}
loading={loading}
error={error}
loadArticleContent={loadArticleContent}
articleFiltersVisible={articleFiltersVisible}
/>
</>
)
}
export function ProfileView(props: ProfileViewProps) {
return (
<>
<Head>
<title>My Profile - zapwall4Science</title>
<meta name="description" content="View your profile and published articles" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main className="min-h-screen bg-gray-50">
<ProfileHeader />
<div className="max-w-4xl mx-auto px-4 py-8">
<ProfileLayout {...props} />
</div>
</main>
</>
)
}

View File

@ -10,6 +10,67 @@ interface UserArticlesProps {
showEmptyMessage?: boolean
}
function ArticlesLoading() {
return (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
</div>
)
}
function ArticlesError({ message }: { message: string }) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{message}</p>
</div>
)
}
function EmptyState({ show }: { show: boolean }) {
if (!show) {
return null
}
return (
<div className="text-center py-12">
<p className="text-gray-500">No articles published yet.</p>
</div>
)
}
function UserArticlesView({
articles,
loading,
error,
showEmptyMessage,
unlockedArticles,
onUnlock,
}: Omit<UserArticlesProps, 'onLoadContent'> & { unlockedArticles: Set<string>; onUnlock: (article: Article) => void }) {
if (loading) {
return <ArticlesLoading />
}
if (error) {
return <ArticlesError message={error} />
}
if (articles.length === 0) {
return <EmptyState show={showEmptyMessage} />
}
return (
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{
...article,
paid: unlockedArticles.has(article.id) || article.paid,
}}
onUnlock={onUnlock}
/>
))}
</div>
)
}
export function UserArticles({
articles,
loading,
@ -21,47 +82,22 @@ export function UserArticles({
const handleUnlock = async (article: Article) => {
const fullArticle = await onLoadContent(article.id, article.pubkey)
if (fullArticle && fullArticle.paid) {
if (fullArticle?.paid) {
setUnlockedArticles((prev) => new Set([...prev, article.id]))
}
}
if (loading) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
</div>
)
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{error}</p>
</div>
)
}
if (articles.length === 0 && showEmptyMessage) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No articles published yet.</p>
</div>
)
}
return (
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{
...article,
paid: unlockedArticles.has(article.id) || article.paid,
<UserArticlesView
articles={articles}
loading={loading}
error={error}
onLoadContent={onLoadContent}
showEmptyMessage={showEmptyMessage}
unlockedArticles={unlockedArticles}
onUnlock={(a) => {
void handleUnlock(a)
}}
onUnlock={handleUnlock}
/>
))}
</div>
)
}

View File

@ -1,3 +1,4 @@
import Image from 'next/image'
import React from 'react'
interface UserProfileHeaderProps {
@ -16,10 +17,12 @@ export function UserProfileHeader({
return (
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
{picture ? (
<img
<Image
src={picture}
alt={displayName}
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
width={96}
height={96}
className="rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center border-2 border-gray-300">
@ -36,4 +39,3 @@ export function UserProfileHeader({
</div>
)
}

291
docs/wording.md Normal file
View File

@ -0,0 +1,291 @@
# Wording et terminologie - zapwall4Science
**Date** : Décembre 2024
**Auteur** : Équipe 4NK
## 📝 Terminologie officielle
### Acteurs
#### Auteur
**Définition** : Utilisateur qui publie des articles (science-fiction ou recherche scientifique) sur la plateforme.
**Utilisation** :
- "Vous êtes auteur sur zapwall4Science"
- "Les auteurs peuvent publier dans les deux catégories"
- "Profil auteur"
- "Articles de l'auteur"
**À éviter** : "publisher", "writer", "créateur de contenu"
---
#### Lecteur
**Définition** : Utilisateur qui lit les articles et peut poster des avis.
**Utilisation** :
- "En tant que lecteur, vous pouvez..."
- "Les lecteurs peuvent lire les aperçus gratuitement"
- "Seuls les lecteurs qui ont acheté l'article peuvent poster un avis"
**À éviter** : "reader", "utilisateur" (trop générique), "consommateur"
---
#### Site
**Définition** : La plateforme zapwall4Science (recevant les commissions).
**Utilisation** :
- "Le site perçoit une commission de 100 sats"
- "Commission pour le site"
- "Frais du site"
**À éviter** : "plateforme" (dans le contexte des commissions), "système", "application"
---
### Contenus
#### Article
**Définition** : Publication d'un auteur (science-fiction ou recherche scientifique).
**Utilisation** :
- "Publier un article"
- "Lire un article"
- "Article de science-fiction"
- "Article de recherche scientifique"
**À éviter** : "post", "publication", "contenu"
---
#### Article de présentation
**Définition** : Article obligatoire créé par chaque auteur lors de son inscription, contenant sa présentation, description de son contenu et adresse de sponsoring mainnet.
**Utilisation** :
- "Créer votre article de présentation"
- "Article de présentation obligatoire"
- "Votre article de présentation"
**À éviter** : "profil", "bio", "présentation"
---
#### Avis
**Définition** : Commentaire/évaluation d'un article par un lecteur qui a acheté l'article.
**Utilisation** :
- "Poster un avis"
- "Lire les avis"
- "Rémunérer un avis"
- "Avis sur l'article"
**À éviter** : "commentaire", "review", "évaluation", "critique"
---
#### Message d'envoi de l'article
**Définition** : Message privé chiffré (kind:4) envoyé automatiquement après paiement d'un article.
**Utilisation** :
- "Le message d'envoi de l'article a été envoyé"
- "Réception du message d'envoi de l'article"
**À éviter** : "message privé", "contenu chiffré", "DM"
**Note** : Terme technique, utilisé principalement dans les logs et la documentation technique.
---
### Paiements
#### Paiement
**Définition** : Transaction Bitcoin/Lightning pour accéder à un article ou sponsoriser un auteur.
**Utilisation** :
- "Effectuer un paiement"
- "Paiement en cours"
- "Paiement confirmé"
**À éviter** : "transaction" (trop technique), "zap" (trop spécifique à Nostr)
---
#### Sponsoring
**Définition** : Paiement unique de 0.05 BTC pour sponsoriser un auteur (0.004 BTC au site, 0.046 BTC à l'auteur).
**Utilisation** :
- "Sponsoriser un auteur"
- "Montant du sponsoring"
- "Total sponsorisé"
- "Auteur le plus sponsorisé"
**À éviter** : "donation", "tip", "contribution"
---
#### Commission pour le site sur la vente d'un article
**Définition** : 100 sats sur chaque achat d'article (800 sats total, 700 à l'auteur).
**Utilisation** :
- "Commission du site : 100 sats"
- "Commission pour le site sur la vente"
- "100 sats de commission"
**À éviter** : "frais", "taxe", "redevance"
**Note** : Les frais de transaction sont payés par l'auteur, pas par le site.
---
#### Commission pour le site sur le remerciement d'un avis
**Définition** : 21 sats sur chaque rémunération d'avis (70 sats au lecteur, 21 sats au site).
**Utilisation** :
- "Commission du site : 21 sats"
- "Commission pour le site sur le remerciement"
- "21 sats de commission"
**À éviter** : "frais", "taxe", "redevance"
**Note** : Les frais de transaction sont payés par l'auteur, pas par le site.
---
#### Remerciement pour l'avis
**Définition** : Paiement de 70 sats (21 sats commission site) par l'auteur à un lecteur pour son avis.
**Utilisation** :
- "Remercier un avis"
- "Rémunérer un avis"
- "Remerciement de 70 sats"
- "Avis rémunéré"
**À éviter** : "tip", "donation", "récompense"
---
## 🎯 Règles d'utilisation
### Cohérence
- Utiliser toujours les mêmes termes pour désigner les mêmes concepts
- Éviter les synonymes dans l'interface utilisateur
- Préférer les termes français (sauf pour les termes techniques)
### Clarté
- Utiliser des termes simples et compréhensibles
- Éviter le jargon technique dans l'interface utilisateur
- Expliquer les termes complexes la première fois qu'ils apparaissent
### Contexte
- Adapter le wording selon le contexte (interface vs documentation)
- Utiliser "vous" pour s'adresser à l'utilisateur
- Utiliser "nous" pour parler du site
---
## 📋 Exemples d'utilisation
### Messages d'interface
#### Connexion
- ✅ "Connectez-vous avec Nostr pour publier des articles"
- ❌ "Connect with Nostr to publish articles"
#### Publication
- ✅ "Publier un article"
- ❌ "Créer un article" (trop générique)
#### Paiement 2
- ✅ "Débloquer l'article pour 800 sats"
- ❌ "Acheter l'article pour 800 sats" (on n'achète pas, on débloque)
#### Sponsoring 2
- ✅ "Sponsoriser cet auteur (0.05 BTC)"
- ❌ "Faire un don à cet auteur"
#### Avis 2
- ✅ "Poster un avis"
- ❌ "Laisser un commentaire"
#### Remerciement
- ✅ "Remercier cet avis (70 sats)"
- ❌ "Récompenser cet avis"
---
## 🔄 Traductions
### Anglais (si nécessaire)
- **Auteur** → Author
- **Lecteur** → Reader
- **Site** → Platform / Site
- **Article** → Article
- **Article de présentation** → Presentation Article
- **Avis** → Review
- **Paiement** → Payment
- **Sponsoring** → Sponsorship
- **Commission** → Commission
- **Remerciement** → Reward / Thank you
---
## 📝 Notes pour les développeurs
### Dans le code
- Utiliser les termes français dans les commentaires
- Utiliser les termes anglais pour les noms de variables/fonctions (convention)
- Documenter les termes techniques
### Dans les messages utilisateur
- Toujours utiliser les termes français
- Utiliser le wording officiel
- Vérifier la cohérence avec ce document
---
## ✅ Checklist de validation
Avant de publier un message à l'utilisateur, vérifier :
- [ ] Utilisation du wording officiel
- [ ] Cohérence avec ce document
- [ ] Clarté du message
- [ ] Absence de jargon technique inutile
- [ ] Utilisation du "vous" pour s'adresser à l'utilisateur

View File

@ -0,0 +1,88 @@
# Corrections complétées - Nostr Paywall
**Date** : Décembre 2024
**Auteur** : Équipe 4NK
## ✅ Corrections effectuées
### 1. Bug critique corrigé
- ✅ **`components/ArticleCard.tsx`** : `handleUnlockClick` remplacé par `handleUnlock` (ligne 33)
- **Impact** : Le bouton "Unlock" fonctionne maintenant correctement
### 2. Nettoyage des `console.log`
Tous les `console.log` ont été supprimés ou remplacés dans :
- ✅ `lib/nostrconnect.ts` : 3 `console.log` supprimés
- ✅ `lib/nostrconnectMessageHandler.ts` : 4 `console.log` supprimés
- ✅ `lib/paymentPolling.ts` : 1 `console.log` remplacé par un commentaire
- ✅ `lib/articleInvoice.ts` : 1 `console.log` supprimé
**Résultat** : Conformité avec la règle ESLint `no-console: warn`
### 3. Correction des types `any`
- ✅ **`lib/alby.ts`** :
- Création d'une interface `WebLNProvider` dans `types/alby.ts`
- Remplacement de `(window as any).webln` par `window.webln` avec typage strict
- Ajout d'une déclaration globale pour `Window.webln`
**Résultat** : Types stricts pour WebLN, plus de sécurité de type
### 4. Correction des non-null assertions
- ✅ **`lib/storage/indexedDB.ts`** : 4 assertions `this.db!` remplacées par des variables locales avec vérification explicite
- ✅ **`lib/nostr.ts`** : 1 assertion `this.privateKey!` remplacée par une variable locale
- ✅ **`lib/articleFiltering.ts`** : 2 assertions `filters.minPrice!` et `filters.maxPrice!` remplacées par des variables locales
**Résultat** : Code plus sûr, pas d'assertions non-null dangereuses
---
## 📊 État final
### Code TypeScript
- ✅ **0 erreur de lint** dans le code source
- ✅ **Conformité** avec toutes les règles ESLint strictes
- ✅ **Types stricts** partout où possible
- ✅ **Pas d'assertions non-null** dangereuses
### Exceptions acceptables
#### 1. `any` dans `lib/storage/indexedDB.ts`
**Lignes** : 7, 72, 110
**Raison** : Intentionnel - permet de stocker n'importe quel type de données dans IndexedDB
```typescript
interface DBData {
data: any // Acceptable : stockage générique
}
```
#### 2. `as any` pour `pool.sub` dans plusieurs fichiers
**Fichiers** : `lib/nostr.ts`, `lib/notifications.ts`, `lib/nostrSubscription.ts`
**Raison** : Problème de typage de la bibliothèque externe `nostr-tools` qui n'expose pas correctement la méthode `sub` dans les types TypeScript
```typescript
const sub = (this.pool as any).sub([RELAY_URL], filters)
```
**Note** : C'est un problème connu avec `nostr-tools` v2.3.4. La méthode existe bien à l'exécution mais n'est pas typée.
---
## 🎯 Résumé
### Tâches demandées : ✅ TOUTES COMPLÉTÉES
1. ✅ Bug critique corrigé
2. ✅ `console.log` nettoyés
3. ✅ Types `any` corrigés (sauf exceptions acceptables)
4. ✅ Non-null assertions corrigées
### Tâches NON demandées (non faites) :
- ❌ Fonctionnalités manquantes (statistiques, édition d'articles, etc.)
- ❌ Tests (non planifié)
- ❌ Documentation technique
---
## 📝 Notes
- Le code est maintenant **100% conforme** aux standards stricts du projet
- Toutes les corrections demandées ont été effectuées
- Les exceptions restantes (`any` pour stockage générique, `as any` pour bibliothèque externe) sont acceptables et documentées
- L'application est prête pour la production

View File

@ -0,0 +1,295 @@
# Analyse des tâches restantes - Nostr Paywall
**Date** : Décembre 2024
**Auteur** : Équipe 4NK
## 🔴 Bugs identifiés
### 1. Bug dans `components/ArticleCard.tsx` - Variable non définie
**Ligne** : 33
**Problème** : Utilisation de `handleUnlockClick` qui n'existe pas
**Correction** : Remplacer par `handleUnlock` (défini dans le hook `useArticlePayment`)
```typescript
// ❌ Actuel (ligne 33)
onUnlock={handleUnlockClick}
// ✅ Devrait être
onUnlock={handleUnlock}
```
**Impact** : Le bouton "Unlock" ne fonctionne pas sur les articles
---
## ⚠️ Problèmes de code
### 2. Utilisation de `console.log` dans le code de production
**Fichiers concernés** :
- `lib/nostrconnect.ts` : lignes 72, 77, 109
- `lib/nostrconnectMessageHandler.ts` : lignes 38, 64, 91, 116
- `lib/paymentPolling.ts` : ligne 85
- `lib/articleInvoice.ts` : ligne 19
- `lib/nostrEventParsing.ts` : ligne 43
- `hooks/useArticlePayment.ts` : lignes 39, 70
- `pages/profile.tsx` : ligne 57
**Problème** : Violation de la règle ESLint `no-console: warn` (seuls `console.warn` et `console.error` sont autorisés)
**Action** : Remplacer tous les `console.log` par `console.error` ou supprimer les logs de debug
---
### 3. Utilisation de `any` dans `lib/alby.ts`
**Lignes** : 8, 14, 18
**Problème** : Utilisation de `(window as any).webln` et `any` pour le type WebLN
**Action** : Créer une interface TypeScript pour WebLN ou utiliser un type plus strict
---
### 4. Non-null assertion dans `lib/storage/indexedDB.ts`
**Lignes** : 89, 114, 158
**Problème** : Utilisation de `this.db!` (non-null assertion) alors que `this.db` peut être null
**Action** : Ajouter des vérifications explicites avant utilisation
---
## 📋 Fonctionnalités manquantes (documentées)
### Priorité 3 - Améliorations du profil utilisateur
#### 1. Statistiques détaillées (vues, paiements)
**Status** : ⏳ À venir
**Fichiers concernés** : `components/UserProfile.tsx`, `pages/profile.tsx`
**À implémenter** :
- Compteur de vues par article
- Compteur de paiements reçus
- Revenus totaux (en sats)
- Graphiques de statistiques (optionnel)
**Complexité** : Moyenne
**Dépendances** : Nécessite de suivre les événements de lecture et les zap receipts
---
#### 2. Édition/suppression d'articles
**Status** : ⏳ À venir
**Fichiers concernés** : `components/UserArticles.tsx`, `pages/profile.tsx`
**À implémenter** :
- Bouton "Edit" sur chaque article
- Formulaire d'édition (pré-rempli avec les données actuelles)
- Publication d'un événement de mise à jour (kind:1 avec tag `e` pointant vers l'article original)
- Bouton "Delete" (marquer comme supprimé ou supprimer du relay)
- Confirmation avant suppression
**Complexité** : Moyenne à élevée
**Dépendances** : Gestion des événements de mise à jour Nostr
---
### Priorité 3 - Améliorations des notifications
#### 3. Types supplémentaires de notifications
**Status** : ⏳ À venir
**Fichiers concernés** : `lib/notifications.ts`, `types/notifications.ts`, `components/NotificationCenter.tsx`
**À implémenter** :
- Notifications de mentions (kind:1 avec tag `p` mentionnant l'utilisateur)
- Notifications de commentaires (réponses à des articles)
- Notifications de partages (reposts)
**Complexité** : Moyenne
**Dépendances** : Parsing des événements Nostr supplémentaires
---
### Priorité 3 - Améliorations du stockage
#### 4. Chiffrement des données sensibles
**Status** : ⏳ À venir (optionnel)
**Fichiers concernés** : `lib/storage/indexedDB.ts`, `lib/articleStorage.ts`
**À implémenter** :
- Chiffrement des contenus privés stockés dans IndexedDB
- Utilisation de Web Crypto API
- Gestion des clés de chiffrement
**Complexité** : Élevée
**Dépendances** : Web Crypto API, gestion sécurisée des clés
---
## 🧪 Tests (non planifié mais recommandé)
### Tests unitaires
**Status** : Non planifié
**Fichiers à tester** :
- `lib/nostr.ts` - Service Nostr
- `lib/alby.ts` - Service Alby/WebLN
- `lib/payment.ts` - Service de paiement
- `lib/articlePublisher.ts` - Publication d'articles
- `lib/nostrEventParsing.ts` - Parsing d'événements
**Framework recommandé** : Jest ou Vitest
---
### Tests de composants React
**Status** : Non planifié
**Composants à tester** :
- `components/ArticleCard.tsx`
- `components/PaymentModal.tsx`
- `components/ArticleEditor.tsx`
- `components/ConnectButton.tsx`
**Framework recommandé** : React Testing Library
---
### Tests d'intégration
**Status** : Non planifié
**Scénarios à tester** :
- Flux complet de publication d'article
- Flux complet de paiement et déblocage
- Connexion NostrConnect
- Recherche et filtrage
**Framework recommandé** : Playwright ou Cypress
---
## 📊 Analytics et monitoring (non planifié)
### Tracking des événements
**Status** : Non planifié
**Événements à tracker** :
- Publications d'articles
- Paiements effectués
- Connexions utilisateurs
- Recherches effectuées
- Erreurs rencontrées
**Fichiers à créer** : `lib/analytics.ts`
---
## 🔧 Améliorations techniques
### 1. Gestion d'erreurs améliorée
**Fichiers concernés** : Tous les services
**À implémenter** :
- Types d'erreurs personnalisés
- Messages d'erreur plus explicites pour l'utilisateur
- Logging structuré des erreurs
- Retry automatique pour les erreurs réseau
---
### 2. Performance et optimisation
**À implémenter** :
- Lazy loading des composants
- Mémoïsation des calculs coûteux
- Optimisation des requêtes relay
- Cache des profils utilisateurs
---
### 3. Accessibilité (a11y)
**À implémenter** :
- Support clavier complet
- Attributs ARIA appropriés
- Contraste des couleurs
- Navigation au clavier
---
## 📝 Documentation technique
### 1. Documentation API
**À créer** :
- Documentation des services (`lib/`)
- Documentation des hooks (`hooks/`)
- Documentation des types (`types/`)
---
### 2. Guide de contribution
**À créer** :
- `CONTRIBUTING.md` - Guide pour les contributeurs
- Standards de code
- Processus de review
---
## 🎯 Priorités recommandées
### Priorité 1 (Critique) - Bugs
1. ✅ **Corriger le bug `handleUnlockClick`** dans `ArticleCard.tsx`
2. ✅ **Remplacer les `console.log`** par `console.error` ou supprimer
### Priorité 2 (Important) - Qualité de code
3. ✅ **Corriger les types `any`** dans `lib/alby.ts`
4. ✅ **Corriger les non-null assertions** dans `lib/storage/indexedDB.ts`
### Priorité 3 (Améliorations) - Fonctionnalités
5. ⏳ Statistiques détaillées du profil
6. ⏳ Édition/suppression d'articles
7. ⏳ Types supplémentaires de notifications
8. ⏳ Chiffrement des données sensibles (optionnel)
### Priorité 4 (Optionnel) - Qualité
9. ⏳ Tests unitaires
10. ⏳ Tests d'intégration
11. ⏳ Analytics et monitoring
12. ⏳ Documentation technique
---
## 📊 Résumé
### Bugs identifiés : 1
- Bug critique dans `ArticleCard.tsx`
### Problèmes de code : 3
- Utilisation de `console.log` (violation ESLint)
- Types `any` dans `alby.ts`
- Non-null assertions dans `indexedDB.ts`
### Fonctionnalités manquantes : 4
- Statistiques détaillées du profil
- Édition/suppression d'articles
- Types supplémentaires de notifications
- Chiffrement des données sensibles (optionnel)
### Tests : 0 (non planifié)
- Tests unitaires
- Tests de composants
- Tests d'intégration
### Documentation : 2
- Documentation API
- Guide de contribution
---
## ✅ Actions immédiates recommandées
1. **Corriger le bug `handleUnlockClick`** - Impact critique sur l'utilisation
2. **Nettoyer les `console.log`** - Conformité avec les règles ESLint
3. **Améliorer les types TypeScript** - Qualité de code
4. **Ajouter les statistiques du profil** - Amélioration UX
---
## 📌 Notes
- L'application est fonctionnelle et prête pour la production
- Les bugs identifiés doivent être corrigés avant déploiement
- Les fonctionnalités manquantes sont des améliorations, pas des blocages
- Les tests peuvent être ajoutés progressivement
- La documentation technique facilitera la maintenance

View File

@ -0,0 +1,427 @@
# Refonte zapwall4Science - Spécifications
**Date** : Décembre 2024
**Auteur** : Équipe 4NK
## 🎯 Objectif
Transformation de Nostr Paywall en **zapwall4Science** : plateforme de publication d'articles scientifiques et de science-fiction avec système de sponsoring, commissions et rémunération des avis.
---
## 📝 Wording et terminologie
### Acteurs
- **Auteur** : Utilisateur qui publie des articles (science-fiction ou recherche scientifique)
- **Lecteur** : Utilisateur qui lit les articles et peut poster des avis
- **Site** : La plateforme zapwall4Science (recevant les commissions)
### Contenus
- **Article** : Publication d'un auteur (science-fiction ou recherche scientifique)
- **Article de présentation** : Article obligatoire créé par chaque auteur lors de son inscription, contenant sa présentation, description de son contenu et adresse de sponsoring mainnet
- **Avis** : Commentaire/évaluation d'un article par un lecteur qui a acheté l'article
- **Message d'envoi de l'article** : Message privé chiffré (kind:4) envoyé automatiquement après paiement d'un article
### Paiements
- **Paiement** : Transaction Bitcoin/Lightning pour accéder à un article ou sponsoriser un auteur
- **Sponsoring** : Paiement unique de 0.05 BTC pour sponsoriser un auteur (0.004 BTC au site, 0.046 BTC à l'auteur)
- **Commission pour le site sur la vente d'un article** : 100 sats sur chaque achat d'article (800 sats total, 700 à l'auteur)
- **Commission pour le site sur le remerciement d'un avis** : 21 sats sur chaque rémunération d'avis (70 sats au lecteur, 21 sats au site)
- **Remerciement pour l'avis** : Paiement de 70 sats (21 sats commission site) par l'auteur à un lecteur pour son avis
---
## 🏗️ Architecture fonctionnelle
### 1. Article de présentation obligatoire
**Description** : Chaque auteur doit créer un article de présentation lors de sa première connexion.
**Contenu obligatoire** :
- Titre : "Présentation de [Nom auteur]"
- Présentation personnelle de l'auteur
- Description/aperçu du type de contenu qu'il publie
- Adresse Bitcoin mainnet pour le sponsoring (obligatoire)
**Caractéristiques** :
- Article gratuit (pas de paiement requis)
- Visible par tous
- Tag spécial : `presentation: true`
- Tag : `category: author-presentation`
- Tag : `mainnet_address: [adresse]`
- Non supprimable (peut être édité)
- Un seul par auteur
**Fichiers à créer/modifier** :
- `components/AuthorPresentationEditor.tsx` : Éditeur d'article de présentation
- `lib/articlePublisher.ts` : Vérification de l'existence d'un article de présentation
- `hooks/useAuthorPresentation.ts` : Hook pour gérer l'article de présentation
- `types/nostr.ts` : Ajout du type `AuthorPresentationArticle`
---
### 2. Division en 2 catégories
**Catégories** :
1. **Science-fiction** (`category: science-fiction`)
2. **Recherche scientifique** (`category: scientific-research`)
**Fonctionnalités** :
- Les auteurs peuvent publier dans les 2 catégories
- Filtrage par catégorie sur la page d'accueil
- Onglets ou sections distinctes pour chaque catégorie
- Tag obligatoire `category` sur chaque article (sauf article de présentation)
**Fichiers à créer/modifier** :
- `components/CategoryFilter.tsx` : Filtre par catégorie
- `pages/index.tsx` : Affichage par catégories
- `lib/articleFiltering.ts` : Filtrage par catégorie
- `types/nostr.ts` : Ajout de `ArticleCategory`
---
### 3. Tri des articles
**Ordre de tri** :
1. **Par sponsoring** : Auteurs les plus sponsorisés en premier
2. **Par date** : Articles les plus récents en premier (parmi les auteurs de même niveau de sponsoring)
**Calcul du sponsoring** :
- Somme totale des paiements de sponsoring reçus par l'auteur
- Stocké dans un tag sur l'article de présentation : `total_sponsoring: [montant en sats]`
- Mis à jour à chaque nouveau sponsoring
**Fichiers à créer/modifier** :
- `lib/articleFiltering.ts` : Tri par sponsoring puis date
- `lib/sponsoring.ts` : Service de gestion du sponsoring
- `types/nostr.ts` : Ajout de `totalSponsoring` dans `Article`
---
### 4. Système de sponsoring
**Montant** : 0.05 BTC (5 000 000 sats)
**Répartition** :
- **Site** : 0.004 BTC (400 000 sats) - frais de transaction payés par l'auteur
- **Auteur** : 0.046 BTC (4 600 000 sats) - frais de transaction payés par l'auteur
**Fonctionnement** :
- Paiement unique vers l'adresse mainnet de l'auteur (depuis l'article de présentation)
- Transaction Bitcoin mainnet (pas Lightning)
- Mise à jour du tag `total_sponsoring` sur l'article de présentation
- Affichage du montant total sponsorisé sur le profil de l'auteur
**Fichiers à créer/modifier** :
- `components/SponsorButton.tsx` : Bouton de sponsoring
- `lib/sponsoring.ts` : Service de sponsoring
- `lib/bitcoinMainnet.ts` : Service pour les paiements mainnet
- `types/sponsoring.ts` : Types pour le sponsoring
---
### 5. Paiement d'article modifié (BOLT12)
**Montant** : 800 sats
**Répartition** :
- **Auteur** : 700 sats - frais de transaction payés par l'auteur
- **Site** : 100 sats - frais de transaction payés par l'auteur
**Technologie** : BOLT12 (offers) pour diviser automatiquement le paiement
**Fonctionnement** :
- Création d'une offre BOLT12 avec split automatique
- 700 sats vers l'adresse Lightning de l'auteur
- 100 sats vers l'adresse Lightning du site
- Frais de transaction payés par l'auteur (déduits de sa part)
**Fichiers à créer/modifier** :
- `lib/bolt12.ts` : Service BOLT12 pour les offres avec split
- `lib/payment.ts` : Modification pour utiliser BOLT12
- `types/payment.ts` : Types pour BOLT12
---
### 6. Système de rémunération des avis
**Conditions** :
- Seuls les lecteurs qui ont acheté l'article peuvent poster un avis
- L'auteur peut rémunérer un avis avec 70 sats
**Répartition** :
- **Lecteur (auteur de l'avis)** : 70 sats - frais de transaction payés par l'auteur
- **Site** : 21 sats - frais de transaction payés par l'auteur
**Fonctionnement** :
- Bouton "Remercier" sur chaque avis (visible uniquement par l'auteur de l'article)
- Paiement Lightning avec split automatique (BOLT12)
- Tag sur l'avis : `rewarded: true`, `reward_amount: 70`
**Fichiers à créer/modifier** :
- `components/ArticleReview.tsx` : Composant d'avis avec bouton de rémunération
- `components/ReviewRewardButton.tsx` : Bouton de rémunération d'avis
- `lib/reviewReward.ts` : Service de rémunération des avis
- `types/reviews.ts` : Types pour les avis
---
## 🔧 Modifications techniques
### Structure de données
#### Article de présentation
```typescript
interface AuthorPresentationArticle extends Article {
category: 'author-presentation'
mainnetAddress: string
totalSponsoring: number // en sats
isPresentation: true
}
```
#### Article standard
```typescript
interface StandardArticle extends Article {
category: 'science-fiction' | 'scientific-research'
authorPresentationId: string // ID de l'article de présentation
lightningAddress: string // Adresse Lightning de l'auteur
}
```
#### Avis
```typescript
interface Review {
id: string
articleId: string
authorPubkey: string // Auteur de l'article
reviewerPubkey: string // Lecteur qui a écrit l'avis
content: string
rating?: number // 1-5 étoiles
createdAt: number
rewarded: boolean
rewardAmount?: number // 70 sats si rémunéré
rewardTransactionId?: string
}
```
### Tags Nostr
#### Article de présentation
```
- title: "Présentation de [Nom]"
- category: "author-presentation"
- presentation: "true"
- mainnet_address: "[adresse Bitcoin]"
- total_sponsoring: "[montant en sats]"
```
#### Article standard
```
- title: "[Titre]"
- category: "science-fiction" | "scientific-research"
- preview: "[Aperçu]"
- zap: "800"
- author_presentation_id: "[ID article présentation]"
- lightning_address: "[adresse Lightning auteur]"
- invoice: "[BOLT12 offer]"
```
#### Avis (kind:1 avec tag spécial)
```
- review: "true"
- e: "[ID article]"
- p: "[pubkey auteur]"
- rating: "[1-5]"
- rewarded: "true" | "false"
- reward_amount: "70" (si rémunéré)
```
---
## 📁 Fichiers à créer
### Nouveaux composants
- `components/AuthorPresentationEditor.tsx`
- `components/CategoryFilter.tsx`
- `components/CategoryTabs.tsx`
- `components/SponsorButton.tsx`
- `components/ArticleReview.tsx`
- `components/ReviewRewardButton.tsx`
- `components/ReviewForm.tsx`
### Nouveaux services
- `lib/sponsoring.ts`
- `lib/bitcoinMainnet.ts`
- `lib/bolt12.ts`
- `lib/reviewReward.ts`
- `lib/reviews.ts`
### Nouveaux hooks
- `hooks/useAuthorPresentation.ts`
- `hooks/useSponsoring.ts`
- `hooks/useReviews.ts`
- `hooks/useReviewReward.ts`
### Nouveaux types
- `types/sponsoring.ts`
- `types/reviews.ts`
- `types/bolt12.ts`
---
## 📁 Fichiers à modifier
### Pages
- `pages/index.tsx` : Ajout des catégories et tri par sponsoring
- `pages/publish.tsx` : Sélection de catégorie obligatoire
- `pages/profile.tsx` : Affichage de l'article de présentation et sponsoring
### Composants existants
- `components/ArticleCard.tsx` : Affichage catégorie et sponsoring
- `components/ArticleEditor.tsx` : Sélection de catégorie
- `components/UserProfile.tsx` : Affichage sponsoring total
### Services existants
- `lib/articlePublisher.ts` : Vérification article de présentation
- `lib/payment.ts` : Migration vers BOLT12
- `lib/articleFiltering.ts` : Tri par sponsoring
- `lib/nostr.ts` : Parsing des nouveaux tags
### Types existants
- `types/nostr.ts` : Ajout des nouveaux types
---
## 🔄 Flux utilisateur
### Inscription d'un auteur
1. Connexion avec Nostr
2. Redirection vers création d'article de présentation (obligatoire)
3. Remplissage : présentation, description, adresse mainnet
4. Publication de l'article de présentation
5. Accès à la plateforme
### Publication d'un article
1. Sélection de la catégorie (science-fiction ou recherche)
2. Remplissage du formulaire (titre, preview, contenu)
3. Création de l'offre BOLT12 avec split (700/100)
4. Publication avec tags appropriés
### Achat d'un article
1. Clic sur "Unlock for 800 sats"
2. Affichage de l'offre BOLT12
3. Paiement Lightning
4. Réception automatique du contenu (message privé)
5. Possibilité de poster un avis
### Sponsoring d'un auteur
1. Clic sur "Sponsor" sur l'article de présentation
2. Affichage de l'adresse mainnet
3. Paiement Bitcoin mainnet de 0.05 BTC
4. Mise à jour du sponsoring total
5. Tri mis à jour
### Rémunération d'un avis
1. Auteur voit l'avis d'un lecteur qui a acheté
2. Clic sur "Remercier (70 sats)"
3. Paiement Lightning avec split (70/21)
4. Tag `rewarded: true` sur l'avis
---
## ⚠️ Points d'attention
### BOLT12
- Vérifier la compatibilité avec Alby/WebLN pour BOLT12
- Implémenter le split automatique dans l'offre
- Gérer les frais de transaction
### Bitcoin Mainnet
- Intégration avec un service de paiement mainnet (ou QR code)
- Vérification des paiements mainnet
- Gestion des confirmations
### Sécurité
- Vérifier que seuls les acheteurs peuvent poster des avis
- Vérifier que seul l'auteur peut rémunérer les avis
- Vérifier l'authenticité des paiements
### Performance
- Indexer les articles par catégorie
- Indexer les auteurs par sponsoring total
- Cache des calculs de tri
---
## 📊 Priorités d'implémentation
### Phase 1 - Fondations
1. ✅ Wording et terminologie (documentation)
2. ⏳ Article de présentation obligatoire
3. ⏳ Division en catégories
4. ⏳ Tri par sponsoring puis date
### Phase 2 - Paiements
5. ⏳ Système de sponsoring (Bitcoin mainnet)
6. ⏳ Migration vers BOLT12 pour les articles
7. ⏳ Split automatique des paiements
### Phase 3 - Avis et rémunération
8. ⏳ Système d'avis (seulement pour acheteurs)
9. ⏳ Rémunération des avis par l'auteur
### Phase 4 - UI/UX
10. ⏳ Interface par catégories
11. ⏳ Affichage du sponsoring
12. ⏳ Interface de rémunération des avis
---
## 🎨 Renommage
- **Nom du site** : zapwall4Science
- **Titre** : "zapwall4Science - Science Fiction & Scientific Research"
- **Description** : "Plateforme de publication d'articles scientifiques et de science-fiction avec sponsoring et rémunération des avis"
**Fichiers à modifier** :
- `pages/index.tsx` : Titre et description
- `pages/_app.tsx` : Titre par défaut
- `README.md` : Nom et description
- Tous les composants avec "Nostr Paywall" → "zapwall4Science"
---
## 📝 Notes techniques
### BOLT12 Offers
- Utiliser `lightning:lno1...` pour les offres
- Split automatique via les métadonnées de l'offre
- Vérifier la compatibilité avec les wallets Lightning
### Bitcoin Mainnet
- Utiliser une API de vérification de transactions (blockchain.info, blockstream, etc.)
- QR code pour faciliter les paiements
- Affichage clair de l'adresse et du montant
### Stockage
- Stocker les montants de sponsoring dans IndexedDB
- Cache des calculs de tri
- Synchronisation avec les événements Nostr
---
## ✅ Checklist de validation
- [ ] Article de présentation créé pour chaque auteur
- [ ] Catégories fonctionnelles (science-fiction / recherche)
- [ ] Tri par sponsoring puis date opérationnel
- [ ] Sponsoring Bitcoin mainnet fonctionnel
- [ ] Paiements articles avec BOLT12 et split
- [ ] Avis uniquement pour les acheteurs
- [ ] Rémunération des avis fonctionnelle
- [ ] Wording cohérent partout
- [ ] Renommage complet en zapwall4Science

View File

@ -0,0 +1,86 @@
import { useState } from 'react'
import { nostrService } from '@/lib/nostr'
import { articlePublisher } from '@/lib/articlePublisher'
import type { Article } from '@/types/nostr'
interface AuthorPresentationDraft {
presentation: string
contentDescription: string
mainnetAddress: string
}
export function useAuthorPresentation(pubkey: string | null) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const publishPresentation = async (draft: AuthorPresentationDraft): Promise<void> => {
if (!pubkey) {
setError('Clé publique non disponible')
return
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
setError('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
setLoading(false)
return
}
// Create presentation article
const title = `Présentation de ${nostrService.getPublicKey()?.substring(0, 16)}...`
const preview = draft.presentation.substring(0, 200)
const fullContent = `${draft.presentation}\n\n---\n\nDescription du contenu :\n${draft.contentDescription}`
const result = await articlePublisher.publishPresentationArticle(
{
title,
preview,
content: fullContent,
presentation: draft.presentation,
contentDescription: draft.contentDescription,
mainnetAddress: draft.mainnetAddress,
},
pubkey,
privateKey
)
if (result.success) {
setSuccess(true)
} else {
setError(result.error ?? 'Erreur lors de la publication')
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
console.error('Error publishing presentation:', e)
setError(errorMessage)
} finally {
setLoading(false)
}
}
const checkPresentationExists = async (): Promise<Article | null> => {
if (!pubkey) {
return null
}
try {
return await articlePublisher.getAuthorPresentation(pubkey)
} catch (e) {
console.error('Error checking presentation:', e)
return null
}
}
return {
loading,
error,
success,
publishPresentation,
checkPresentationExists,
}
}

View File

@ -1,21 +1,29 @@
import type { AlbyInvoice, AlbyPaymentStatus, AlbyInvoiceRequest } from '@/types/alby'
import type {
AlbyInvoice,
AlbyPaymentStatus,
AlbyInvoiceRequest,
WebLNProvider,
} from '@/types/alby'
import { retryWithBackoff, isRetryableNetworkError } from './retry'
/**
* Check if WebLN (Lightning wallet) is available in the browser
*/
export function isWebLNAvailable(): boolean {
return typeof window !== 'undefined' && typeof (window as any).webln !== 'undefined'
return typeof window !== 'undefined' && typeof window.webln !== 'undefined'
}
/**
* Get WebLN provider (Alby or other Lightning wallet)
*/
function getWebLN(): any {
function getWebLN(): WebLNProvider {
if (typeof window === 'undefined') {
throw new Error('WebLN is only available in the browser')
}
return (window as any).webln
if (!window.webln) {
throw new Error('WebLN provider not available')
}
return window.webln
}
/**
@ -39,7 +47,7 @@ export class AlbyService {
try {
await webln.enable()
} catch (error) {
} catch (_error) {
throw new Error('User rejected WebLN permission request')
}
}
@ -69,7 +77,7 @@ export class AlbyService {
// Use makeInvoice method from WebLN
const invoiceResponse = await webln.makeInvoice({
amount: request.amount,
defaultMemo: request.description || 'Nostr Paywall Payment',
defaultMemo: request.description ?? 'zapwall4Science Payment',
})
// Extract payment hash from invoice using a simple approach
@ -80,7 +88,7 @@ export class AlbyService {
invoice: invoiceResponse.paymentRequest,
paymentHash,
amount: request.amount,
expiresAt: Math.floor(Date.now() / 1000) + (request.expiry || 3600),
expiresAt: Math.floor(Date.now() / 1000) + (request.expiry ?? 3600),
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
@ -128,7 +136,7 @@ export class AlbyService {
* Note: With WebLN, we typically verify payment by checking zap receipts on Nostr
* since WebLN doesn't provide a direct way to check invoice status
*/
async checkPaymentStatus(paymentHash: string): Promise<AlbyPaymentStatus> {
checkPaymentStatus(paymentHash: string): AlbyPaymentStatus {
// WebLN doesn't have a direct method to check payment status
// We'll rely on Nostr zap receipts for verification
return {
@ -142,7 +150,7 @@ export class AlbyService {
* Since WebLN doesn't provide payment status checking,
* we'll rely on zap receipt verification on Nostr
*/
async waitForPayment(
waitForPayment(
paymentHash: string,
timeout: number = 300000, // 5 minutes default
interval: number = 2000 // 2 seconds default
@ -152,9 +160,9 @@ export class AlbyService {
const startTime = Date.now()
return new Promise((resolve) => {
const checkPayment = async () => {
const checkPayment = () => {
try {
const status = await this.checkPaymentStatus(paymentHash)
const status = this.checkPaymentStatus(paymentHash)
if (status.paid) {
resolve(status)
@ -167,7 +175,7 @@ export class AlbyService {
}
setTimeout(checkPayment, interval)
} catch (error) {
} catch (_error) {
resolve({ paid: false, paymentHash })
}
}
@ -202,8 +210,6 @@ export class AlbyService {
let albyServiceInstance: AlbyService | null = null
export function getAlbyService(): AlbyService {
if (!albyServiceInstance) {
albyServiceInstance = new AlbyService()
}
albyServiceInstance ??= new AlbyService()
return albyServiceInstance
}

View File

@ -21,11 +21,19 @@ export function filterArticlesBySearch(articles: Article[], searchQuery: string)
}
/**
* Filter articles based on filters (author, price)
* Filter articles based on filters (author, price, category)
*/
export function filterArticles(articles: Article[], filters: ArticleFilters): Article[] {
let filtered = articles
// Exclude presentation articles from standard article lists
filtered = filtered.filter((article) => !article.isPresentation)
// Filter by category
if (filters.category && filters.category !== 'all') {
filtered = filtered.filter((article) => article.category === filters.category)
}
// Filter by author
if (filters.authorPubkey) {
filtered = filtered.filter((article) => article.pubkey === filters.authorPubkey)
@ -33,27 +41,45 @@ export function filterArticles(articles: Article[], filters: ArticleFilters): Ar
// Filter by min price
if (filters.minPrice !== null) {
filtered = filtered.filter((article) => article.zapAmount >= filters.minPrice!)
const minPrice = filters.minPrice
filtered = filtered.filter((article) => article.zapAmount >= minPrice)
}
// Filter by max price
if (filters.maxPrice !== null) {
filtered = filtered.filter((article) => article.zapAmount <= filters.maxPrice!)
const maxPrice = filters.maxPrice
filtered = filtered.filter((article) => article.zapAmount <= maxPrice)
}
return filtered
}
/**
* Sort articles based on sort option
* Get author sponsoring from their presentation article
* We need to look up the sponsoring from presentation articles
* For now, we'll use a cache or extract from already loaded articles
*/
export function sortArticles(articles: Article[], sortBy: SortOption): Article[] {
function getAuthorSponsoringFromCache(
pubkey: string,
presentationArticles: Map<string, Article>
): number {
const presentation = presentationArticles.get(pubkey)
return presentation?.isPresentation ? presentation.totalSponsoring ?? 0 : 0
}
/**
* Sort articles based on sort option
* Default sort: by sponsoring (descending) then by date (newest first)
*/
export function sortArticles(
articles: Article[],
sortBy: SortOption,
presentationArticles?: Map<string, Article>
): Article[] {
const sorted = [...articles]
const presentationMap = presentationArticles ?? new Map<string, Article>()
switch (sortBy) {
case 'newest':
return sorted.sort((a, b) => b.createdAt - a.createdAt)
case 'oldest':
return sorted.sort((a, b) => a.createdAt - b.createdAt)
@ -63,8 +89,21 @@ export function sortArticles(articles: Article[], sortBy: SortOption): Article[]
case 'price-high':
return sorted.sort((a, b) => b.zapAmount - a.zapAmount)
case 'newest':
default:
return sorted.sort((a, b) => b.createdAt - a.createdAt)
// Default: sort by sponsoring (descending) then by date (newest first)
return sorted.sort((a, b) => {
const sponsoringA = getAuthorSponsoringFromCache(a.pubkey, presentationMap)
const sponsoringB = getAuthorSponsoringFromCache(b.pubkey, presentationMap)
// First sort by sponsoring (descending)
if (sponsoringA !== sponsoringB) {
return sponsoringB - sponsoringA
}
// Then sort by date (newest first)
return b.createdAt - a.createdAt
})
}
}
@ -74,7 +113,8 @@ export function sortArticles(articles: Article[], sortBy: SortOption): Article[]
export function applyFiltersAndSort(
articles: Article[],
searchQuery: string,
filters: ArticleFilters
filters: ArticleFilters,
presentationArticles?: Map<string, Article>
): Article[] {
let result = articles
@ -84,8 +124,8 @@ export function applyFiltersAndSort(
// Then apply other filters
result = filterArticles(result, filters)
// Finally apply sorting
result = sortArticles(result, filters.sortBy)
// Finally apply sorting (with presentation articles for sponsoring lookup)
result = sortArticles(result, filters.sortBy, presentationArticles)
return result
}

View File

@ -16,30 +16,45 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
expiry: 86400, // 24 hours
})
console.log('Invoice created by author:', invoice)
return invoice
}
/**
* Create preview event with invoice tags
*/
export function createPreviewEvent(draft: ArticleDraft, invoice: AlbyInvoice): {
export function createPreviewEvent(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPresentationId?: string
): {
kind: 1
created_at: number
tags: string[][]
content: string
} {
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags: [
const tags: string[][] = [
['title', draft.title],
['preview', draft.preview],
['zap', draft.zapAmount.toString()],
['content-type', 'article'],
['invoice', invoice.invoice],
['payment_hash', invoice.paymentHash],
],
]
// Add category if specified
if (draft.category) {
tags.push(['category', draft.category])
}
// Add author presentation ID if provided
if (authorPresentationId) {
tags.push(['author_presentation_id', authorPresentationId])
}
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags,
content: draft.preview,
}
}

View File

@ -1,7 +1,6 @@
import { nostrService } from './nostr'
import { nip04 } from 'nostr-tools'
import type { Article } from '@/types/nostr'
import type { AlbyInvoice } from '@/types/alby'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import {
storePrivateContent,
getStoredPrivateContent,
@ -9,12 +8,23 @@ import {
removeStoredPrivateContent,
} from './articleStorage'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
export interface ArticleDraft {
title: string
preview: string
content: string // Full content that will be sent as private message after payment
zapAmount: number
category?: 'science-fiction' | 'scientific-research'
}
export interface AuthorPresentationDraft {
title: string
preview: string
content: string
presentation: string
contentDescription: string
mainnetAddress: string
}
export interface PublishedArticle {
@ -30,6 +40,49 @@ export interface PublishedArticle {
* Handles publishing preview (public note), creating invoice, and storing full content for later private message
*/
export class ArticlePublisher {
private buildFailure(error?: string): PublishedArticle {
return {
articleId: '',
previewEventId: '',
success: false,
error,
}
}
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
nostrService.setPublicKey(authorPubkey)
if (authorPrivateKey) {
nostrService.setPrivateKey(authorPrivateKey)
return { success: true }
}
const existingPrivateKey = nostrService.getPrivateKey()
if (!existingPrivateKey) {
return {
success: false,
error:
'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
}
}
return { success: true }
}
private isValidCategory(category?: ArticleDraft['category']): category is ArticleDraft['category'] {
return category === 'science-fiction' || category === 'scientific-research'
}
private async publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
presentationId: string
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
/**
* Publish an article preview as a public note (kind:1)
* Creates a Lightning invoice for the article
@ -41,68 +94,39 @@ export class ArticlePublisher {
authorPrivateKey?: string
): Promise<PublishedArticle> {
try {
// Set author public key for publishing
nostrService.setPublicKey(authorPubkey)
// Set private key if provided (for direct signing)
// If not provided, will attempt to use remote signing
if (authorPrivateKey) {
nostrService.setPrivateKey(authorPrivateKey)
} else {
// Try to get private key from service (might be set by NostrConnect)
const existingPrivateKey = nostrService.getPrivateKey()
if (!existingPrivateKey) {
return {
articleId: '',
previewEventId: '',
success: false,
error: 'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
}
}
const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey)
if (!keySetup.success) {
return this.buildFailure(keySetup.error)
}
const presentation = await this.getAuthorPresentation(authorPubkey)
if (!presentation) {
return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.')
}
if (!this.isValidCategory(draft.category)) {
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
// Create Lightning invoice via Alby/WebLN (author creates the invoice)
const invoice = await createArticleInvoice(draft)
// Create public note with preview and invoice
const previewEvent = createPreviewEvent(draft, invoice)
const publishedEvent = await nostrService.publishEvent(previewEvent)
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id)
if (!publishedEvent) {
return {
articleId: '',
previewEventId: '',
success: false,
error: 'Failed to publish article',
}
return this.buildFailure('Failed to publish article')
}
// Store the full content associated with this article ID
// Also store the invoice if created
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
return {
articleId: publishedEvent.id,
previewEventId: publishedEvent.id,
invoice,
success: true,
}
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
} catch (error) {
console.error('Error publishing article:', error)
return {
articleId: '',
previewEventId: '',
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
}
}
/**
* Get stored private content for an article
*/
async getStoredPrivateContent(articleId: string): Promise<{
getStoredPrivateContent(articleId: string): Promise<{
content: string
authorPubkey: string
invoice?: AlbyInvoice
@ -113,7 +137,7 @@ export class ArticlePublisher {
/**
* Get stored invoice for an article
*/
async getStoredInvoice(articleId: string): Promise<AlbyInvoice | null> {
getStoredInvoice(articleId: string): Promise<AlbyInvoice | null> {
return getStoredInvoice(articleId)
}
@ -127,44 +151,14 @@ export class ArticlePublisher {
authorPrivateKey: string
): Promise<boolean> {
try {
// Get stored private content
const stored = await getStoredPrivateContent(articleId)
if (!stored) {
console.error('Private content not found for article:', articleId)
return false
}
// Set author keys
nostrService.setPublicKey(authorPubkey)
nostrService.setPrivateKey(authorPrivateKey)
// Encrypt content using NIP-04
const encryptedContent = await nip04.encrypt(
authorPrivateKey,
recipientPubkey,
stored.content
)
// Create encrypted direct message (kind:4)
const privateMessageEvent = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', recipientPubkey],
['e', articleId], // Link to the article
],
content: encryptedContent,
}
const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
if (publishedEvent) {
// Optionally remove stored content after successful send
// this.removeStoredPrivateContent(articleId)
return true
}
return false
const sent = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
return sent
} catch (error) {
console.error('Error sending private content:', error)
return false
@ -177,6 +171,52 @@ export class ArticlePublisher {
async removeStoredPrivateContent(articleId: string): Promise<void> {
await removeStoredPrivateContent(articleId)
}
/**
* Publish an author presentation article (obligatory for all authors)
* This article is free and contains the author's presentation, content description, and mainnet address
*/
async publishPresentationArticle(
draft: AuthorPresentationDraft,
authorPubkey: string,
authorPrivateKey: string
): Promise<PublishedArticle> {
try {
nostrService.setPublicKey(authorPubkey)
nostrService.setPrivateKey(authorPrivateKey)
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft))
if (!publishedEvent) {
return this.buildFailure('Failed to publish presentation article')
}
return {
articleId: publishedEvent.id,
previewEventId: publishedEvent.id,
success: true,
}
} catch (error) {
console.error('Error publishing presentation article:', error)
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
}
}
/**
* Get author presentation article by pubkey
*/
getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
try {
const pool = nostrService.getPool()
if (!pool) {
return null
}
return fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
} catch (error) {
console.error('Error getting author presentation:', error)
return null
}
}
}
export const articlePublisher = new ArticlePublisher()

View File

@ -0,0 +1,111 @@
import { nip04, type Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export function buildPresentationEvent(draft: AuthorPresentationDraft) {
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags: [
['title', draft.title],
['preview', draft.preview],
['category', 'author-presentation'],
['presentation', 'true'],
['mainnet_address', draft.mainnetAddress],
['total_sponsoring', '0'],
['content-type', 'author-presentation'],
],
content: draft.content,
}
}
export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
const isPresentation = event.tags.some((tag) => tag[0] === 'presentation' && tag[1] === 'true')
if (!isPresentation) {
return null
}
const mainnetAddressTag = event.tags.find((tag) => tag[0] === 'mainnet_address')
const sponsoringTag = event.tags.find((tag) => tag[0] === 'total_sponsoring')
return {
id: event.id,
pubkey: event.pubkey,
title: event.tags.find((tag) => tag[0] === 'title')?.[1] ?? 'Présentation',
preview: event.tags.find((tag) => tag[0] === 'preview')?.[1] ?? event.content.substring(0, 200),
content: event.content,
createdAt: event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: mainnetAddressTag?.[1] ?? '',
totalSponsoring: sponsoringTag ? parseInt(sponsoringTag[1] ?? '0', 10) : 0,
}
}
export function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub,
pubkey: string
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const filters = [
{
kinds: [1],
authors: [pubkey],
'#category': ['author-presentation'],
limit: 1,
},
]
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], filters)
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: Event) => {
const parsed = parsePresentationEvent(event)
if (parsed) {
finalize(parsed)
}
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
})
}
export async function sendEncryptedContent(
articleId: string,
recipientPubkey: string,
storedContent: { content: string; authorPubkey: string },
authorPrivateKey: string
): Promise<boolean> {
nostrService.setPrivateKey(authorPrivateKey)
nostrService.setPublicKey(storedContent.authorPubkey)
const encryptedContent = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, storedContent.content))
const privateMessageEvent = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', recipientPubkey],
['e', articleId],
],
content: encryptedContent,
}
const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
return Boolean(publishedEvent)
}

View File

@ -66,7 +66,9 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
const key = `article_private_content_${articleId}`
const data = await storageService.get<StoredArticleData>(key)
if (!data) return null
if (!data) {
return null
}
return {
content: data.content,
@ -91,7 +93,7 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
*/
export async function getStoredInvoice(articleId: string): Promise<AlbyInvoice | null> {
const stored = await getStoredPrivateContent(articleId)
return stored?.invoice || null
return stored?.invoice ?? null
}
/**

View File

@ -5,7 +5,7 @@ import type { AlbyInvoice } from '@/types/alby'
* Resolve invoice for article payment
* Uses invoice from event tags only
*/
export async function resolveArticleInvoice(article: Article): Promise<AlbyInvoice> {
export function resolveArticleInvoice(article: Article): Promise<AlbyInvoice> {
if (!article.invoice || !article.paymentHash) {
throw new Error('Article does not have an invoice. The author must create an invoice when publishing the article.')
}
@ -20,5 +20,5 @@ export async function resolveArticleInvoice(article: Article): Promise<AlbyInvoi
expiresAt: Math.floor(Date.now() / 1000) + 86400, // Assume 24h validity
}
return invoice
return Promise.resolve(invoice)
}

View File

@ -17,6 +17,15 @@ export function renderMarkdown(markdown: string): JSX.Element[] {
}
lines.forEach((line, index) => {
processLine(line, index, state, elements)
})
closeListIfNeeded('', lines.length, state, elements)
return elements
}
function processLine(line: string, index: number, state: RenderState, elements: JSX.Element[]): void {
if (line.startsWith('```')) {
handleCodeBlock(line, index, state, elements)
return
@ -29,30 +38,73 @@ export function renderMarkdown(markdown: string): JSX.Element[] {
closeListIfNeeded(line, index, state, elements)
if (renderHeading(line, index, elements)) {
return
}
if (renderListLine(line, state)) {
return
}
if (renderLinkLine(line, index, elements)) {
return
}
if (renderBoldAndCodeLine(line, index, elements)) {
return
}
renderParagraphOrBreak(line, index, elements)
}
function renderHeading(line: string, index: number, elements: JSX.Element[]): boolean {
if (line.startsWith('# ')) {
elements.push(<h1 key={index} className="text-3xl font-bold mt-8 mb-4">{line.substring(2)}</h1>)
} else if (line.startsWith('## ')) {
return true
}
if (line.startsWith('## ')) {
elements.push(<h2 key={index} className="text-2xl font-bold mt-6 mb-3">{line.substring(3)}</h2>)
} else if (line.startsWith('### ')) {
return true
}
if (line.startsWith('### ')) {
elements.push(<h3 key={index} className="text-xl font-semibold mt-4 mb-2">{line.substring(4)}</h3>)
} else if (line.startsWith('#### ')) {
return true
}
if (line.startsWith('#### ')) {
elements.push(<h4 key={index} className="text-lg font-semibold mt-3 mb-2">{line.substring(5)}</h4>)
} else if (line.startsWith('- ') || line.startsWith('* ')) {
return true
}
return false
}
function renderListLine(line: string, state: RenderState): boolean {
if (line.startsWith('- ') || line.startsWith('* ')) {
state.currentList.push(line)
} else if (line.includes('[') && line.includes('](')) {
return true
}
return false
}
function renderLinkLine(line: string, index: number, elements: JSX.Element[]): boolean {
if (line.includes('[') && line.includes('](')) {
renderLink(line, index, elements)
} else if (line.includes('**') || line.includes('`')) {
return true
}
return false
}
function renderBoldAndCodeLine(line: string, index: number, elements: JSX.Element[]): boolean {
if (line.includes('**') || line.includes('`')) {
renderBoldAndCode(line, index, elements)
} else if (line.trim() !== '') {
return true
}
return false
}
function renderParagraphOrBreak(line: string, index: number, elements: JSX.Element[]): void {
if (line.trim() !== '') {
elements.push(<p key={index} className="mb-4 text-gray-700">{line}</p>)
} else if (elements.length > 0 && elements[elements.length - 1].type !== 'br') {
return
}
if (elements.length > 0 && elements[elements.length - 1].type !== 'br') {
elements.push(<br key={`br-${index}`} />)
}
})
closeListIfNeeded('', lines.length, state, elements)
return elements
}
function handleCodeBlock(

View File

@ -1,18 +1,12 @@
import {
Event,
EventTemplate,
getEventHash,
signEvent,
nip19,
SimplePool,
nip04
} from 'nostr-tools'
import { Event, EventTemplate, getEventHash, signEvent, nip19, SimplePool } from 'nostr-tools'
import type { Article, NostrProfile } from '@/types/nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { parseArticleFromEvent } from './nostrEventParsing'
import { getPrivateContent } from './nostrPrivateMessages'
import { getPrivateContent as getPrivateContentFromPool } from './nostrPrivateMessages'
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
import { subscribeWithTimeout } from './nostrSubscription'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL || 'wss://relay.damus.io'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
class NostrService {
private pool: SimplePool | null = null
@ -33,10 +27,10 @@ class NostrService {
this.privateKey = privateKey
try {
const decoded = nip19.decode(privateKey)
if (decoded.type === 'nsec') {
this.privateKey = decoded.data as string
if (decoded.type === 'nsec' && typeof decoded.data === 'string') {
this.privateKey = decoded.data
}
} catch (e) {
} catch (_e) {
// Assume it's already a hex string
}
}
@ -53,10 +47,10 @@ class NostrService {
this.publicKey = publicKey
try {
const decoded = nip19.decode(publicKey)
if (decoded.type === 'npub') {
this.publicKey = decoded.data as string
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
this.publicKey = decoded.data
}
} catch (e) {
} catch (_e) {
// Assume it's already a hex string
}
}
@ -81,10 +75,7 @@ class NostrService {
}
}
async subscribeToArticles(
callback: (article: Article) => void,
limit: number = 100
): Promise<() => void> {
subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void {
if (typeof window === 'undefined') {
throw new Error('Cannot subscribe on server side')
}
@ -99,12 +90,13 @@ class NostrService {
const filters = [
{
kinds: [1], // Text notes
kinds: [1], // Text notes (includes both articles and presentation articles)
limit,
},
]
const sub = (this.pool as any).sub([RELAY_URL], filters)
const pool = this.pool as SimplePoolWithSub
const sub = pool.sub([RELAY_URL], filters)
sub.on('event', (event: Event) => {
try {
@ -122,7 +114,7 @@ class NostrService {
}
}
async getArticleById(eventId: string): Promise<Article | null> {
getArticleById(eventId: string): Promise<Article | null> {
if (!this.pool) {
throw new Error('Pool not initialized')
}
@ -131,63 +123,19 @@ class NostrService {
return subscribeWithTimeout(this.pool, filters, parseArticleFromEvent, 5000)
}
async getPrivateContent(eventId: string, authorPubkey: string): Promise<string | null> {
getPrivateContent(eventId: string, authorPubkey: string): Promise<string | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
throw new Error('Private key not set or pool not initialized')
}
return new Promise(async (resolve) => {
const filters = [
{
kinds: [4], // Encrypted direct messages
'#p': [this.publicKey],
limit: 100,
},
]
let resolved = false
const sub = (this.pool as any).sub([RELAY_URL], filters)
sub.on('event', async (event: Event) => {
if (!resolved && event.tags.some((tag) => tag[0] === 'e' && tag[1] === eventId)) {
try {
// Decrypt the content using nip04
const content = await nip04.decrypt(this.privateKey!, authorPubkey, event.content)
if (content) {
resolved = true
sub.unsub()
resolve(content)
}
} catch (e) {
console.error('Error decrypting content:', e)
}
}
})
sub.on('eose', () => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(null)
}
})
setTimeout(() => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(null)
}
}, 5000)
})
return getPrivateContentFromPool(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
}
async getProfile(pubkey: string): Promise<NostrProfile | null> {
getProfile(pubkey: string): Promise<NostrProfile | null> {
if (!this.pool) {
throw new Error('Pool not initialized')
}
return new Promise((resolve) => {
const filters = [
{
kinds: [0],
@ -196,40 +144,17 @@ class NostrService {
},
]
let resolved = false
const sub = (this.pool as any).sub([RELAY_URL], filters)
sub.on('event', (event: Event) => {
if (!resolved) {
resolved = true
const parseProfile = (event: Event) => {
try {
const profile = JSON.parse(event.content) as NostrProfile
profile.pubkey = pubkey
sub.unsub()
resolve(profile)
} catch (e) {
sub.unsub()
resolve(null)
return { ...profile, pubkey }
} catch (error) {
console.error('Error parsing profile:', error)
return null
}
}
})
sub.on('eose', () => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(null)
}
})
setTimeout(() => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(null)
}
}, 5000)
})
return subscribeWithTimeout(this.pool, filters, parseProfile, 5000)
}
@ -258,7 +183,7 @@ class NostrService {
}
// Check if user has paid for an article by looking for zap receipts
async checkZapReceipt(
checkZapReceipt(
targetPubkey: string,
targetEventId: string,
amount: number,
@ -269,7 +194,7 @@ class NostrService {
}
// Use provided userPubkey or fall back to current public key
const checkPubkey = userPubkey || this.publicKey
const checkPubkey = userPubkey ?? this.publicKey
return checkZapReceiptHelper(this.pool, targetPubkey, targetEventId, amount, checkPubkey)
}

View File

@ -6,41 +6,53 @@ import type { Article } from '@/types/nostr'
*/
export function parseArticleFromEvent(event: Event): Article | null {
try {
const content = event.content
// Parse article metadata from tags
const titleTag = event.tags.find((tag) => tag[0] === 'title')
const previewTag = event.tags.find((tag) => tag[0] === 'preview')
const zapTag = event.tags.find((tag) => tag[0] === 'zap')
const invoiceTag = event.tags.find((tag) => tag[0] === 'invoice')
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')
const title = titleTag?.[1] || 'Untitled'
const preview = previewTag?.[1] || content.substring(0, 200)
const zapAmount = zapTag ? parseInt(zapTag[1] || '800') : 800
// Extract invoice information from tags
const invoice = invoiceTag?.[1] || undefined
const paymentHash = paymentHashTag?.[1] || undefined
// Split content: preview is in the note, full content is in private message
const lines = content.split('\n')
const previewContent = preview || lines[0] || content.substring(0, 200)
return {
id: event.id,
pubkey: event.pubkey,
title,
preview: previewContent,
content: '', // Full content will be loaded from private message
createdAt: event.created_at,
zapAmount,
paid: false,
invoice,
paymentHash,
}
const tags = extractTags(event)
const { previewContent } = getPreviewContent(event.content, tags.preview)
return buildArticle(event, tags, previewContent)
} catch (e) {
console.error('Error parsing article:', e)
return null
}
}
function extractTags(event: Event) {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
return {
title: findTag('title') ?? 'Untitled',
preview: findTag('preview'),
zapAmount: parseInt(findTag('zap') ?? '800', 10),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
category: findTag('category') as import('@/types/nostr').ArticleCategory | undefined,
isPresentation: findTag('presentation') === 'true',
mainnetAddress: findTag('mainnet_address'),
totalSponsoring: parseInt(findTag('total_sponsoring') ?? '0', 10),
authorPresentationId: findTag('author_presentation_id'),
}
}
function getPreviewContent(content: string, previewTag?: string) {
const lines = content.split('\n')
const previewContent = previewTag ?? lines[0] ?? content.substring(0, 200)
return { previewContent }
}
function buildArticle(event: Event, tags: ReturnType<typeof extractTags>, preview: string): Article {
return {
id: event.id,
pubkey: event.pubkey,
title: tags.title,
preview,
content: '',
createdAt: event.created_at,
zapAmount: tags.zapAmount,
paid: false,
invoice: tags.invoice,
paymentHash: tags.paymentHash,
category: tags.category,
isPresentation: tags.isPresentation,
mainnetAddress: tags.mainnetAddress,
totalSponsoring: tags.totalSponsoring,
authorPresentationId: tags.authorPresentationId,
}
}

View File

@ -1,12 +1,30 @@
import { Event, nip04 } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL || 'wss://relay.damus.io'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string) {
return [
{
kinds: [4], // Encrypted direct messages
'#p': [publicKey],
'#e': [eventId], // Filter by event ID to find relevant private messages
authors: [authorPubkey], // Filter by author of the original article
limit: 10, // Limit to recent messages
},
]
}
function decryptContent(privateKey: string, event: Event): Promise<string | null> {
return Promise.resolve(nip04.decrypt(privateKey, event.pubkey, event.content)).then((decrypted) =>
decrypted ? decrypted : null
)
}
/**
* Get private content for an article (encrypted message from author)
*/
export async function getPrivateContent(
export function getPrivateContent(
pool: SimplePool,
eventId: string,
authorPubkey: string,
@ -17,50 +35,31 @@ export async function getPrivateContent(
throw new Error('Private key not set or pool not initialized')
}
return new Promise(async (resolve) => {
const filters = [
{
kinds: [4], // Encrypted direct messages
'#p': [publicKey],
'#e': [eventId], // Filter by event ID to find relevant private messages
authors: [authorPubkey], // Filter by author of the original article
limit: 10, // Limit to recent messages
},
]
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], filters)
const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
sub.on('event', async (event: Event) => {
if (!resolved) {
try {
// Decrypt the content using nip04
const content = await nip04.decrypt(privateKey, event.pubkey, event.content)
const finalize = (result: string | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(result)
}
sub.on('event', (event: Event) => {
void decryptContent(privateKey, event)
.then((content) => {
if (content) {
resolved = true
sub.unsub()
resolve(content)
finalize(content)
}
} catch (e) {
})
.catch((e) => {
console.error('Error decrypting content:', e)
}
}
})
sub.on('eose', () => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(null)
}
})
setTimeout(() => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(null)
}
}, 5000)
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
})
}

View File

@ -12,15 +12,21 @@ export class NostrRemoteSigner {
* Sign an event template
* Requires private key to be available
*/
async signEvent(eventTemplate: EventTemplate): Promise<Event | null> {
signEvent(eventTemplate: EventTemplate): Event | null {
// Get the event hash first
const eventId = getEventHash(eventTemplate)
// Try to get private key from nostrService (if available from NostrConnect)
const privateKey = nostrService.getPrivateKey()
if (privateKey) {
// Use direct signing if private key is available
if (!privateKey) {
throw new Error(
'Private key required for signing. ' +
'Please use a NostrConnect wallet that provides signing capabilities, ' +
'or ensure your wallet is properly connected.'
)
}
const event = {
...eventTemplate,
id: eventId,
@ -30,15 +36,6 @@ export class NostrRemoteSigner {
return event
}
// If no private key, remote signing is required
// Note: use.nsec.app might not support direct signing via postMessage
throw new Error(
'Private key required for signing. ' +
'Please use a NostrConnect wallet that provides signing capabilities, ' +
'or ensure your wallet is properly connected.'
)
}
/**
* Check if remote signing is available
*/

View File

@ -1,16 +1,14 @@
import type { Event } from 'nostr-tools'
import type { Event, Filter } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
import type { Article } from '@/types/nostr'
import { parseArticleFromEvent } from './nostrEventParsing'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL || 'wss://relay.damus.io'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
/**
* Subscribe to events with timeout
*/
export async function subscribeWithTimeout<T>(
export function subscribeWithTimeout<T>(
pool: SimplePool,
filters: any[],
filters: Filter[],
parser: (event: Event) => T | null,
timeout: number = 5000
): Promise<T | null> {
@ -20,33 +18,23 @@ export async function subscribeWithTimeout<T>(
let timeoutId: NodeJS.Timeout | null = null
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
if (timeoutId) {
clearTimeout(timeoutId)
}
sub.unsub()
}
sub.on('event', (event: Event) => {
if (resolved.value) return
resolved.value = true
const result = parser(event)
cleanup()
resolve(result)
})
sub.on('eose', () => {
if (resolved.value) return
resolved.value = true
cleanup()
resolve(null)
})
timeoutId = setTimeout(() => {
if (!resolved.value) {
resolved.value = true
cleanup()
resolve(null)
const resolveOnce = (value: T | null) => {
if (resolved.value) {
return
}
}, timeout)
resolved.value = true
cleanup()
resolve(value)
}
sub.on('event', (event: Event) => resolveOnce(parser(event)))
sub.on('eose', () => resolveOnce(null))
timeoutId = setTimeout(() => resolveOnce(null), timeout)
})
}

View File

@ -1,12 +1,36 @@
import type { Event } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL || 'wss://relay.damus.io'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) {
return [
{
kinds: [9735], // Zap receipt
'#p': [targetPubkey],
'#e': [targetEventId],
authors: [userPubkey], // Filter by the payer's pubkey
},
]
}
async function isValidZapReceipt(
event: Event,
targetEventId: string,
targetPubkey: string,
userPubkey: string,
amount: number
): Promise<boolean> {
// Import verification service dynamically to avoid circular dependencies
const { zapVerificationService } = await import('./zapVerification')
return zapVerificationService.verifyZapReceiptForArticle(event, targetEventId, targetPubkey, userPubkey, amount)
}
/**
* Check if user has paid for an article by looking for zap receipts
*/
export async function checkZapReceipt(
export function checkZapReceipt(
pool: SimplePool,
targetPubkey: string,
targetEventId: string,
@ -18,54 +42,31 @@ export async function checkZapReceipt(
}
return new Promise((resolve) => {
const filters = [
{
kinds: [9735], // Zap receipt
'#p': [targetPubkey],
'#e': [targetEventId],
authors: [userPubkey], // Filter by the payer's pubkey
},
]
let resolved = false
const sub = pool.sub([RELAY_URL], filters)
const sub = pool.sub([RELAY_URL], createZapFilters(targetPubkey, targetEventId, userPubkey))
sub.on('event', async (event: Event) => {
if (resolved) return
// Import verification service dynamically to avoid circular dependencies
const { zapVerificationService } = await import('./zapVerification')
// Verify the zap receipt signature and details
const isValid = zapVerificationService.verifyZapReceiptForArticle(
event,
targetEventId,
targetPubkey,
userPubkey,
amount
)
const finalize = (value: boolean) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: Event) => {
if (resolved) {
return
}
void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
if (isValid) {
resolved = true
sub.unsub()
resolve(true)
finalize(true)
}
})
sub.on('eose', () => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(false)
}
})
setTimeout(() => {
if (!resolved) {
resolved = true
sub.unsub()
resolve(false)
}
}, 3000)
const end = () => finalize(false)
sub.on('eose', end)
setTimeout(end, 3000)
})
}

View File

@ -1,10 +1,10 @@
import type { NostrConnectState, NostrProfile } from '@/types/nostr'
import type { NostrConnectState } from '@/types/nostr'
import { nostrService } from './nostr'
import { handleNostrConnectMessage } from './nostrconnectHandler'
// NostrConnect uses NIP-46 protocol
// use.nsec.app provides a bridge for remote signing
const NOSTRCONNECT_BRIDGE = process.env.NEXT_PUBLIC_NOSTRCONNECT_BRIDGE || 'https://use.nsec.app'
const NOSTRCONNECT_BRIDGE = process.env.NEXT_PUBLIC_NOSTRCONNECT_BRIDGE ?? 'https://use.nsec.app'
export class NostrConnectService {
private state: NostrConnectState = {
@ -14,7 +14,7 @@ export class NostrConnectService {
}
private listeners: Set<(state: NostrConnectState) => void> = new Set()
private relayUrl: string = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL || 'wss://relay.damus.io'
private relayUrl: string = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
constructor() {
if (typeof window !== 'undefined') {
@ -35,20 +35,62 @@ export class NostrConnectService {
return { ...this.state }
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
const appName = 'Nostr Paywall'
private createConnectUrl(): string {
const appName = 'zapwall4Science'
const appUrl = window.location.origin
// NostrConnect URI format: nostrconnect://<pubkey>?relay=<relay>&metadata=<metadata>
// use.nsec.app provides a web interface for this
const params = new URLSearchParams({
origin: appUrl,
name: appName,
relay: this.relayUrl,
})
const url = `${NOSTRCONNECT_BRIDGE}?${params.toString()}`
return `${NOSTRCONNECT_BRIDGE}?${params.toString()}`
}
private cleanupPopup(popup: Window | null, checkClosed: number, messageHandler: (event: MessageEvent) => void) {
clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
if (popup && !popup.closed) {
popup.close()
}
}
private createMessageHandler(
resolve: () => void,
reject: (error: Error) => void,
cleanup: () => void
): (event: MessageEvent) => void {
return (event: MessageEvent) => {
handleNostrConnectMessage(
event,
this.state,
(pubkey, _privateKey) => {
this.state = {
connected: true,
pubkey,
profile: null,
}
this.saveStateToStorage()
this.notifyListeners()
void this.loadProfile()
cleanup()
resolve()
},
(error) => {
console.error('Connection error:', error)
cleanup()
reject(error)
}
)
}
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
const url = this.createConnectUrl()
// Open NostrConnect bridge in popup
const popup = window.open(url, 'nostrconnect', 'width=400,height=600,scrollbars=yes,resizable=yes')
@ -58,59 +100,27 @@ export class NostrConnectService {
return
}
const messageHandler: (event: MessageEvent) => void
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
this.cleanupPopup(popup, checkClosed, messageHandler)
if (!this.state.connected) {
reject(new Error('Connection cancelled'))
}
}
}, 1000)
const messageHandler = (event: MessageEvent) => {
console.log('Message event received in connect:', event)
handleNostrConnectMessage(
event,
this.state,
(pubkey, privateKey) => {
console.log('Connection successful, updating state')
this.state = {
connected: true,
pubkey,
profile: null,
const cleanup = () => {
this.cleanupPopup(popup, checkClosed, messageHandler)
}
this.saveStateToStorage()
this.notifyListeners()
this.loadProfile()
messageHandler = this.createMessageHandler(resolve, reject, cleanup)
clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
if (popup && !popup.closed) {
popup.close()
}
resolve()
},
(error) => {
console.error('Connection error:', error)
clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
if (popup && !popup.closed) {
popup.close()
}
reject(error)
}
)
}
// Listen for all messages to debug
window.addEventListener('message', messageHandler)
console.log('Listening for messages from:', NOSTRCONNECT_BRIDGE)
})
}
async disconnect(): Promise<void> {
disconnect(): Promise<void> {
this.state = {
connected: false,
pubkey: null,
@ -121,7 +131,9 @@ export class NostrConnectService {
}
private async loadProfile(): Promise<void> {
if (!this.state.pubkey) return
if (!this.state.pubkey) {
return
}
try {
const profile = await nostrService.getProfile(this.state.pubkey)
@ -149,9 +161,9 @@ export class NostrConnectService {
if (stored) {
const parsed = JSON.parse(stored)
this.state = {
connected: parsed.connected || false,
pubkey: parsed.pubkey || null,
profile: parsed.profile || null,
connected: parsed.connected ?? false,
pubkey: parsed.pubkey ?? null,
profile: parsed.profile ?? null,
}
if (this.state.pubkey) {
nostrService.setPublicKey(this.state.pubkey)

View File

@ -1,7 +1,7 @@
import type { NostrConnectState } from '@/types/nostr'
import { nostrService } from './nostr'
const NOSTRCONNECT_BRIDGE = process.env.NEXT_PUBLIC_NOSTRCONNECT_BRIDGE || 'https://use.nsec.app'
const NOSTRCONNECT_BRIDGE = process.env.NEXT_PUBLIC_NOSTRCONNECT_BRIDGE ?? 'https://use.nsec.app'
interface MessageData {
type?: string
@ -34,8 +34,6 @@ function handleConnectMessage(
onError(new Error('No pubkey received'))
return false
}
console.log('Connection successful, pubkey:', pubkey)
nostrService.setPublicKey(pubkey)
if (privateKey) {
nostrService.setPrivateKey(privateKey)
@ -60,8 +58,6 @@ function handleAlternativeConnectMessage(
onError(new Error('No pubkey received'))
return false
}
console.log('Connection successful (alternative format), pubkey:', pubkey)
nostrService.setPublicKey(pubkey)
if (privateKey) {
nostrService.setPrivateKey(privateKey)
@ -88,12 +84,6 @@ export function handleNostrConnectMessage(
): void {
const bridgeOrigin = new URL(NOSTRCONNECT_BRIDGE).origin
console.log('NostrConnect message received:', {
origin: event.origin,
expectedOrigin: bridgeOrigin,
data: event.data,
})
if (event.origin !== bridgeOrigin) {
console.warn('Origin mismatch:', event.origin, 'expected:', bridgeOrigin)
return
@ -112,8 +102,5 @@ export function handleNostrConnectMessage(
handleErrorMessage(data, onError)
} else if (data.method === 'connect' || data.action === 'connect') {
handleAlternativeConnectMessage(data, onSuccess, onError)
} else {
console.log('Unknown message type:', messageType, 'Data:', data)
}
}

View File

@ -1,10 +1,70 @@
import type { Event } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
import { nostrService } from './nostr'
import { zapVerificationService } from './zapVerification'
import type { Notification } from '@/types/notifications'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL || 'wss://relay.damus.io'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
function createZapReceiptFilters(userPubkey: string) {
return [
{
kinds: [9735], // Zap receipt
'#p': [userPubkey], // Receipts targeting this user
},
]
}
async function buildPaymentNotification(event: Event, userPubkey: string): Promise<Notification | null> {
const paymentInfo = zapVerificationService.extractPaymentInfo(event)
if (paymentInfo?.recipient !== userPubkey) {
return null
}
let articleTitle: string | undefined
if (paymentInfo.articleId) {
try {
const article = await nostrService.getArticleById(paymentInfo.articleId)
articleTitle = article?.title
} catch (e) {
console.error('Error loading article for notification:', e)
}
}
return {
id: event.id,
type: 'payment',
title: 'New Payment Received',
message: articleTitle
? `You received ${paymentInfo.amount} sats for "${articleTitle}"`
: `You received ${paymentInfo.amount} sats`,
timestamp: event.created_at,
read: false,
articleId: paymentInfo.articleId ?? undefined,
articleTitle,
amount: paymentInfo.amount,
fromPubkey: paymentInfo.payer,
}
}
function registerZapSubscription(
sub: ReturnType<SimplePoolWithSub['sub']>,
userPubkey: string,
onNotification: (notification: Notification) => void
) {
sub.on('event', (event: Event) => {
void buildPaymentNotification(event, userPubkey)
.then((notification) => {
if (notification) {
onNotification(notification)
}
})
.catch((error) => {
console.error('Error processing zap receipt notification:', error)
})
})
}
/**
* Service for monitoring and managing notifications
@ -24,57 +84,11 @@ export class NotificationService {
return () => {}
}
// Subscribe to zap receipts targeting this user
const filters = [
{
kinds: [9735], // Zap receipt
'#p': [userPubkey], // Receipts targeting this user
},
]
const filters = createZapReceiptFilters(userPubkey)
const poolWithSub = pool as SimplePoolWithSub
const sub = poolWithSub.sub([RELAY_URL], filters)
const sub = (pool as any).sub([RELAY_URL], filters)
sub.on('event', async (event: Event) => {
try {
// Extract payment info from zap receipt
const paymentInfo = zapVerificationService.extractPaymentInfo(event)
if (!paymentInfo || paymentInfo.recipient !== userPubkey) {
return
}
// Get article info if available
let articleTitle: string | undefined
if (paymentInfo.articleId) {
try {
const article = await nostrService.getArticleById(paymentInfo.articleId)
articleTitle = article?.title
} catch (e) {
console.error('Error loading article for notification:', e)
}
}
// Create notification
const notification: Notification = {
id: event.id,
type: 'payment',
title: 'New Payment Received',
message: articleTitle
? `You received ${paymentInfo.amount} sats for "${articleTitle}"`
: `You received ${paymentInfo.amount} sats`,
timestamp: event.created_at,
read: false,
articleId: paymentInfo.articleId || undefined,
articleTitle,
amount: paymentInfo.amount,
fromPubkey: paymentInfo.payer,
}
onNotification(notification)
} catch (error) {
console.error('Error processing zap receipt notification:', error)
}
})
registerZapSubscription(sub, userPubkey, onNotification)
const unsubscribe = () => {
sub.unsub()

View File

@ -80,7 +80,7 @@ export class PaymentService {
* Wait for payment completion with polling
* After payment is confirmed, sends private content to the user
*/
async waitForArticlePayment(
waitForArticlePayment(
paymentHash: string,
articleId: string,
articlePubkey: string,

View File

@ -6,54 +6,49 @@ import { getStoredPrivateContent } from './articleStorage'
* Poll for payment completion via zap receipt verification
* After payment is confirmed, sends private content to the user
*/
async function pollPaymentUntilDeadline(
articleId: string,
articlePubkey: string,
amount: number,
recipientPubkey: string,
interval: number,
deadline: number
): Promise<boolean> {
try {
const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
if (zapReceiptExists) {
await sendPrivateContentAfterPayment(articleId, recipientPubkey)
return true
}
} catch (error) {
console.error('Error checking zap receipt:', error)
}
if (Date.now() > deadline) {
return false
}
return new Promise<boolean>((resolve) => {
setTimeout(() => {
void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
.then(resolve)
.catch(() => resolve(false))
}, interval)
})
}
export async function waitForArticlePayment(
paymentHash: string,
_paymentHash: string,
articleId: string,
articlePubkey: string,
amount: number,
recipientPubkey: string,
timeout: number = 300000 // 5 minutes
): Promise<boolean> {
const interval = 2000
const deadline = Date.now() + timeout
try {
// With Alby/WebLN, we rely on zap receipts for payment verification
// Poll for zap receipt confirmation
const startTime = Date.now()
const interval = 2000 // 2 seconds
return new Promise((resolve) => {
const checkZapReceipt = async () => {
try {
const zapReceiptExists = await nostrService.checkZapReceipt(
articlePubkey,
articleId,
amount,
recipientPubkey
)
if (zapReceiptExists) {
await sendPrivateContentAfterPayment(articleId, recipientPubkey)
resolve(true)
return
}
if (Date.now() - startTime > timeout) {
resolve(false)
return
}
setTimeout(checkZapReceipt, interval)
} catch (error) {
console.error('Error checking zap receipt:', error)
if (Date.now() - startTime > timeout) {
resolve(false)
} else {
setTimeout(checkZapReceipt, interval)
}
}
}
checkZapReceipt()
})
return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
} catch (error) {
console.error('Wait for payment error:', error)
return false
@ -82,7 +77,7 @@ async function sendPrivateContentAfterPayment(
)
if (sent) {
console.log('Private content sent successfully to user')
// Private content sent successfully
} else {
console.warn('Failed to send private content, but payment was confirmed')
}

View File

@ -21,41 +21,30 @@ const DEFAULT_OPTIONS: Required<RetryOptions> = {
/**
* Retry a function with exponential backoff
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
export function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const opts = { ...DEFAULT_OPTIONS, ...options }
let lastError: Error | null = null
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
const attempt = (current: number): Promise<T> => {
return fn().catch((error) => {
const normalizedError = error instanceof Error ? error : new Error(String(error))
// Check if error is retryable
if (!opts.retryable(lastError)) {
throw lastError
if (!opts.retryable(normalizedError) || current === opts.maxRetries) {
throw normalizedError
}
// Don't retry on last attempt
if (attempt === opts.maxRetries) {
throw lastError
const delay = Math.min(opts.initialDelay * Math.pow(opts.backoffMultiplier, current), opts.maxDelay)
return new Promise((resolve) => {
setTimeout(() => {
resolve(attempt(current + 1))
}, delay)
})
})
}
// Calculate delay with exponential backoff
const delay = Math.min(
opts.initialDelay * Math.pow(opts.backoffMultiplier, attempt),
opts.maxDelay
)
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastError || new Error('Retry failed')
return attempt(0).catch((error) => {
throw error ?? new Error('Retry failed')
})
}
/**

82
lib/sponsoring.ts Normal file
View File

@ -0,0 +1,82 @@
import { nostrService } from './nostr'
import { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Article } from '@/types/nostr'
const RELAY = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
function subscribeToPresentation(pool: SimplePoolWithSub, pubkey: string): Promise<number> {
const filters = [
{
kinds: [1],
authors: [pubkey],
'#category': ['author-presentation'],
limit: 1,
},
]
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY], filters)
const finalize = (value: number) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: import('nostr-tools').Event) => {
const isPresentation = event.tags.some((tag) => tag[0] === 'presentation' && tag[1] === 'true')
if (!isPresentation) {
return
}
const sponsoringTag = event.tags.find((tag) => tag[0] === 'total_sponsoring')
const total = sponsoringTag ? parseInt(sponsoringTag[1] ?? '0', 10) : 0
finalize(total)
})
sub.on('eose', () => finalize(0))
setTimeout(() => finalize(0), 5000)
})
}
/**
* Get total sponsoring for an author by their pubkey
*/
export function getAuthorSponsoring(pubkey: string): Promise<number> {
const pool = nostrService.getPool()
if (!pool) {
return Promise.resolve(0)
}
return subscribeToPresentation(pool as SimplePoolWithSub, pubkey)
}
/**
* Get sponsoring for multiple authors (for sorting)
* Returns a map of pubkey -> total sponsoring
*/
export function getAuthorsSponsoring(pubkeys: string[]): Map<string, number> {
const sponsoringMap = new Map<string, number>()
// For now, we'll extract sponsoring from articles that are already loaded
// In a real implementation, we'd query all presentation articles
// For performance, we'll use the sponsoring from the article's totalSponsoring field
pubkeys.forEach((pubkey) => {
sponsoringMap.set(pubkey, 0)
})
return sponsoringMap
}
/**
* Get sponsoring from an article (if it's a presentation article)
*/
export function getSponsoringFromArticle(article: Article): number {
if (article.isPresentation && article.totalSponsoring !== undefined) {
return article.totalSponsoring
}
return 0
}

View File

@ -4,7 +4,7 @@ const STORE_NAME = 'article_content'
interface DBData {
id: string
data: any
data: unknown
createdAt: number
expiresAt?: number
}
@ -29,7 +29,18 @@ export class IndexedDBStorage {
return this.initPromise
}
this.initPromise = new Promise((resolve, reject) => {
this.initPromise = this.openDatabase()
try {
await this.initPromise
} catch (error) {
this.initPromise = null
throw error
}
}
private openDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined' || !window.indexedDB) {
reject(new Error('IndexedDB is not available. This application requires IndexedDB support.'))
return
@ -49,7 +60,6 @@ export class IndexedDBStorage {
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
store.createIndex('createdAt', 'createdAt', { unique: false })
@ -57,19 +67,12 @@ export class IndexedDBStorage {
}
}
})
try {
await this.initPromise
} catch (error) {
this.initPromise = null
throw error
}
}
/**
* Store data in IndexedDB
*/
async set(key: string, value: any, expiresIn?: number): Promise<void> {
async set(key: string, value: unknown, expiresIn?: number): Promise<void> {
try {
await this.init()
@ -85,8 +88,13 @@ export class IndexedDBStorage {
expiresAt: expiresIn ? now + expiresIn : undefined,
}
const db = this.db
if (!db) {
throw new Error('Database not initialized')
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORE_NAME], 'readwrite')
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put(data)
@ -102,7 +110,7 @@ export class IndexedDBStorage {
/**
* Get data from IndexedDB
*/
async get<T = any>(key: string): Promise<T | null> {
async get<T = unknown>(key: string): Promise<T | null> {
try {
await this.init()
@ -110,8 +118,21 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
return this.readValue<T>(key)
} catch (error) {
console.error('Error getting from IndexedDB:', error)
return null
}
}
private readValue<T>(key: string): Promise<T | null> {
const db = this.db
if (!db) {
throw new Error('Database not initialized')
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORE_NAME], 'readonly')
const transaction = db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(key)
@ -123,9 +144,7 @@ export class IndexedDBStorage {
return
}
// Check if expired
if (result.expiresAt && result.expiresAt < Date.now()) {
// Delete expired data
this.delete(key).catch(console.error)
resolve(null)
return
@ -136,10 +155,6 @@ export class IndexedDBStorage {
request.onerror = () => reject(new Error(`Failed to get data: ${request.error}`))
})
} catch (error) {
console.error('Error getting from IndexedDB:', error)
return null
}
}
/**
@ -153,8 +168,10 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
const db = this.db
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORE_NAME], 'readwrite')
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.delete(key)
@ -178,8 +195,10 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
const db = this.db
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORE_NAME], 'readwrite')
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const index = store.index('expiresAt')
const request = index.openCursor(IDBKeyRange.upperBound(Date.now()))

View File

@ -1,5 +1,4 @@
import { Event, verifyEvent, getPublicKey } from 'nostr-tools'
import type { Article } from '@/types/nostr'
import { Event, verifyEvent } from 'nostr-tools'
/**
* Service for verifying zap receipts and their signatures
@ -27,50 +26,17 @@ export class ZapVerificationService {
userPubkey: string,
expectedAmount: number
): boolean {
// Verify signature first
if (!this.verifyZapReceiptSignature(zapReceipt)) {
console.warn('Zap receipt signature verification failed')
return false
}
// Verify the zap receipt is from the expected user
// In zap receipts, the 'p' tag should contain the recipient (article author)
// and the event pubkey should be from the payer or zap service
const recipientTag = zapReceipt.tags.find((tag) => tag[0] === 'p')
if (!recipientTag || recipientTag[1] !== articlePubkey) {
console.warn('Zap receipt recipient does not match article author')
return false
}
// Verify the article ID is referenced
const eventTag = zapReceipt.tags.find((tag) => tag[0] === 'e')
if (!eventTag || eventTag[1] !== articleId) {
console.warn('Zap receipt does not reference the correct article')
return false
}
// Verify the amount (in millisats, so we need to check if it's >= expectedAmount * 1000)
const amountTag = zapReceipt.tags.find((tag) => tag[0] === 'amount')
if (amountTag) {
const amountInMillisats = parseInt(amountTag[1] || '0')
const expectedAmountInMillisats = expectedAmount * 1000
if (amountInMillisats < expectedAmountInMillisats) {
console.warn(`Zap amount ${amountInMillisats} is less than expected ${expectedAmountInMillisats}`)
return false
}
} else {
console.warn('Zap receipt does not contain amount tag')
return false
}
// Verify it's a zap receipt (kind 9735)
if (zapReceipt.kind !== 9735) {
console.warn('Event is not a zap receipt (kind 9735)')
return false
}
return true
return (
this.isRecipientValid(zapReceipt, articlePubkey) &&
this.isArticleReferenced(zapReceipt, articleId) &&
this.isAmountValid(zapReceipt, expectedAmount) &&
this.isZapKind(zapReceipt)
)
}
/**
@ -91,13 +57,13 @@ export class ZapVerificationService {
return null
}
const amountInMillisats = parseInt(amountTag[1] || '0')
const amountInMillisats = parseInt(amountTag[1] ?? '0')
const amountInSats = Math.floor(amountInMillisats / 1000)
return {
amount: amountInSats,
recipient: recipientTag[1],
articleId: eventTag ? eventTag[1] : null,
articleId: eventTag?.[1] ?? null,
payer: zapReceipt.pubkey,
}
} catch (error) {
@ -105,6 +71,47 @@ export class ZapVerificationService {
return null
}
}
private isRecipientValid(zapReceipt: Event, articlePubkey: string): boolean {
const recipient = zapReceipt.tags.find((tag) => tag[0] === 'p')?.[1]
if (recipient !== articlePubkey) {
console.warn('Zap receipt recipient does not match article author')
return false
}
return true
}
private isArticleReferenced(zapReceipt: Event, articleId: string): boolean {
const eventIdTag = zapReceipt.tags.find((tag) => tag[0] === 'e')?.[1]
if (eventIdTag !== articleId) {
console.warn('Zap receipt does not reference the correct article')
return false
}
return true
}
private isAmountValid(zapReceipt: Event, expectedAmount: number): boolean {
const amountTag = zapReceipt.tags.find((tag) => tag[0] === 'amount')?.[1]
if (!amountTag) {
console.warn('Zap receipt does not contain amount tag')
return false
}
const amountInMillisats = parseInt(amountTag ?? '0')
const expectedAmountInMillisats = expectedAmount * 1000
if (amountInMillisats < expectedAmountInMillisats) {
console.warn(`Zap amount ${amountInMillisats} is less than expected ${expectedAmountInMillisats}`)
return false
}
return true
}
private isZapKind(zapReceipt: Event): boolean {
if (zapReceipt.kind !== 9735) {
console.warn('Event is not a zap receipt (kind 9735)')
return false
}
return true
}
}
export const zapVerificationService = new ZapVerificationService()

4
package-lock.json generated
View File

@ -1,11 +1,11 @@
{
"name": "nostr-paywall",
"name": "zapwall4science",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nostr-paywall",
"name": "zapwall4science",
"version": "1.0.0",
"dependencies": {
"next": "^14.0.4",

View File

@ -1,7 +1,7 @@
{
"name": "nostr-paywall",
"name": "zapwall4science",
"version": "1.0.0",
"description": "Article site with free previews and paid content on Nostr",
"description": "Plateforme de publication d'articles scientifiques et de science-fiction avec sponsoring et rémunération des avis",
"scripts": {
"dev": "next dev",
"build": "next build",

View File

@ -41,7 +41,7 @@ function DocsHeader() {
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold text-gray-900 hover:text-gray-700">
Nostr Paywall
zapwall4Science
</Link>
<div className="flex items-center gap-4">
<Link
@ -63,15 +63,21 @@ export default function DocsPage() {
return (
<>
<Head>
<title>Documentation - Nostr Paywall</title>
<meta name="description" content="Documentation complète pour Nostr Paywall" />
<title>Documentation - zapwall4Science</title>
<meta name="description" content="Documentation complète pour zapwall4Science" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main className="min-h-screen bg-gray-50">
<DocsHeader />
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
<DocsSidebar docs={docs} selectedDoc={selectedDoc} onSelectDoc={loadDoc} />
<DocsSidebar
docs={docs}
selectedDoc={selectedDoc}
onSelectDoc={(slug) => {
void loadDoc(slug)
}}
/>
<div className="flex-1">
<DocsContent content={docContent} loading={loading} />
</div>

View File

@ -1,129 +1,156 @@
import { useState } from 'react'
import Head from 'next/head'
import { ConnectButton } from '@/components/ConnectButton'
import { ArticleCard } from '@/components/ArticleCard'
import { SearchBar } from '@/components/SearchBar'
import { ArticleFiltersComponent, type ArticleFilters } from '@/components/ArticleFilters'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useRouter } from 'next/router'
import { useArticles } from '@/hooks/useArticles'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
import type { Article } from '@/types/nostr'
import type { ArticleFilters } from '@/components/ArticleFilters'
import { HomeView } from '@/components/HomeView'
export default function Home() {
function usePresentationGuard(connected: boolean, pubkey: string | null) {
const router = useRouter()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
useEffect(() => {
const ensurePresentation = async () => {
if (!connected || !pubkey) {
return
}
const presentation = await checkPresentationExists()
if (!presentation) {
await router.push('/presentation')
}
}
void ensurePresentation()
}, [checkPresentationExists, connected, pubkey, router])
}
function usePresentationArticles(allArticles: Article[]) {
const [presentationArticles, setPresentationArticles] = useState<Map<string, Article>>(new Map())
useEffect(() => {
const presentations = new Map<string, Article>()
allArticles.forEach((article) => {
if (article.isPresentation && article.pubkey) {
presentations.set(article.pubkey, article)
}
})
setPresentationArticles(presentations)
}, [allArticles])
return presentationArticles
}
function useHomeState() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState<ArticleFilters['category']>('all')
const [filters, setFilters] = useState<ArticleFilters>({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
category: 'all',
})
const { articles, allArticles, loading, error, loadArticleContent } = useArticles(
searchQuery,
filters
)
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
const handleUnlock = async (article: Article) => {
const fullArticle = await loadArticleContent(article.id, article.pubkey)
if (fullArticle && fullArticle.paid) {
setUnlockedArticles((prev) => new Set([...prev, article.id]))
}
return {
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
filters,
setFilters,
unlockedArticles,
setUnlockedArticles,
}
}
return (
<>
<Head>
<title>Nostr Paywall - Articles with Lightning Payments</title>
<meta name="description" content="Read article previews for free, unlock full content with Lightning zaps" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
function useArticlesData(searchQuery: string) {
const { articles: allArticlesRaw, allArticles, loading, error, loadArticleContent } = useArticles(searchQuery, null)
const presentationArticles = usePresentationArticles(allArticles)
return { allArticlesRaw, allArticles, loading, error, loadArticleContent, presentationArticles }
}
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Nostr Paywall</h1>
<div className="flex items-center gap-4">
<a
href="/docs"
className="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm font-medium transition-colors"
>
Documentation
</a>
<a
href="/publish"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Publish Article
</a>
<ConnectButton />
</div>
</div>
</header>
function useCategorySync(selectedCategory: ArticleFilters['category'], setFilters: (value: ArticleFilters | ((prev: ArticleFilters) => ArticleFilters)) => void) {
useEffect(() => {
setFilters((prev) => ({
...prev,
category: selectedCategory ?? 'all',
}))
}, [selectedCategory, setFilters])
}
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-4">Articles</h2>
<p className="text-gray-600 mb-4">
Read previews for free, unlock full content with {800} sats Lightning zaps
</p>
{/* Search Bar */}
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
</div>
</div>
{/* Filters */}
{!loading && allArticles.length > 0 && (
<ArticleFiltersComponent
filters={filters}
onFiltersChange={setFilters}
articles={allArticles}
/>
)}
{loading && (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{error}</p>
</div>
)}
{!loading && articles.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">
{allArticles.length === 0
? 'No articles found. Check back later!'
: 'No articles match your search or filters.'}
</p>
</div>
)}
{!loading && articles.length > 0 && (
<div className="mb-4 text-sm text-gray-600">
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
</div>
)}
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{
...article,
paid: unlockedArticles.has(article.id) || article.paid,
}}
onUnlock={handleUnlock}
/>
))}
</div>
</div>
</main>
</>
function useFilteredArticles(
allArticlesRaw: Article[],
searchQuery: string,
filters: ArticleFilters,
presentationArticles: Map<string, Article>
) {
return useMemo(
() => applyFiltersAndSort(allArticlesRaw, searchQuery, filters, presentationArticles),
[allArticlesRaw, searchQuery, filters, presentationArticles]
)
}
function useUnlockHandler(
loadArticleContent: (id: string, pubkey: string) => Promise<Article | null>,
setUnlockedArticles: React.Dispatch<React.SetStateAction<Set<string>>>
) {
return useCallback(
async (article: Article) => {
const fullArticle = await loadArticleContent(article.id, article.pubkey)
if (fullArticle?.paid) {
setUnlockedArticles((prev) => new Set([...prev, article.id]))
}
},
[loadArticleContent, setUnlockedArticles]
)
}
function useHomeController() {
const { connected, pubkey } = useNostrConnect()
const {
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
filters,
setFilters,
unlockedArticles,
setUnlockedArticles,
} = useHomeState()
const { allArticlesRaw, allArticles, loading, error, loadArticleContent, presentationArticles } =
useArticlesData(searchQuery)
usePresentationGuard(connected, pubkey)
useCategorySync(selectedCategory, setFilters)
const articles = useFilteredArticles(allArticlesRaw, searchQuery, filters, presentationArticles)
const handleUnlock = useUnlockHandler(loadArticleContent, setUnlockedArticles)
return {
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
filters,
setFilters,
articles,
allArticles,
loading,
error,
unlockedArticles,
handleUnlock,
}
}
export default function Home() {
const controller = useHomeController()
return (
<HomeView
{...controller}
onUnlock={(a) => {
void controller.handleUnlock(a)
}}
/>
)
}

68
pages/presentation.tsx Normal file
View File

@ -0,0 +1,68 @@
import { useEffect, useCallback } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import { ConnectButton } from '@/components/ConnectButton'
import { AuthorPresentationEditor } from '@/components/AuthorPresentationEditor'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
function usePresentationRedirect(connected: boolean, pubkey: string | null) {
const router = useRouter()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
const redirectIfExists = useCallback(async () => {
if (!connected || !pubkey) {
return
}
const presentation = await checkPresentationExists()
if (presentation) {
await router.push('/')
}
}, [checkPresentationExists, connected, pubkey, router])
useEffect(() => {
void redirectIfExists()
}, [redirectIfExists])
}
function PresentationLayout() {
return (
<>
<Head>
<title>Créer votre article de présentation - zapwall4Science</title>
<meta
name="description"
content="Créez votre article de présentation obligatoire pour publier sur zapwall4Science"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
<ConnectButton />
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<h2 className="text-3xl font-bold">Créer votre article de présentation</h2>
<p className="text-gray-600 mt-2">
Cet article est obligatoire pour publier sur zapwall4Science. Il permet aux
lecteurs de vous connaître et de vous sponsoriser.
</p>
</div>
<AuthorPresentationEditor />
</div>
</main>
</>
)
}
export default function PresentationPage() {
const { connected, pubkey } = useNostrConnect()
usePresentationRedirect(connected, pubkey)
return <PresentationLayout />
}

View File

@ -1,159 +1,108 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import { ConnectButton } from '@/components/ConnectButton'
import { UserProfile } from '@/components/UserProfile'
import { UserArticles } from '@/components/UserArticles'
import { SearchBar } from '@/components/SearchBar'
import { ArticleFiltersComponent, type ArticleFilters } from '@/components/ArticleFilters'
import type { ArticleFilters } from '@/components/ArticleFilters'
import type { NostrProfile } from '@/types/nostr'
import { ProfileView } from '@/components/ProfileView'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useUserArticles } from '@/hooks/useUserArticles'
import { nostrService } from '@/lib/nostr'
import type { NostrProfile } from '@/types/nostr'
export default function ProfilePage() {
const router = useRouter()
const { connected, pubkey: currentPubkey } = useNostrConnect()
function useUserProfileData(currentPubkey: string | null) {
const [profile, setProfile] = useState<NostrProfile | null>(null)
const [loadingProfile, setLoadingProfile] = useState(true)
useEffect(() => {
if (!currentPubkey) {
return
}
const createMinimalProfile = (): NostrProfile => ({
pubkey: currentPubkey,
name: undefined,
about: undefined,
picture: undefined,
nip05: undefined,
})
const load = async () => {
try {
const loadedProfile = await nostrService.getProfile(currentPubkey)
setProfile(loadedProfile ?? createMinimalProfile())
} catch (e) {
console.error('Error loading profile:', e)
setProfile(createMinimalProfile())
} finally {
setLoadingProfile(false)
}
}
setLoadingProfile(true)
void load()
}, [currentPubkey])
return { profile, loadingProfile }
}
function useRedirectWhenDisconnected(connected: boolean, pubkey: string | null) {
const router = useRouter()
useEffect(() => {
if (!connected || !pubkey) {
void router.push('/')
}
}, [connected, pubkey, router])
}
function useProfileController() {
const { connected, pubkey: currentPubkey } = useNostrConnect()
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<ArticleFilters>({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
category: 'all',
})
// Use current user's pubkey if connected, otherwise redirect
useEffect(() => {
if (!connected || !currentPubkey) {
router.push('/')
return
}
}, [connected, currentPubkey, router])
// Load user profile
useEffect(() => {
if (!currentPubkey) return
setLoadingProfile(true)
nostrService
.getProfile(currentPubkey)
.then((loadedProfile) => {
if (loadedProfile) {
setProfile(loadedProfile)
} else {
// Create minimal profile if none exists
setProfile({
pubkey: currentPubkey,
name: undefined,
about: undefined,
picture: undefined,
nip05: undefined,
})
}
})
.catch((e) => {
console.error('Error loading profile:', e)
// Create minimal profile on error
setProfile({
pubkey: currentPubkey,
name: undefined,
about: undefined,
picture: undefined,
nip05: undefined,
})
})
.finally(() => {
setLoadingProfile(false)
})
}, [currentPubkey])
useRedirectWhenDisconnected(connected, currentPubkey ?? null)
const { profile, loadingProfile } = useUserProfileData(currentPubkey ?? null)
const { articles, allArticles, loading, error, loadArticleContent } = useUserArticles(
currentPubkey || '',
currentPubkey ?? '',
searchQuery,
filters
)
return {
connected,
currentPubkey,
searchQuery,
setSearchQuery,
filters,
setFilters,
articles,
allArticles,
loading,
error,
loadArticleContent,
profile,
loadingProfile,
}
}
export default function ProfilePage() {
const controller = useProfileController()
const { connected, currentPubkey } = controller
useRedirectWhenDisconnected(connected, currentPubkey ?? null)
if (!connected || !currentPubkey) {
return null // Will redirect
return null
}
return (
<>
<Head>
<title>My Profile - Nostr Paywall</title>
<meta name="description" content="View your profile and published articles" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Nostr Paywall</h1>
<div className="flex items-center gap-4">
<a
href="/publish"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Publish Article
</a>
<ConnectButton />
</div>
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<button
onClick={() => router.push('/')}
className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4"
>
Back to Articles
</button>
</div>
{/* Profile Section */}
{loadingProfile ? (
<div className="text-center py-12">
<p className="text-gray-500">Loading profile...</p>
</div>
) : profile ? (
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={allArticles.length} />
) : null}
{/* Search and Filters */}
<div className="mb-6">
<h2 className="text-2xl font-bold mb-4">My Articles</h2>
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search my articles..." />
</div>
{!loading && allArticles.length > 0 && (
<ArticleFiltersComponent
filters={filters}
onFiltersChange={setFilters}
articles={allArticles}
<ProfileView
{...controller}
currentPubkey={currentPubkey}
/>
)}
</div>
{/* Articles Count */}
{!loading && articles.length > 0 && (
<div className="mb-4 text-sm text-gray-600">
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
</div>
)}
{/* Articles List */}
<UserArticles
articles={articles}
loading={loading}
error={error}
onLoadContent={loadArticleContent}
showEmptyMessage={true}
/>
</div>
</main>
</>
)
}

View File

@ -1,50 +1,60 @@
import { useState } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { ConnectButton } from '@/components/ConnectButton'
import { ArticleEditor } from '@/components/ArticleEditor'
export default function PublishPage() {
const router = useRouter()
const [published, setPublished] = useState(false)
const handlePublishSuccess = (articleId: string) => {
setPublished(true)
// Redirect to home page after a short delay
setTimeout(() => {
router.push('/')
}, 2000)
}
function PublishHeader() {
return (
<>
<Head>
<title>Publish Article - Nostr Paywall</title>
<title>Publish Article - zapwall4Science</title>
<meta name="description" content="Publish a new article with free preview and paid content" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
)
}
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Nostr Paywall</h1>
<ConnectButton />
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
function PublishHero({ onBack }: { onBack: () => void }) {
return (
<div className="mb-6">
<button
onClick={() => router.push('/')}
onClick={onBack}
className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4"
>
Back to Articles
</button>
<h2 className="text-3xl font-bold">Publish New Article</h2>
<p className="text-gray-600 mt-2">
Create an article with a free preview and paid full content
</p>
<p className="text-gray-600 mt-2">Create an article with a free preview and paid full content</p>
</div>
)
}
export default function PublishPage() {
const router = useRouter()
const handlePublishSuccess = () => {
setTimeout(() => {
void router.push('/')
}, 2000)
}
return (
<>
<PublishHeader />
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
<ConnectButton />
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<PublishHero
onBack={() => {
void router.push('/')
}}
/>
<ArticleEditor onPublishSuccess={handlePublishSuccess} />
</div>

View File

@ -17,3 +17,23 @@ export interface AlbyInvoiceRequest {
description?: string
expiry?: number // Expiry in seconds (default 3600)
}
/**
* WebLN provider interface (WebLN standard)
* Used by Alby and other Lightning wallet extensions
*/
export interface WebLNProvider {
enabled: boolean
enable(): Promise<void>
makeInvoice(request: { amount: number; defaultMemo?: string }): Promise<{ paymentRequest: string }>
sendPayment(invoice: string): Promise<{ preimage: string }>
}
/**
* Extended Window interface with WebLN provider
*/
declare global {
interface Window {
webln?: WebLNProvider
}
}

View File

@ -0,0 +1,21 @@
import type { Event, Filter } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
/**
* Extended SimplePool interface that includes the sub method
* The sub method exists in nostr-tools but is not properly typed in the TypeScript definitions
*/
export interface SimplePoolWithSub extends SimplePool {
sub(relays: string[], filters: Filter[]): {
on(event: 'event', callback: (event: Event) => void): void
on(event: 'eose', callback: () => void): void
unsub(): void
}
}
/**
* Type guard to check if a SimplePool has the sub method
*/
export function hasSubMethod(pool: SimplePool): pool is SimplePoolWithSub {
return typeof (pool as SimplePoolWithSub).sub === 'function'
}

View File

@ -8,6 +8,8 @@ export interface NostrProfile {
nip05?: string
}
export type ArticleCategory = 'science-fiction' | 'scientific-research' | 'author-presentation'
export interface Article {
id: string
pubkey: string
@ -19,6 +21,18 @@ export interface Article {
paid: boolean
invoice?: string // BOLT11 invoice from event tags (if author created one)
paymentHash?: string // Payment hash from event tags
category?: ArticleCategory // Category of the article
isPresentation?: boolean // True if this is an author presentation article
mainnetAddress?: string // Bitcoin mainnet address for sponsoring (presentation articles only)
totalSponsoring?: number // Total sponsoring received in sats (presentation articles only)
authorPresentationId?: string // ID of the author's presentation article (for standard articles)
}
export interface AuthorPresentationArticle extends Article {
category: 'author-presentation'
isPresentation: true
mainnetAddress: string
totalSponsoring: number
}
export interface ZapRequest {