ux wip
This commit is contained in:
parent
b792e5373a
commit
bc557b9657
@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Card, ErrorState } from './ui'
|
import { Card, ErrorState } from './ui'
|
||||||
import type { ArticleDraft } from '@/lib/articlePublisher'
|
import type { ArticleDraft } from '@/lib/articlePublisher'
|
||||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||||
@ -6,6 +6,7 @@ import type { RelayPublishStatus } from '@/lib/publishResult'
|
|||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft'
|
import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft'
|
||||||
import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight'
|
import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight'
|
||||||
|
import { ArticlePreviewModal } from './ArticlePreviewModal'
|
||||||
|
|
||||||
interface ArticleEditorFormProps {
|
interface ArticleEditorFormProps {
|
||||||
draft: ArticleDraft
|
draft: ArticleDraft
|
||||||
@ -26,6 +27,23 @@ function ErrorAlert({ error }: { error: string | null }): React.ReactElement | n
|
|||||||
return <ErrorState message={error} className="bg-red-50 border-red-200 text-red-800" />
|
return <ErrorState message={error} className="bg-red-50 border-red-200 text-red-800" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePreviewModal(): {
|
||||||
|
showPreview: boolean
|
||||||
|
openPreview: () => void
|
||||||
|
closePreview: () => void
|
||||||
|
} {
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
return {
|
||||||
|
showPreview,
|
||||||
|
openPreview: () => {
|
||||||
|
setShowPreview(true)
|
||||||
|
},
|
||||||
|
closePreview: () => {
|
||||||
|
setShowPreview(false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ArticleEditorForm({
|
export function ArticleEditorForm({
|
||||||
draft,
|
draft,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
@ -37,7 +55,10 @@ export function ArticleEditorForm({
|
|||||||
seriesOptions,
|
seriesOptions,
|
||||||
onSelectSeries,
|
onSelectSeries,
|
||||||
}: ArticleEditorFormProps): React.ReactElement {
|
}: ArticleEditorFormProps): React.ReactElement {
|
||||||
|
const { showPreview, openPreview, closePreview } = usePreviewModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card variant="default" className="bg-white">
|
<Card variant="default" className="bg-white">
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
<h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>
|
||||||
@ -51,8 +72,15 @@ export function ArticleEditorForm({
|
|||||||
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
||||||
</div>
|
</div>
|
||||||
<ErrorAlert error={error} />
|
<ErrorAlert error={error} />
|
||||||
<ArticleFormButtons loading={loading} relayStatuses={relayStatuses} {...(onCancel ? { onCancel } : {})} />
|
<ArticleFormButtons
|
||||||
|
loading={loading}
|
||||||
|
relayStatuses={relayStatuses}
|
||||||
|
onPreview={openPreview}
|
||||||
|
{...(onCancel ? { onCancel } : {})}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ArticlePreviewModal isOpen={showPreview} onClose={closePreview} draft={draft} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,12 @@ import { t } from '@/lib/i18n'
|
|||||||
|
|
||||||
interface ArticleFormButtonsProps {
|
interface ArticleFormButtonsProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
relayStatuses?: unknown // Kept for backward compatibility but not displayed
|
relayStatuses?: unknown
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
|
onPreview?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProps): React.ReactElement {
|
export function ArticleFormButtons({ loading, onCancel, onPreview }: ArticleFormButtonsProps): React.ReactElement {
|
||||||
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">
|
||||||
@ -20,6 +21,16 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
|
|||||||
>
|
>
|
||||||
{loading ? t('publish.publishing') : t('publish.button')}
|
{loading ? t('publish.publishing') : t('publish.button')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{onPreview && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onPreview}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t('article.preview.button')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -120,6 +120,7 @@ function PageDisplay({ page }: { page: Page }): React.ReactElement {
|
|||||||
src={page.content}
|
src={page.content}
|
||||||
alt={t('page.image.alt', { number: page.number })}
|
alt={t('page.image.alt', { number: page.number })}
|
||||||
className="max-w-full h-auto rounded border border-neon-cyan/20"
|
className="max-w-full h-auto rounded border border-neon-cyan/20"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-cyber-accent/50 border border-dashed border-neon-cyan/20 rounded">
|
<div className="text-center py-8 text-cyber-accent/50 border border-dashed border-neon-cyan/20 rounded">
|
||||||
|
|||||||
87
components/ArticlePreviewModal.tsx
Normal file
87
components/ArticlePreviewModal.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Modal } from './ui'
|
||||||
|
import type { ArticleDraft } from '@/lib/articlePublisher'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
|
interface ArticlePreviewModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
draft: ArticleDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticlePreviewModal({ isOpen, onClose, draft }: ArticlePreviewModalProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('article.preview.title')}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<ArticlePreviewContent draft={draft} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticlePreviewContent({ draft }: { draft: ArticleDraft }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ArticlePreviewHeader draft={draft} />
|
||||||
|
<ArticlePreviewBody draft={draft} />
|
||||||
|
<ArticlePreviewMeta draft={draft} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticlePreviewHeader({ draft }: { draft: ArticleDraft }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-neon-cyan/30 pb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-neon-cyan mb-2">{draft.title || t('article.preview.noTitle')}</h2>
|
||||||
|
{draft.category && (
|
||||||
|
<span className="text-sm text-cyber-accent/70">
|
||||||
|
{t(`category.${draft.category}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticlePreviewBody({ draft }: { draft: ArticleDraft }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{draft.preview && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-neon-cyan mb-2">{t('article.preview.previewLabel')}</h3>
|
||||||
|
<p className="text-cyber-accent whitespace-pre-wrap">{draft.preview}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{draft.content && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-neon-cyan mb-2">{t('article.preview.contentLabel')}</h3>
|
||||||
|
<p className="text-sm text-cyber-accent/80 whitespace-pre-wrap">{draft.content}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!draft.preview && !draft.content) && (
|
||||||
|
<p className="text-cyber-accent/50 italic">{t('article.preview.empty')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticlePreviewMeta({ draft }: { draft: ArticleDraft }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-neon-cyan/30 pt-4 text-xs text-cyber-accent/70 space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{t('article.preview.zapAmount')}:</span> {draft.zapAmount} sats
|
||||||
|
</div>
|
||||||
|
{draft.seriesId && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{t('article.preview.series')}:</span> {draft.seriesId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{draft.media && draft.media.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{t('article.preview.media')}:</span> {draft.media.length} {t('article.preview.mediaItems')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { ArticleCard } from './ArticleCard'
|
import { ArticleCard } from './ArticleCard'
|
||||||
import { ErrorState, EmptyState, Skeleton } from './ui'
|
import { ErrorState, EmptyState, Skeleton, Button } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
|
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
|
||||||
|
import { usePagination } from '@/hooks/usePagination'
|
||||||
|
|
||||||
interface ArticlesListProps {
|
interface ArticlesListProps {
|
||||||
articles: Article[]
|
articles: Article[]
|
||||||
@ -14,6 +15,8 @@ interface ArticlesListProps {
|
|||||||
unlockedArticles: Set<string>
|
unlockedArticles: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
function ArticleCardSkeleton(): React.ReactElement {
|
function ArticleCardSkeleton(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark space-y-4">
|
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark space-y-4">
|
||||||
@ -69,7 +72,106 @@ function ArticlesErrorState({ error }: { error: string }): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArticlesContent({
|
function PaginationInfo({ currentPage, totalPages }: { currentPage: number; totalPages: number }): React.ReactElement {
|
||||||
|
const stepText = t('pagination.page', { current: currentPage, total: totalPages })
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-cyber-accent/70">
|
||||||
|
{stepText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationButtons({
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
hasNext,
|
||||||
|
hasPrevious,
|
||||||
|
}: {
|
||||||
|
onNext: () => void
|
||||||
|
onPrevious: () => void
|
||||||
|
hasNext: boolean
|
||||||
|
hasPrevious: boolean
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={!hasPrevious}
|
||||||
|
>
|
||||||
|
{t('pagination.previous')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!hasNext}
|
||||||
|
>
|
||||||
|
{t('pagination.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationControls({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
hasNext,
|
||||||
|
hasPrevious,
|
||||||
|
}: {
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
onNext: () => void
|
||||||
|
onPrevious: () => void
|
||||||
|
hasNext: boolean
|
||||||
|
hasPrevious: boolean
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-neon-cyan/30">
|
||||||
|
<PaginationInfo currentPage={currentPage} totalPages={totalPages} />
|
||||||
|
<PaginationButtons
|
||||||
|
onNext={onNext}
|
||||||
|
onPrevious={onPrevious}
|
||||||
|
hasNext={hasNext}
|
||||||
|
hasPrevious={hasPrevious}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticlesListItems({
|
||||||
|
articles,
|
||||||
|
allArticles,
|
||||||
|
onUnlock,
|
||||||
|
unlockedArticles,
|
||||||
|
}: {
|
||||||
|
articles: Article[]
|
||||||
|
allArticles: Article[]
|
||||||
|
onUnlock: (article: Article) => void
|
||||||
|
unlockedArticles: Set<string>
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" role="list">
|
||||||
|
{articles.map((article) => (
|
||||||
|
<div key={article.id} role="listitem">
|
||||||
|
<ArticleCard
|
||||||
|
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
|
||||||
|
onUnlock={onUnlock}
|
||||||
|
allArticles={allArticles}
|
||||||
|
unlockedArticles={unlockedArticles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticlesListContent({
|
||||||
articles,
|
articles,
|
||||||
allArticles,
|
allArticles,
|
||||||
onUnlock,
|
onUnlock,
|
||||||
@ -82,23 +184,36 @@ function ArticlesContent({
|
|||||||
unlockedArticles: Set<string>
|
unlockedArticles: Set<string>
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const pagination = usePagination({ items: articles, itemsPerPage: ITEMS_PER_PAGE })
|
||||||
|
const showingText = t('pagination.showing', {
|
||||||
|
current: pagination.paginatedItems.length,
|
||||||
|
total: articles.length,
|
||||||
|
all: allArticles.length,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="articles-section" aria-label={t('navigation.articlesSection')} tabIndex={-1}>
|
<section id="articles-section" aria-label={t('navigation.articlesSection')} tabIndex={-1}>
|
||||||
<div className="mb-4 text-sm text-cyber-accent/70">
|
<div className="mb-4 text-sm text-cyber-accent/70">
|
||||||
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
|
{showingText}
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="space-y-6" role="list">
|
<div ref={containerRef}>
|
||||||
{articles.map((article) => (
|
<ArticlesListItems
|
||||||
<div key={article.id} role="listitem">
|
articles={pagination.paginatedItems}
|
||||||
<ArticleCard
|
|
||||||
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
|
|
||||||
onUnlock={onUnlock}
|
|
||||||
allArticles={allArticles}
|
allArticles={allArticles}
|
||||||
|
onUnlock={onUnlock}
|
||||||
unlockedArticles={unlockedArticles}
|
unlockedArticles={unlockedArticles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{pagination.totalPages > 1 && (
|
||||||
</div>
|
<PaginationControls
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
totalPages={pagination.totalPages}
|
||||||
|
onNext={pagination.nextPage}
|
||||||
|
onPrevious={pagination.previousPage}
|
||||||
|
hasNext={pagination.hasNextPage}
|
||||||
|
hasPrevious={pagination.hasPreviousPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -125,7 +240,7 @@ export function ArticlesList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticlesContent
|
<ArticlesListContent
|
||||||
articles={articles}
|
articles={articles}
|
||||||
allArticles={allArticles}
|
allArticles={allArticles}
|
||||||
onUnlock={onUnlock}
|
onUnlock={onUnlock}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { AuthorsList } from '@/components/AuthorsList'
|
|||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { SkipLinks } from '@/components/SkipLinks'
|
import { SkipLinks } from '@/components/SkipLinks'
|
||||||
|
import { OnboardingTour } from '@/components/OnboardingTour'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
@ -166,6 +167,7 @@ export function HomeView(props: HomeViewProps): React.ReactElement {
|
|||||||
<main role="main" className="min-h-screen bg-cyber-darker">
|
<main role="main" className="min-h-screen bg-cyber-darker">
|
||||||
<PageHeader />
|
<PageHeader />
|
||||||
<HomeContent {...props} />
|
<HomeContent {...props} />
|
||||||
|
<OnboardingTour />
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
233
components/OnboardingTour.tsx
Normal file
233
components/OnboardingTour.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button, Modal } from './ui'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { isOnboardingCompleted, markOnboardingCompleted } from '@/lib/onboardingPreferences'
|
||||||
|
|
||||||
|
interface OnboardingStep {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
targetSelector?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
title: t('onboarding.steps.welcome.title'),
|
||||||
|
content: t('onboarding.steps.welcome.content'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
title: t('onboarding.steps.search.title'),
|
||||||
|
content: t('onboarding.steps.search.content'),
|
||||||
|
targetSelector: '[role="search"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filters',
|
||||||
|
title: t('onboarding.steps.filters.title'),
|
||||||
|
content: t('onboarding.steps.filters.content'),
|
||||||
|
targetSelector: '#filters-section',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'articles',
|
||||||
|
title: t('onboarding.steps.articles.title'),
|
||||||
|
content: t('onboarding.steps.articles.content'),
|
||||||
|
targetSelector: '#articles-section',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payment',
|
||||||
|
title: t('onboarding.steps.payment.title'),
|
||||||
|
content: t('onboarding.steps.payment.content'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface OnboardingTourProps {
|
||||||
|
onComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingHandlersParams {
|
||||||
|
isLastStep: boolean
|
||||||
|
currentStep: number
|
||||||
|
setCurrentStep: (value: number) => void
|
||||||
|
setIsActive: (value: boolean) => void
|
||||||
|
onComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function useOnboardingState(): {
|
||||||
|
isActive: boolean
|
||||||
|
setIsActive: (value: boolean) => void
|
||||||
|
currentStep: number
|
||||||
|
setCurrentStep: (value: number) => void
|
||||||
|
isLoading: boolean
|
||||||
|
} {
|
||||||
|
const [isActive, setIsActive] = useState(false)
|
||||||
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkOnboarding = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const completed = await isOnboardingCompleted()
|
||||||
|
if (!completed) {
|
||||||
|
setIsActive(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking onboarding status:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void checkOnboarding()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { isActive, setIsActive, currentStep, setCurrentStep, isLoading }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStepScrolling(isActive: boolean, currentStep: number): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && currentStep < ONBOARDING_STEPS.length) {
|
||||||
|
const step = ONBOARDING_STEPS[currentStep]
|
||||||
|
if (step?.targetSelector) {
|
||||||
|
const element = document.querySelector(step.targetSelector)
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isActive, currentStep])
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingTourActions({
|
||||||
|
currentStep,
|
||||||
|
isLastStep,
|
||||||
|
onNext,
|
||||||
|
onSkip,
|
||||||
|
}: {
|
||||||
|
currentStep: number
|
||||||
|
isLastStep: boolean
|
||||||
|
onNext: () => void
|
||||||
|
onSkip: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const stepText = t('onboarding.step', { current: currentStep + 1, total: ONBOARDING_STEPS.length })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-xs text-cyber-accent/70">
|
||||||
|
{stepText}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
{t('onboarding.skip')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
{isLastStep ? t('onboarding.finish') : t('onboarding.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingTourContent({
|
||||||
|
step,
|
||||||
|
currentStep,
|
||||||
|
isLastStep,
|
||||||
|
onNext,
|
||||||
|
onSkip,
|
||||||
|
}: {
|
||||||
|
step: OnboardingStep
|
||||||
|
currentStep: number
|
||||||
|
isLastStep: boolean
|
||||||
|
onNext: () => void
|
||||||
|
onSkip: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-cyber-accent">{step.content}</p>
|
||||||
|
<OnboardingTourActions
|
||||||
|
currentStep={currentStep}
|
||||||
|
isLastStep={isLastStep}
|
||||||
|
onNext={onNext}
|
||||||
|
onSkip={onSkip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useOnboardingHandlers(params: OnboardingHandlersParams): {
|
||||||
|
handleNext: () => void
|
||||||
|
handleSkip: () => void
|
||||||
|
} {
|
||||||
|
const handleComplete = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await markOnboardingCompleted()
|
||||||
|
params.setIsActive(false)
|
||||||
|
params.onComplete?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking onboarding as completed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
if (params.isLastStep) {
|
||||||
|
void handleComplete()
|
||||||
|
} else {
|
||||||
|
params.setCurrentStep(params.currentStep + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkip = (): void => {
|
||||||
|
void handleComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleNext, handleSkip }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingTour({ onComplete }: OnboardingTourProps): React.ReactElement | null {
|
||||||
|
const { isActive, setIsActive, currentStep, setCurrentStep, isLoading } = useOnboardingState()
|
||||||
|
useStepScrolling(isActive, currentStep)
|
||||||
|
|
||||||
|
if (isLoading || !isActive) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = ONBOARDING_STEPS[currentStep]
|
||||||
|
if (!step) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLastStep = currentStep === ONBOARDING_STEPS.length - 1
|
||||||
|
const { handleNext, handleSkip } = useOnboardingHandlers({
|
||||||
|
isLastStep,
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
setIsActive,
|
||||||
|
onComplete,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isActive}
|
||||||
|
onClose={handleSkip}
|
||||||
|
title={step.title}
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
<OnboardingTourContent
|
||||||
|
step={step}
|
||||||
|
currentStep={currentStep}
|
||||||
|
isLastStep={isLastStep}
|
||||||
|
onNext={handleNext}
|
||||||
|
onSkip={handleSkip}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
hooks/useInfiniteScroll.ts
Normal file
57
hooks/useInfiniteScroll.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface UseInfiniteScrollOptions {
|
||||||
|
hasMore: boolean
|
||||||
|
loading: boolean
|
||||||
|
onLoadMore: () => void
|
||||||
|
threshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteScroll({
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
onLoadMore,
|
||||||
|
threshold = 200,
|
||||||
|
}: UseInfiniteScrollOptions): React.RefObject<HTMLDivElement | null> {
|
||||||
|
const observerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isIntersecting, setIsIntersecting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMore || loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
setIsIntersecting(true)
|
||||||
|
} else {
|
||||||
|
setIsIntersecting(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: `${threshold}px`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentRef = observerRef.current
|
||||||
|
if (currentRef) {
|
||||||
|
observer.observe(currentRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (currentRef) {
|
||||||
|
observer.unobserve(currentRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasMore, loading, threshold])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isIntersecting && hasMore && !loading) {
|
||||||
|
onLoadMore()
|
||||||
|
}
|
||||||
|
}, [isIntersecting, hasMore, loading, onLoadMore])
|
||||||
|
|
||||||
|
return observerRef
|
||||||
|
}
|
||||||
60
hooks/usePagination.ts
Normal file
60
hooks/usePagination.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface UsePaginationOptions<T> {
|
||||||
|
items: T[]
|
||||||
|
itemsPerPage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePaginationResult<T> {
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
paginatedItems: T[]
|
||||||
|
goToPage: (page: number) => void
|
||||||
|
nextPage: () => void
|
||||||
|
previousPage: () => void
|
||||||
|
hasNextPage: boolean
|
||||||
|
hasPreviousPage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagination<T>({ items, itemsPerPage }: UsePaginationOptions<T>): UsePaginationResult<T> {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(items.length / itemsPerPage))
|
||||||
|
|
||||||
|
const paginatedItems = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage
|
||||||
|
const endIndex = startIndex + itemsPerPage
|
||||||
|
return items.slice(startIndex, endIndex)
|
||||||
|
}, [items, currentPage, itemsPerPage])
|
||||||
|
|
||||||
|
const goToPage = (page: number): void => {
|
||||||
|
const validPage = Math.max(1, Math.min(page, totalPages))
|
||||||
|
setCurrentPage(validPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPage = (): void => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
setCurrentPage(currentPage + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPage = (): void => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage(currentPage - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNextPage = currentPage < totalPages
|
||||||
|
const hasPreviousPage = currentPage > 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
paginatedItems,
|
||||||
|
goToPage,
|
||||||
|
nextPage,
|
||||||
|
previousPage,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
73
lib/onboardingPreferences.ts
Normal file
73
lib/onboardingPreferences.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper'
|
||||||
|
|
||||||
|
const DB_NAME = 'nostr_paywall_settings'
|
||||||
|
const DB_VERSION = 3
|
||||||
|
const STORE_NAME = 'onboarding_preferences'
|
||||||
|
|
||||||
|
export interface OnboardingPreferencesItem {
|
||||||
|
key: 'completed'
|
||||||
|
value: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPLETED_KEY = 'completed'
|
||||||
|
|
||||||
|
class OnboardingPreferencesService {
|
||||||
|
private readonly dbHelper: IndexedDBHelper
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dbHelper = createIndexedDBHelper({
|
||||||
|
dbName: DB_NAME,
|
||||||
|
version: DB_VERSION,
|
||||||
|
storeName: STORE_NAME,
|
||||||
|
keyPath: 'key',
|
||||||
|
indexes: [{ name: 'timestamp', keyPath: 'timestamp', unique: false }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCompleted(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.dbHelper.get<OnboardingPreferencesItem>(COMPLETED_KEY)
|
||||||
|
return result?.value ?? false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading onboarding preferences:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markCompleted(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dbHelper.put({
|
||||||
|
key: COMPLETED_KEY,
|
||||||
|
value: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving onboarding preferences:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dbHelper.delete(COMPLETED_KEY)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing onboarding preferences:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onboardingPreferencesService = new OnboardingPreferencesService()
|
||||||
|
|
||||||
|
export async function isOnboardingCompleted(): Promise<boolean> {
|
||||||
|
return onboardingPreferencesService.isCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOnboardingCompleted(): Promise<void> {
|
||||||
|
return onboardingPreferencesService.markCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearOnboardingPreferences(): Promise<void> {
|
||||||
|
return onboardingPreferencesService.clear()
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user