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 type { ArticleDraft } from '@/lib/articlePublisher'
|
||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||
@ -6,6 +6,7 @@ import type { RelayPublishStatus } from '@/lib/publishResult'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft'
|
||||
import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight'
|
||||
import { ArticlePreviewModal } from './ArticlePreviewModal'
|
||||
|
||||
interface ArticleEditorFormProps {
|
||||
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" />
|
||||
}
|
||||
|
||||
function usePreviewModal(): {
|
||||
showPreview: boolean
|
||||
openPreview: () => void
|
||||
closePreview: () => void
|
||||
} {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
return {
|
||||
showPreview,
|
||||
openPreview: () => {
|
||||
setShowPreview(true)
|
||||
},
|
||||
closePreview: () => {
|
||||
setShowPreview(false)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function ArticleEditorForm({
|
||||
draft,
|
||||
onDraftChange,
|
||||
@ -37,22 +55,32 @@ export function ArticleEditorForm({
|
||||
seriesOptions,
|
||||
onSelectSeries,
|
||||
}: ArticleEditorFormProps): React.ReactElement {
|
||||
const { showPreview, openPreview, closePreview } = usePreviewModal()
|
||||
|
||||
return (
|
||||
<Card variant="default" className="bg-white">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>
|
||||
<div className="space-y-4">
|
||||
<ArticleFieldsLeft
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
{...(seriesOptions ? { seriesOptions } : {})}
|
||||
{...(onSelectSeries ? { onSelectSeries } : {})}
|
||||
<>
|
||||
<Card variant="default" className="bg-white">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>
|
||||
<div className="space-y-4">
|
||||
<ArticleFieldsLeft
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
{...(seriesOptions ? { seriesOptions } : {})}
|
||||
{...(onSelectSeries ? { onSelectSeries } : {})}
|
||||
/>
|
||||
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
||||
</div>
|
||||
<ErrorAlert error={error} />
|
||||
<ArticleFormButtons
|
||||
loading={loading}
|
||||
relayStatuses={relayStatuses}
|
||||
onPreview={openPreview}
|
||||
{...(onCancel ? { onCancel } : {})}
|
||||
/>
|
||||
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
||||
</div>
|
||||
<ErrorAlert error={error} />
|
||||
<ArticleFormButtons loading={loading} relayStatuses={relayStatuses} {...(onCancel ? { onCancel } : {})} />
|
||||
</form>
|
||||
</Card>
|
||||
</form>
|
||||
</Card>
|
||||
<ArticlePreviewModal isOpen={showPreview} onClose={closePreview} draft={draft} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,11 +3,12 @@ import { t } from '@/lib/i18n'
|
||||
|
||||
interface ArticleFormButtonsProps {
|
||||
loading: boolean
|
||||
relayStatuses?: unknown // Kept for backward compatibility but not displayed
|
||||
relayStatuses?: unknown
|
||||
onCancel?: () => void
|
||||
onPreview?: () => void
|
||||
}
|
||||
|
||||
export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProps): React.ReactElement {
|
||||
export function ArticleFormButtons({ loading, onCancel, onPreview }: ArticleFormButtonsProps): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="flex gap-3">
|
||||
@ -20,6 +21,16 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
|
||||
>
|
||||
{loading ? t('publish.publishing') : t('publish.button')}
|
||||
</Button>
|
||||
{onPreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onPreview}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('article.preview.button')}
|
||||
</Button>
|
||||
)}
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -120,6 +120,7 @@ function PageDisplay({ page }: { page: Page }): React.ReactElement {
|
||||
src={page.content}
|
||||
alt={t('page.image.alt', { number: page.number })}
|
||||
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">
|
||||
|
||||
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 type { Article } from '@/types/nostr'
|
||||
import { ArticleCard } from './ArticleCard'
|
||||
import { ErrorState, EmptyState, Skeleton } from './ui'
|
||||
import { ErrorState, EmptyState, Skeleton, Button } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
|
||||
import { usePagination } from '@/hooks/usePagination'
|
||||
|
||||
interface ArticlesListProps {
|
||||
articles: Article[]
|
||||
@ -14,6 +15,8 @@ interface ArticlesListProps {
|
||||
unlockedArticles: Set<string>
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 10
|
||||
|
||||
function ArticleCardSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<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,
|
||||
allArticles,
|
||||
onUnlock,
|
||||
@ -82,23 +184,36 @@ function ArticlesContent({
|
||||
unlockedArticles: Set<string>
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}): 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 (
|
||||
<section id="articles-section" aria-label={t('navigation.articlesSection')} tabIndex={-1}>
|
||||
<div className="mb-4 text-sm text-cyber-accent/70">
|
||||
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
|
||||
{showingText}
|
||||
</div>
|
||||
<div ref={containerRef} 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 ref={containerRef}>
|
||||
<ArticlesListItems
|
||||
articles={pagination.paginatedItems}
|
||||
allArticles={allArticles}
|
||||
onUnlock={onUnlock}
|
||||
unlockedArticles={unlockedArticles}
|
||||
/>
|
||||
</div>
|
||||
{pagination.totalPages > 1 && (
|
||||
<PaginationControls
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onNext={pagination.nextPage}
|
||||
onPrevious={pagination.previousPage}
|
||||
hasNext={pagination.hasNextPage}
|
||||
hasPrevious={pagination.hasPreviousPage}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -125,7 +240,7 @@ export function ArticlesList({
|
||||
}
|
||||
|
||||
return (
|
||||
<ArticlesContent
|
||||
<ArticlesListContent
|
||||
articles={articles}
|
||||
allArticles={allArticles}
|
||||
onUnlock={onUnlock}
|
||||
|
||||
@ -8,6 +8,7 @@ import { AuthorsList } from '@/components/AuthorsList'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { SkipLinks } from '@/components/SkipLinks'
|
||||
import { OnboardingTour } from '@/components/OnboardingTour'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
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">
|
||||
<PageHeader />
|
||||
<HomeContent {...props} />
|
||||
<OnboardingTour />
|
||||
<Footer />
|
||||
</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