create for series
This commit is contained in:
parent
57acb3d9f3
commit
6fcfae4cc0
@ -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"
|
||||
>
|
||||
Install Alby
|
||||
<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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}>
|
||||
{t('nav.createAuthorPage')}
|
||||
<Link href="/presentation">
|
||||
<Button variant="primary" size="small">
|
||||
{t('nav.createAuthorPage')}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
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"
|
||||
/>
|
||||
{localValue && <ClearButton onClick={handleClear} />}
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={defaultPlaceholder}
|
||||
leftIcon={<SearchIcon />}
|
||||
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
|
||||
className="pr-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<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">
|
||||
{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">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
<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" variant="success" onClick={params.onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={params.onCancel}>
|
||||
{t('common.cancel')}
|
||||
</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
|
||||
id="sponsoring-text"
|
||||
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"
|
||||
/>
|
||||
<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>}
|
||||
<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}
|
||||
helperText={t('sponsoring.form.text.help')}
|
||||
/>
|
||||
{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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -69,7 +69,6 @@ export function MobileMenu({ children, 'aria-label': ariaLabel }: MobileMenuProp
|
||||
/>
|
||||
{isOpen && (
|
||||
<MobileMenuDrawer
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
ariaLabel={ariaLabel ?? 'Mobile menu'}
|
||||
>
|
||||
@ -102,44 +101,64 @@ function MobileMenuButton({
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuDrawer({
|
||||
isOpen,
|
||||
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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuDrawer({
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MobileMenuOverlay onClose={onClose} />
|
||||
<MobileMenuContent onClose={onClose} ariaLabel={ariaLabel}>
|
||||
{children}
|
||||
</MobileMenuContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,16 +118,70 @@ export function Modal({
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel ?? title}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<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}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
|
||||
{children}
|
||||
</div>
|
||||
{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}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
|
||||
{children}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user