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 { getAlbyService } from '@/lib/alby'
import { Button } from './ui'
interface AlbyInstallerProps {
onInstalled?: () => void
@ -43,18 +44,20 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
href="https://getalby.com/"
target="_blank"
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
</Button>
</a>
<button
<Button
variant="secondary"
size="small"
onClick={() => {
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
</button>
</Button>
</div>
</div>
)

View File

@ -3,6 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useArticlePayment } from '@/hooks/useArticlePayment'
import { ArticlePreview } from './ArticlePreview'
import { PaymentModal } from './PaymentModal'
import { Card } from './ui'
import { t } from '@/lib/i18n'
import Link from 'next/link'
@ -69,7 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
}, connect)
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} />
<div className="text-cyber-accent mb-4">
<ArticlePreview
@ -89,6 +90,6 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
void handlePaymentComplete()
}}
/>
</article>
</Card>
)
}

View File

@ -1,5 +1,6 @@
import React from 'react'
import type { Article } from '@/types/nostr'
import { Card } from './ui'
import { t } from '@/lib/i18n'
import { AuthorFilter } from './AuthorFilter'
@ -123,9 +124,9 @@ export function ArticleFiltersComponent({
filters.category !== null
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} />
<FiltersGrid data={data} filters={filters} onFiltersChange={onFiltersChange} />
</div>
</Card>
)
}

View File

@ -1,3 +1,4 @@
import { Button } from './ui'
import { t } from '@/lib/i18n'
interface ArticleFormButtonsProps {
@ -10,21 +11,23 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
return (
<div className="space-y-3 pt-4">
<div className="flex gap-3">
<button
<Button
type="submit"
variant="primary"
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')}
</button>
</Button>
{onCancel && (
<button
<Button
type="button"
variant="ghost"
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')}
</button>
</Button>
)}
</div>
</div>

View File

@ -1,4 +1,5 @@
import type { Article } from '@/types/nostr'
import { Button } from './ui'
import { ArticlePages } from './ArticlePages'
interface ArticlePreviewProps {
@ -25,13 +26,14 @@ export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewPro
<p className="text-sm text-cyber-accent/70 mb-4">
Contenu complet disponible après un zap de {article.zapAmount} sats
</p>
<button
<Button
variant="success"
onClick={onUnlock}
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`}
</button>
</Button>
</div>
</div>
)

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import type { Review, Article } from '@/types/nostr'
import { getReviewsForArticle } from '@/lib/reviews'
import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
import { Card, ErrorState, Button } from './ui'
import { ReviewForm } from './ReviewForm'
import { ReviewTipForm } from './ReviewTipForm'
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 })
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} />
{reviewForm.show && (
<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.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 && (
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
)}
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
<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>
<div className="flex items-center gap-4">
<span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span>
<button
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"
>
<Button variant="success" size="small" onClick={onAddReview}>
{t('review.add')}
</button>
</Button>
</div>
</div>
)
@ -192,14 +190,16 @@ function ArticleReviewsList({ reviews, onTipReview }: { reviews: Review[]; onTip
<span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span>
<span></span>
<span>{formatDate(r.createdAt)}</span>
<button
<Button
variant="success"
size="small"
onClick={() => {
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')}
</button>
</Button>
</div>
</div>
))}

View File

@ -1,5 +1,6 @@
import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard'
import { ErrorState, EmptyState } from './ui'
import { t } from '@/lib/i18n'
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 (
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{message}</p>
</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>
<EmptyState
title={hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
/>
)
}
@ -53,7 +44,7 @@ export function ArticlesList({
return <ErrorState message={error} />
}
if (articles.length === 0) {
return <EmptyState hasAny={allArticles.length > 0} />
return <ArticlesEmptyState hasAny={allArticles.length > 0} />
}
return (

View File

@ -1,6 +1,7 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import Image from 'next/image'
import type { Article } from '@/types/nostr'
import { Card } from './ui'
import { t } from '@/lib/i18n'
interface AuthorCardProps {
@ -8,14 +9,16 @@ interface AuthorCardProps {
}
export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement {
const router = useRouter()
const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author')
const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000
const handleClick = (): void => {
void router.push(`/author/${presentation.pubkey}`)
}
return (
<Link
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"
>
<Card variant="interactive" onClick={handleClick} className="bg-cyber-dark/50">
<div className="flex items-start gap-4">
{presentation.bannerUrl && (
<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>
</Link>
</Card>
)
}

View File

@ -1,5 +1,6 @@
import type { Article } from '@/types/nostr'
import { AuthorCard } from './AuthorCard'
import { ErrorState, EmptyState } from './ui'
import { t } from '@/lib/i18n'
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 (
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{message}</p>
</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>
<EmptyState
title={hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
/>
)
}
@ -43,7 +34,7 @@ export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsList
return <ErrorState message={error} />
}
if (authors.length === 0) {
return <EmptyState hasAny={allAuthors.length > 0} />
return <AuthorsEmptyState hasAny={allAuthors.length > 0} />
}
return (

View File

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

View File

@ -3,15 +3,16 @@ import Image from 'next/image'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react'
import { Button } from './ui'
import { t } from '@/lib/i18n'
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 {
return (
<Link href="/presentation" className={buttonClassName}>
<Link href="/presentation">
<Button variant="primary" size="small">
{t('nav.createAuthorPage')}
</Button>
</Link>
)
}

View File

@ -3,6 +3,7 @@ import { ConnectedUserMenu } from './ConnectedUserMenu'
import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal'
import type { NostrProfile } from '@/types/nostr'
import { Button } from './ui'
import { t } from '@/lib/i18n'
import { getConnectButtonMode } from './connectButton/connectButtonMode'
import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
@ -20,20 +21,22 @@ function ConnectForm({
}): React.ReactElement {
return (
<div className="flex flex-col gap-2">
<button
<Button
variant="primary"
onClick={onCreateAccount}
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')}
</button>
<button
</Button>
<Button
variant="secondary"
onClick={onUnlock}
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')}
</button>
</Button>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)

View File

@ -3,7 +3,7 @@ import QRCode from 'react-qr-code'
import type { AlbyInvoice } from '@/types/alby'
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
import { AlbyInstaller } from './AlbyInstaller'
import { Modal } from './ui/Modal'
import { Modal, Button } from './ui'
import { t } from '@/lib/i18n'
interface PaymentModalProps {
@ -94,20 +94,22 @@ function PaymentActions({
}): React.ReactElement {
return (
<div className="flex gap-2">
<button
<Button
variant="secondary"
onClick={() => {
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')}
</button>
<button
</Button>
<Button
variant="primary"
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')}
</button>
</Button>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { SearchIcon } from './SearchIcon'
import { ClearButton } from './ClearButton'
import { Input } from './ui'
import { t } from '@/lib/i18n'
interface SearchBarProps {
@ -29,18 +30,14 @@ export function SearchBar({ value, onChange, placeholder }: SearchBarProps): Rea
}
return (
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon />
</div>
<input
<Input
type="text"
value={localValue}
onChange={handleChange}
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 { nostrService } from '@/lib/nostr'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { Button, Card, Textarea, ErrorState } from './ui'
import { t } from '@/lib/i18n'
import { sponsoringPaymentService } from '@/lib/sponsoringPayment'
import type { AuthorPresentationArticle } from '@/types/nostr'
@ -129,36 +130,38 @@ async function submitSponsoring(params: {
function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
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>
<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')}
</button>
</div>
</Button>
</Card>
)
}
function SponsoringNoAddress(): React.ReactElement {
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>
</div>
</Card>
)
}
function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
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>
<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">
<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')}
</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>
<Button type="button" variant="ghost" onClick={params.onCancel}>
{t('common.cancel')}
</button>
</Button>
</div>
</Card>
</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">
<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>
<div>
<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
<Textarea
id="sponsoring-text"
label={`${t('sponsoring.form.text.label')} (${t('common.optional')})`}
value={params.text}
onChange={(e) => params.setText(e.target.value)}
placeholder={t('sponsoring.form.text.placeholder')}
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>
</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>}
{params.error && <ErrorState message={params.error} />}
<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')}
</button>
</Button>
{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')}
</button>
</Button>
)}
</div>
</form>

View File

@ -1,16 +1,14 @@
import React from 'react'
import { Button, Card } from '@/components/ui'
import { t } from '@/lib/i18n'
export function ConnectRequiredCard(params: { message: string; onConnect: () => void }): React.ReactElement {
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>
<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')}
</button>
</div>
</Button>
</Card>
)
}

View File

@ -1,4 +1,5 @@
import React from 'react'
import { Button, ErrorState } from '@/components/ui'
import { t } from '@/lib/i18n'
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 {
return (
<div className="flex gap-2">
<button
<Button
type="submit"
variant="success"
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')}
</button>
{params.onCancel ? (
<button
</Button>
{params.onCancel && (
<Button
type="button"
variant="ghost"
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')}
</button>
) : null}
</Button>
)}
</div>
)
}
function ErrorBox({ message }: { message: string }): React.ReactElement {
return (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{message}
</div>
)
return <ErrorState message={message} />
}
function TextInput(params: {

View File

@ -1,4 +1,5 @@
import React from 'react'
import { Button, ErrorState } from '@/components/ui'
import { t } from '@/lib/i18n'
import type { ReviewTipFormController } from './useReviewTipFormController'
@ -57,30 +58,27 @@ function ReviewTipFormActions(params: {
}): React.ReactElement {
return (
<div className="flex gap-2">
<button
<Button
type="submit"
variant="success"
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 })}
</button>
{params.onCancel ? (
<button
</Button>
{params.onCancel && (
<Button
type="button"
variant="ghost"
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')}
</button>
) : null}
</Button>
)}
</div>
)
}
function ErrorBox({ message }: { message: string }): React.ReactElement {
return (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{message}
</div>
)
return <ErrorState message={message} />
}

View File

@ -69,7 +69,6 @@ export function MobileMenu({ children, 'aria-label': ariaLabel }: MobileMenuProp
/>
{isOpen && (
<MobileMenuDrawer
isOpen={isOpen}
onClose={() => setIsOpen(false)}
ariaLabel={ariaLabel ?? 'Mobile menu'}
>
@ -102,24 +101,26 @@ function MobileMenuButton({
)
}
function MobileMenuDrawer({
isOpen,
onClose,
ariaLabel,
children,
}: {
isOpen: boolean
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
function MobileMenuOverlay({ onClose }: { onClose: () => void }): React.ReactElement {
return (
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
onClick={onClose}
aria-hidden="true"
/>
)
}
function MobileMenuContent({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
return (
<div
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"
@ -140,6 +141,24 @@ function MobileMenuDrawer({
<div className="space-y-4">{children}</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,
onClose,
}: {
title?: string
title?: string | undefined
showCloseButton: boolean
onClose: () => void
}): React.ReactElement | null {
@ -58,32 +58,28 @@ function ModalHeader({
)
}
export function Modal({
children,
isOpen,
onClose,
title,
size = 'medium',
showCloseButton = true,
'aria-label': ariaLabel,
}: ModalProps): React.ReactElement | null {
const modalRef = useRef<HTMLDivElement>(null)
function useModalFocus(modalRef: React.RefObject<HTMLDivElement | null>, isOpen: boolean): void {
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement
const { activeElement } = document
if (activeElement instanceof HTMLElement) {
previousFocusRef.current = activeElement
}
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement | null
if (firstFocusable) {
)
if (firstFocusable instanceof HTMLElement) {
firstFocusable.focus()
}
} else {
previousFocusRef.current?.focus()
}
}, [isOpen])
}, [isOpen, modalRef])
}
function useModalKeyboard(isOpen: boolean, onClose: () => void): void {
useEffect(() => {
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && isOpen) {
@ -101,13 +97,17 @@ export function Modal({
document.body.style.overflow = ''
}
}, [isOpen, onClose])
}
if (!isOpen) {
return null
}
const sizeClasses = getSizeClasses(size)
function ModalOverlay({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string | undefined
children: ReactNode
}): React.ReactElement {
return (
<div
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"
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
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}`}
@ -128,6 +149,39 @@ export function Modal({
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
{children}
</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>
)
}