130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
import type { Page } from '@/types/nostr'
|
|
import { t } from '@/lib/i18n'
|
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
|
import { useEffect, useState } from 'react'
|
|
import { objectCache } from '@/lib/objectCache'
|
|
|
|
interface ArticlePagesProps {
|
|
pages: Page[]
|
|
articleId: string
|
|
}
|
|
|
|
export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null {
|
|
const { pubkey } = useNostrAuth()
|
|
const hasPurchased = useHasPurchasedArticle({ pubkey, articleId })
|
|
|
|
if (!pages || pages.length === 0) {
|
|
return null
|
|
}
|
|
|
|
if (!pubkey || !hasPurchased) {
|
|
return <LockedPagesView pagesCount={pages.length} />
|
|
}
|
|
|
|
return <PurchasedPagesView pages={pages} />
|
|
}
|
|
|
|
function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactElement {
|
|
return (
|
|
<div className="space-y-6 mt-6">
|
|
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
|
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark text-center">
|
|
<p className="text-cyber-accent mb-2">{t('article.pages.locked.title')}</p>
|
|
<p className="text-sm text-cyber-accent/70">{t('article.pages.locked.message', { count: pagesCount })}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
|
|
return (
|
|
<div className="space-y-6 mt-6">
|
|
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{pages.map((page) => (
|
|
<PageDisplay key={page.number} page={page} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useHasPurchasedArticle({
|
|
pubkey,
|
|
articleId,
|
|
}: {
|
|
pubkey: string | null
|
|
articleId: string
|
|
}): boolean {
|
|
const [hasPurchased, setHasPurchased] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!pubkey) {
|
|
return
|
|
}
|
|
|
|
const checkPurchase = async (): Promise<void> => {
|
|
try {
|
|
const purchases = await objectCache.getAll('purchase')
|
|
const userPurchases = purchases.filter((p) => isUserPurchase({ p, pubkey, articleId }))
|
|
setHasPurchased(userPurchases.length > 0)
|
|
} catch (error) {
|
|
console.error('Error checking purchase status:', error)
|
|
setHasPurchased(false)
|
|
}
|
|
}
|
|
|
|
void checkPurchase()
|
|
}, [pubkey, articleId])
|
|
|
|
return hasPurchased
|
|
}
|
|
|
|
function isUserPurchase({
|
|
p,
|
|
pubkey,
|
|
articleId,
|
|
}: {
|
|
p: unknown
|
|
pubkey: string
|
|
articleId: string
|
|
}): boolean {
|
|
if (typeof p !== 'object' || p === null) {
|
|
return false
|
|
}
|
|
const purchase = p as { payerPubkey?: string; articleId?: string }
|
|
return purchase.payerPubkey === pubkey && purchase.articleId === articleId
|
|
}
|
|
|
|
function PageDisplay({ page }: { page: Page }): React.ReactElement {
|
|
return (
|
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="font-semibold text-neon-cyan">
|
|
{t('page.number', { number: page.number })}
|
|
</h4>
|
|
<span className="text-xs text-cyber-accent/70">{t(`page.type.${page.type}`)}</span>
|
|
</div>
|
|
{page.type === 'markdown' ? (
|
|
<div className="prose prose-invert max-w-none text-cyber-accent whitespace-pre-wrap">
|
|
{page.content || <span className="text-cyber-accent/50">{t('page.markdown.empty')}</span>}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{page.content ? (
|
|
<img
|
|
src={page.content}
|
|
alt={t('page.image.alt', { number: page.number })}
|
|
className="max-w-full h-auto rounded border border-neon-cyan/20"
|
|
/>
|
|
) : (
|
|
<div className="text-center py-8 text-cyber-accent/50 border border-dashed border-neon-cyan/20 rounded">
|
|
{t('page.image.empty')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|