create for series

This commit is contained in:
Nicolas Cantu 2026-01-14 00:34:36 +01:00
parent 57acb3d9f3
commit 6fcfae4cc0
20 changed files with 292 additions and 225 deletions

View File

@ -1,5 +1,6 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { getAlbyService } from '@/lib/alby' import { getAlbyService } from '@/lib/alby'
import { Button } from './ui'
interface AlbyInstallerProps { interface AlbyInstallerProps {
onInstalled?: () => void onInstalled?: () => void
@ -43,18 +44,20 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
href="https://getalby.com/" href="https://getalby.com/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center justify-center px-4 py-2 border border-neon-cyan/50 text-sm font-medium rounded-lg text-neon-cyan bg-neon-cyan/20 hover:bg-neon-cyan/30 hover:shadow-glow-cyan focus:outline-none focus:ring-2 focus:ring-neon-cyan transition-all"
> >
<Button variant="primary" size="small">
Install Alby Install Alby
</Button>
</a> </a>
<button <Button
variant="secondary"
size="small"
onClick={() => { onClick={() => {
void connect() void connect()
}} }}
className="inline-flex items-center justify-center px-4 py-2 border border-neon-cyan/30 text-sm font-medium rounded-lg text-cyber-accent bg-cyber-light hover:bg-cyber-dark hover:border-neon-cyan/50 focus:outline-none focus:ring-2 focus:ring-neon-cyan transition-all"
> >
Already installed? Connect Already installed? Connect
</button> </Button>
</div> </div>
</div> </div>
) )

View File

@ -3,6 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useArticlePayment } from '@/hooks/useArticlePayment' import { useArticlePayment } from '@/hooks/useArticlePayment'
import { ArticlePreview } from './ArticlePreview' import { ArticlePreview } from './ArticlePreview'
import { PaymentModal } from './PaymentModal' import { PaymentModal } from './PaymentModal'
import { Card } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import Link from 'next/link' import Link from 'next/link'
@ -69,7 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
}, connect) }, connect)
return ( return (
<article className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all"> <Card variant="interactive" className="mb-0">
<ArticleHeader article={article} /> <ArticleHeader article={article} />
<div className="text-cyber-accent mb-4"> <div className="text-cyber-accent mb-4">
<ArticlePreview <ArticlePreview
@ -89,6 +90,6 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
void handlePaymentComplete() void handlePaymentComplete()
}} }}
/> />
</article> </Card>
) )
} }

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { Card } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { AuthorFilter } from './AuthorFilter' import { AuthorFilter } from './AuthorFilter'
@ -123,9 +124,9 @@ export function ArticleFiltersComponent({
filters.category !== null filters.category !== null
return ( return (
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-4 mb-6 shadow-glow-cyan"> <Card variant="default" className="mb-6 shadow-glow-cyan">
<FiltersHeader hasActiveFilters={hasActiveFilters} onClear={handleClearFilters} /> <FiltersHeader hasActiveFilters={hasActiveFilters} onClear={handleClearFilters} />
<FiltersGrid data={data} filters={filters} onFiltersChange={onFiltersChange} /> <FiltersGrid data={data} filters={filters} onFiltersChange={onFiltersChange} />
</div> </Card>
) )
} }

View File

@ -1,3 +1,4 @@
import { Button } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface ArticleFormButtonsProps { interface ArticleFormButtonsProps {
@ -10,21 +11,23 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
return ( return (
<div className="space-y-3 pt-4"> <div className="space-y-3 pt-4">
<div className="flex gap-3"> <div className="flex gap-3">
<button <Button
type="submit" type="submit"
variant="primary"
disabled={loading} disabled={loading}
className="flex-1 px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed" loading={loading}
className="flex-1"
> >
{loading ? t('publish.publishing') : t('publish.button')} {loading ? t('publish.publishing') : t('publish.button')}
</button> </Button>
{onCancel && ( {onCancel && (
<button <Button
type="button" type="button"
variant="ghost"
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 bg-cyber-dark hover:bg-cyber-dark/80 text-cyber-accent rounded-lg font-medium transition-colors border border-cyber-accent/30 hover:border-neon-cyan/50"
> >
{t('common.back')} {t('common.back')}
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { Button } from './ui'
import { ArticlePages } from './ArticlePages' import { ArticlePages } from './ArticlePages'
interface ArticlePreviewProps { interface ArticlePreviewProps {
@ -25,13 +26,14 @@ export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewPro
<p className="text-sm text-cyber-accent/70 mb-4"> <p className="text-sm text-cyber-accent/70 mb-4">
Contenu complet disponible après un zap de {article.zapAmount} sats Contenu complet disponible après un zap de {article.zapAmount} sats
</p> </p>
<button <Button
variant="success"
onClick={onUnlock} onClick={onUnlock}
disabled={loading} disabled={loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50" loading={loading}
> >
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`} {loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
</button> </Button>
</div> </div>
</div> </div>
) )

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import type { Review, Article } from '@/types/nostr' import type { Review, Article } from '@/types/nostr'
import { getReviewsForArticle } from '@/lib/reviews' import { getReviewsForArticle } from '@/lib/reviews'
import { getReviewTipsForArticle } from '@/lib/reviewAggregation' import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
import { Card, ErrorState, Button } from './ui'
import { ReviewForm } from './ReviewForm' import { ReviewForm } from './ReviewForm'
import { ReviewTipForm } from './ReviewTipForm' import { ReviewTipForm } from './ReviewTipForm'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
@ -17,7 +18,7 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload }) const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload })
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> <Card variant="default" className="space-y-4">
<ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} /> <ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} />
{reviewForm.show && ( {reviewForm.show && (
<ReviewForm <ReviewForm
@ -27,13 +28,13 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
/> />
)} )}
{data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>} {data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
{data.error && <p className="text-sm text-red-400">{data.error}</p>} {data.error && <ErrorState message={data.error} />}
{!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && ( {!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p> <p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
)} )}
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />} {!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
<SelectedReviewTipForm selection={tipSelection} /> <SelectedReviewTipForm selection={tipSelection} />
</div> </Card>
) )
} }
@ -163,12 +164,9 @@ function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('review.title')}</h3>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span> <span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span>
<button <Button variant="success" size="small" onClick={onAddReview}>
onClick={onAddReview}
className="px-3 py-1 text-sm bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('review.add')} {t('review.add')}
</button> </Button>
</div> </div>
</div> </div>
) )
@ -192,14 +190,16 @@ function ArticleReviewsList({ reviews, onTipReview }: { reviews: Review[]; onTip
<span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span> <span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span>
<span></span> <span></span>
<span>{formatDate(r.createdAt)}</span> <span>{formatDate(r.createdAt)}</span>
<button <Button
variant="success"
size="small"
onClick={() => { onClick={() => {
onTipReview(r.id) onTipReview(r.id)
}} }}
className="ml-auto px-2 py-1 text-xs bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded transition-all border border-neon-green/50" className="ml-auto"
> >
{t('review.tip.button')} {t('review.tip.button')}
</button> </Button>
</div> </div>
</div> </div>
))} ))}

View File

@ -1,5 +1,6 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { ErrorState, EmptyState } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface ArticlesListProps { interface ArticlesListProps {
@ -20,21 +21,11 @@ function LoadingState(): React.ReactElement {
) )
} }
function ErrorState({ message }: { message: string }): React.ReactElement { function ArticlesEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
return ( return (
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4"> <EmptyState
<p className="text-red-400">{message}</p> title={hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
</div> />
)
}
function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">
{hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
</p>
</div>
) )
} }
@ -53,7 +44,7 @@ export function ArticlesList({
return <ErrorState message={error} /> return <ErrorState message={error} />
} }
if (articles.length === 0) { if (articles.length === 0) {
return <EmptyState hasAny={allArticles.length > 0} /> return <ArticlesEmptyState hasAny={allArticles.length > 0} />
} }
return ( return (

View File

@ -1,6 +1,7 @@
import Link from 'next/link' import { useRouter } from 'next/router'
import Image from 'next/image' import Image from 'next/image'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { Card } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface AuthorCardProps { interface AuthorCardProps {
@ -8,14 +9,16 @@ interface AuthorCardProps {
} }
export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement { export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement {
const router = useRouter()
const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author') const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author')
const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000 const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000
const handleClick = (): void => {
void router.push(`/author/${presentation.pubkey}`)
}
return ( return (
<Link <Card variant="interactive" onClick={handleClick} className="bg-cyber-dark/50">
href={`/author/${presentation.pubkey}`}
className="block border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50 hover:bg-cyber-dark hover:border-neon-cyan/40 transition-all hover:shadow-glow-cyan"
>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{presentation.bannerUrl && ( {presentation.bannerUrl && (
<div className="relative w-20 h-20 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0"> <div className="relative w-20 h-20 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0">
@ -37,6 +40,6 @@ export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElemen
)} )}
</div> </div>
</div> </div>
</Link> </Card>
) )
} }

View File

@ -1,5 +1,6 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { AuthorCard } from './AuthorCard' import { AuthorCard } from './AuthorCard'
import { ErrorState, EmptyState } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface AuthorsListProps { interface AuthorsListProps {
@ -17,21 +18,11 @@ function LoadingState(): React.ReactElement {
) )
} }
function ErrorState({ message }: { message: string }): React.ReactElement { function AuthorsEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
return ( return (
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4"> <EmptyState
<p className="text-red-400">{message}</p> title={hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
</div> />
)
}
function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">
{hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
</p>
</div>
) )
} }
@ -43,7 +34,7 @@ export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsList
return <ErrorState message={error} /> return <ErrorState message={error} />
} }
if (authors.length === 0) { if (authors.length === 0) {
return <EmptyState hasAny={allAuthors.length > 0} /> return <AuthorsEmptyState hasAny={allAuthors.length > 0} />
} }
return ( return (

View File

@ -7,9 +7,13 @@ interface ClearButtonProps {
export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement { export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement {
return ( return (
<button <button
onClick={onClick} onClick={(e) => {
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neon-cyan/70 hover:text-neon-cyan transition-colors" e.preventDefault()
onClick()
}}
className="text-neon-cyan/70 hover:text-neon-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
aria-label={t('search.clear')} aria-label={t('search.clear')}
type="button"
> >
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path <path

View File

@ -3,15 +3,16 @@ import Image from 'next/image'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan'
function CreateAuthorPageLink(): React.ReactElement { function CreateAuthorPageLink(): React.ReactElement {
return ( return (
<Link href="/presentation" className={buttonClassName}> <Link href="/presentation">
<Button variant="primary" size="small">
{t('nav.createAuthorPage')} {t('nav.createAuthorPage')}
</Button>
</Link> </Link>
) )
} }

View File

@ -3,6 +3,7 @@ import { ConnectedUserMenu } from './ConnectedUserMenu'
import { RecoveryStep } from './CreateAccountModalSteps' import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal' import { UnlockAccountModal } from './UnlockAccountModal'
import type { NostrProfile } from '@/types/nostr' import type { NostrProfile } from '@/types/nostr'
import { Button } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { getConnectButtonMode } from './connectButton/connectButtonMode' import { getConnectButtonMode } from './connectButton/connectButtonMode'
import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState' import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
@ -20,20 +21,22 @@ function ConnectForm({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <Button
variant="primary"
onClick={onCreateAccount} onClick={onCreateAccount}
disabled={loading} disabled={loading}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50" size="large"
> >
{t('connect.createAccount')} {t('connect.createAccount')}
</button> </Button>
<button <Button
variant="secondary"
onClick={onUnlock} onClick={onUnlock}
disabled={loading} disabled={loading}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50" size="large"
> >
{t('connect.connect')} {t('connect.connect')}
</button> </Button>
{error && <p className="text-sm text-red-400">{error}</p>} {error && <p className="text-sm text-red-400">{error}</p>}
</div> </div>
) )

View File

@ -3,7 +3,7 @@ import QRCode from 'react-qr-code'
import type { AlbyInvoice } from '@/types/alby' import type { AlbyInvoice } from '@/types/alby'
import { getAlbyService, isWebLNAvailable } from '@/lib/alby' import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
import { AlbyInstaller } from './AlbyInstaller' import { AlbyInstaller } from './AlbyInstaller'
import { Modal } from './ui/Modal' import { Modal, Button } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface PaymentModalProps { interface PaymentModalProps {
@ -94,20 +94,22 @@ function PaymentActions({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
variant="secondary"
onClick={() => { onClick={() => {
void onCopy() void onCopy()
}} }}
className="flex-1 px-4 py-2 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors" className="flex-1"
> >
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')} {copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
</button> </Button>
<button <Button
variant="primary"
onClick={onOpenWallet} onClick={onOpenWallet}
className="flex-1 px-4 py-2 bg-neon-cyan/20 border border-neon-cyan/50 hover:bg-neon-cyan/30 hover:border-neon-cyan text-neon-cyan hover:text-neon-green rounded-lg font-medium transition-colors" className="flex-1"
> >
{t('payment.modal.payWithAlby')} {t('payment.modal.payWithAlby')}
</button> </Button>
</div> </div>
) )
} }

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { SearchIcon } from './SearchIcon' import { SearchIcon } from './SearchIcon'
import { ClearButton } from './ClearButton' import { ClearButton } from './ClearButton'
import { Input } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface SearchBarProps { interface SearchBarProps {
@ -29,18 +30,14 @@ export function SearchBar({ value, onChange, placeholder }: SearchBarProps): Rea
} }
return ( return (
<div className="relative"> <Input
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon />
</div>
<input
type="text" type="text"
value={localValue} value={localValue}
onChange={handleChange} onChange={handleChange}
placeholder={defaultPlaceholder} placeholder={defaultPlaceholder}
className="block w-full pl-10 pr-10 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent placeholder-cyber-accent/50 hover:border-neon-cyan/50 transition-colors" leftIcon={<SearchIcon />}
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
className="pr-10"
/> />
{localValue && <ClearButton onClick={handleClear} />}
</div>
) )
} }

View File

@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { nostrService } from '@/lib/nostr' import { nostrService } from '@/lib/nostr'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { Button, Card, Textarea, ErrorState } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { sponsoringPaymentService } from '@/lib/sponsoringPayment' import { sponsoringPaymentService } from '@/lib/sponsoringPayment'
import type { AuthorPresentationArticle } from '@/types/nostr' import type { AuthorPresentationArticle } from '@/types/nostr'
@ -129,36 +130,38 @@ async function submitSponsoring(params: {
function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement { function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark"> <Card variant="default">
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p> <p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
<button onClick={params.onConnect} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"> <Button variant="success" onClick={params.onConnect}>
{t('connect.connect')} {t('connect.connect')}
</button> </Button>
</div> </Card>
) )
} }
function SponsoringNoAddress(): React.ReactElement { function SponsoringNoAddress(): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark"> <Card variant="default">
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p> <p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
</div> </Card>
) )
} }
function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement { function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" role="dialog" aria-modal="true"> <div role="dialog" aria-modal="true">
<Card variant="default" className="space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.instructions', { authorAddress: params.instructions.authorAddress, platformAddress: params.instructions.platformAddress, authorAmount: params.instructions.authorBtc, platformAmount: params.instructions.platformBtc })}</p> <p className="text-sm text-cyber-accent/70">{t('sponsoring.form.instructions', { authorAddress: params.instructions.authorAddress, platformAddress: params.instructions.platformAddress, authorAmount: params.instructions.authorBtc, platformAmount: params.instructions.platformBtc })}</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button type="button" onClick={params.onClose} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"> <Button type="button" variant="success" onClick={params.onClose}>
{t('common.close')} {t('common.close')}
</button> </Button>
<button type="button" onClick={params.onCancel} className="px-4 py-2 bg-neon-cyan/10 hover:bg-neon-cyan/20 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/30"> <Button type="button" variant="ghost" onClick={params.onCancel}>
{t('common.cancel')} {t('common.cancel')}
</button> </Button>
</div> </div>
</Card>
</div> </div>
) )
} }
@ -175,29 +178,24 @@ function SponsoringFormView(params: {
<form onSubmit={params.onSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> <form onSubmit={params.onSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p> <p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p>
<div> <Textarea
<label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label>
<textarea
id="sponsoring-text" id="sponsoring-text"
label={`${t('sponsoring.form.text.label')} (${t('common.optional')})`}
value={params.text} value={params.text}
onChange={(e) => params.setText(e.target.value)} onChange={(e) => params.setText(e.target.value)}
placeholder={t('sponsoring.form.text.placeholder')} placeholder={t('sponsoring.form.text.placeholder')}
rows={3} rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none" helperText={t('sponsoring.form.text.help')}
/> />
<p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p> {params.error && <ErrorState message={params.error} />}
</div>
{params.error && <div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">{params.error}</div>}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" disabled={params.loading} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"> <Button type="submit" variant="success" disabled={params.loading} loading={params.loading}>
{params.loading ? t('common.loading') : t('sponsoring.form.submit')} {params.loading ? t('common.loading') : t('sponsoring.form.submit')}
</button> </Button>
{params.onCancel && ( {params.onCancel && (
<button type="button" onClick={params.onCancel} className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"> <Button type="button" variant="ghost" onClick={params.onCancel}>
{t('common.cancel')} {t('common.cancel')}
</button> </Button>
)} )}
</div> </div>
</form> </form>

View File

@ -1,16 +1,14 @@
import React from 'react' import React from 'react'
import { Button, Card } from '@/components/ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export function ConnectRequiredCard(params: { message: string; onConnect: () => void }): React.ReactElement { export function ConnectRequiredCard(params: { message: string; onConnect: () => void }): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark"> <Card variant="default">
<p className="text-cyber-accent mb-4">{params.message}</p> <p className="text-cyber-accent mb-4">{params.message}</p>
<button <Button variant="success" onClick={params.onConnect}>
onClick={params.onConnect}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')} {t('connect.connect')}
</button> </Button>
</div> </Card>
) )
} }

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { Button, ErrorState } from '@/components/ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { ReviewFormController } from './useReviewFormController' import type { ReviewFormController } from './useReviewFormController'
@ -60,32 +61,29 @@ function ReviewFormFields(params: { ctrl: ReviewFormController }): React.ReactEl
function ReviewFormActions(params: { loading: boolean; onCancel?: (() => void) | undefined }): React.ReactElement { function ReviewFormActions(params: { loading: boolean; onCancel?: (() => void) | undefined }): React.ReactElement {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="submit" type="submit"
variant="success"
disabled={params.loading} disabled={params.loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50" loading={params.loading}
> >
{params.loading ? t('common.loading') : t('review.form.submit')} {params.loading ? t('common.loading') : t('review.form.submit')}
</button> </Button>
{params.onCancel ? ( {params.onCancel && (
<button <Button
type="button" type="button"
variant="ghost"
onClick={params.onCancel} onClick={params.onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </Button>
) : null} )}
</div> </div>
) )
} }
function ErrorBox({ message }: { message: string }): React.ReactElement { function ErrorBox({ message }: { message: string }): React.ReactElement {
return ( return <ErrorState message={message} />
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{message}
</div>
)
} }
function TextInput(params: { function TextInput(params: {

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { Button, ErrorState } from '@/components/ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { ReviewTipFormController } from './useReviewTipFormController' import type { ReviewTipFormController } from './useReviewTipFormController'
@ -57,30 +58,27 @@ function ReviewTipFormActions(params: {
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="submit" type="submit"
variant="success"
disabled={params.loading} disabled={params.loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50" loading={params.loading}
> >
{params.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.amount })} {params.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.amount })}
</button> </Button>
{params.onCancel ? ( {params.onCancel && (
<button <Button
type="button" type="button"
variant="ghost"
onClick={params.onCancel} onClick={params.onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </Button>
) : null} )}
</div> </div>
) )
} }
function ErrorBox({ message }: { message: string }): React.ReactElement { function ErrorBox({ message }: { message: string }): React.ReactElement {
return ( return <ErrorState message={message} />
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{message}
</div>
)
} }

View File

@ -69,7 +69,6 @@ export function MobileMenu({ children, 'aria-label': ariaLabel }: MobileMenuProp
/> />
{isOpen && ( {isOpen && (
<MobileMenuDrawer <MobileMenuDrawer
isOpen={isOpen}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
ariaLabel={ariaLabel ?? 'Mobile menu'} ariaLabel={ariaLabel ?? 'Mobile menu'}
> >
@ -102,24 +101,26 @@ function MobileMenuButton({
) )
} }
function MobileMenuDrawer({ function MobileMenuOverlay({ onClose }: { onClose: () => void }): React.ReactElement {
isOpen,
onClose,
ariaLabel,
children,
}: {
isOpen: boolean
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
return ( return (
<>
<div <div
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden" className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
onClick={onClose} onClick={onClose}
aria-hidden="true" aria-hidden="true"
/> />
)
}
function MobileMenuContent({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
return (
<div <div
id="mobile-menu" id="mobile-menu"
className="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-cyber-dark border-l border-neon-cyan/30 shadow-glow-cyan z-50 transform transition-transform md:hidden overflow-y-auto" className="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-cyber-dark border-l border-neon-cyan/30 shadow-glow-cyan z-50 transform transition-transform md:hidden overflow-y-auto"
@ -140,6 +141,24 @@ function MobileMenuDrawer({
<div className="space-y-4">{children}</div> <div className="space-y-4">{children}</div>
</div> </div>
</div> </div>
)
}
function MobileMenuDrawer({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
return (
<>
<MobileMenuOverlay onClose={onClose} />
<MobileMenuContent onClose={onClose} ariaLabel={ariaLabel}>
{children}
</MobileMenuContent>
</> </>
) )
} }

View File

@ -43,7 +43,7 @@ function ModalHeader({
showCloseButton, showCloseButton,
onClose, onClose,
}: { }: {
title?: string title?: string | undefined
showCloseButton: boolean showCloseButton: boolean
onClose: () => void onClose: () => void
}): React.ReactElement | null { }): React.ReactElement | null {
@ -58,32 +58,28 @@ function ModalHeader({
) )
} }
export function Modal({ function useModalFocus(modalRef: React.RefObject<HTMLDivElement | null>, isOpen: boolean): void {
children,
isOpen,
onClose,
title,
size = 'medium',
showCloseButton = true,
'aria-label': ariaLabel,
}: ModalProps): React.ReactElement | null {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null) const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement const { activeElement } = document
if (activeElement instanceof HTMLElement) {
previousFocusRef.current = activeElement
}
const firstFocusable = modalRef.current?.querySelector( const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement | null )
if (firstFocusable) { if (firstFocusable instanceof HTMLElement) {
firstFocusable.focus() firstFocusable.focus()
} }
} else { } else {
previousFocusRef.current?.focus() previousFocusRef.current?.focus()
} }
}, [isOpen]) }, [isOpen, modalRef])
}
function useModalKeyboard(isOpen: boolean, onClose: () => void): void {
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent): void => { const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && isOpen) { if (e.key === 'Escape' && isOpen) {
@ -101,13 +97,17 @@ export function Modal({
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [isOpen, onClose]) }, [isOpen, onClose])
}
if (!isOpen) { function ModalOverlay({
return null onClose,
} ariaLabel,
children,
const sizeClasses = getSizeClasses(size) }: {
onClose: () => void
ariaLabel: string | undefined
children: ReactNode
}): React.ReactElement {
return ( return (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@ -118,8 +118,29 @@ export function Modal({
}} }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={ariaLabel ?? title} aria-label={ariaLabel}
> >
{children}
</div>
)
}
function ModalContent({
modalRef,
sizeClasses,
title,
showCloseButton,
onClose,
children,
}: {
modalRef: React.RefObject<HTMLDivElement | null>
sizeClasses: string
title?: string | undefined
showCloseButton: boolean
onClose: () => void
children: ReactNode
}): React.ReactElement {
return (
<div <div
ref={modalRef} ref={modalRef}
className={`bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan ${sizeClasses}`} className={`bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan ${sizeClasses}`}
@ -128,6 +149,39 @@ export function Modal({
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} /> <ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
{children} {children}
</div> </div>
</div> )
}
export function Modal({
children,
isOpen,
onClose,
title,
size = 'medium',
showCloseButton = true,
'aria-label': ariaLabel,
}: ModalProps): React.ReactElement | null {
const modalRef = useRef<HTMLDivElement>(null)
useModalFocus(modalRef, isOpen)
useModalKeyboard(isOpen, onClose)
if (!isOpen) {
return null
}
const sizeClasses = getSizeClasses(size)
return (
<ModalOverlay onClose={onClose} ariaLabel={ariaLabel ?? title}>
<ModalContent
modalRef={modalRef}
sizeClasses={sizeClasses}
title={title}
showCloseButton={showCloseButton}
onClose={onClose}
>
{children}
</ModalContent>
</ModalOverlay>
) )
} }