561 lines
23 KiB
TypeScript
561 lines
23 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import PageLayout from '../components/PageLayout'
|
|
import TutorialModal, { createTutorialSteps } from '../components/TutorialModal'
|
|
import useAuthStore from '../store/authStore'
|
|
import { useUserStatus } from '../hooks/useUserStatus'
|
|
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
|
import {
|
|
CheckCircleIcon,
|
|
XCircleIcon,
|
|
EnvelopeOpenIcon,
|
|
IdentificationIcon,
|
|
InformationCircleIcon,
|
|
DocumentCheckIcon,
|
|
ArrowUpOnSquareIcon,
|
|
PencilSquareIcon,
|
|
ClipboardDocumentCheckIcon,
|
|
AcademicCapIcon
|
|
} from '@heroicons/react/24/outline'
|
|
|
|
interface StatusItem {
|
|
key: string
|
|
label: string
|
|
description: string
|
|
complete: boolean
|
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
|
}
|
|
|
|
type LatestNewsItem = {
|
|
id: number
|
|
title: string
|
|
summary?: string
|
|
slug: string
|
|
category?: string
|
|
imageUrl?: string
|
|
published_at?: string | null
|
|
}
|
|
|
|
export default function QuickActionDashboardPage() {
|
|
const router = useRouter()
|
|
const user = useAuthStore(s => s.user)
|
|
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
|
const accessToken = useAuthStore(s => s.accessToken) // NEW
|
|
const { userStatus, loading, error, refreshStatus } = useUserStatus()
|
|
const [isClient, setIsClient] = useState(false)
|
|
const [latestNews, setLatestNews] = useState<LatestNewsItem[]>([])
|
|
const [newsLoading, setNewsLoading] = useState(false)
|
|
const [newsError, setNewsError] = useState<string | null>(null)
|
|
|
|
// Tutorial state
|
|
const [isTutorialOpen, setIsTutorialOpen] = useState(false)
|
|
const [currentTutorialStep, setCurrentTutorialStep] = useState(1)
|
|
const [hasSeenTutorial, setHasSeenTutorial] = useState(false)
|
|
const forceOpenRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
setIsClient(true)
|
|
// Check if user has seen tutorial before
|
|
const tutorialSeen = localStorage.getItem('tutorial_seen')
|
|
setHasSeenTutorial(!!tutorialSeen)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
;(async () => {
|
|
setNewsLoading(true)
|
|
setNewsError(null)
|
|
try {
|
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
|
const res = await fetch(`${BASE_URL}/api/news/active`)
|
|
if (!res.ok) throw new Error('Failed to fetch news')
|
|
const json = await res.json()
|
|
const data = Array.isArray(json.data) ? json.data : []
|
|
if (active) setLatestNews(data.slice(0, 3))
|
|
} catch (e: any) {
|
|
if (active) setNewsError(e?.message || 'Failed to load news')
|
|
} finally {
|
|
if (active) setNewsLoading(false)
|
|
}
|
|
})()
|
|
return () => { active = false }
|
|
}, [])
|
|
|
|
// Derive status from real backend data
|
|
const emailVerified = userStatus?.email_verified || false
|
|
const idUploaded = userStatus?.documents_uploaded || false
|
|
const additionalInfo = userStatus?.profile_completed || false
|
|
const contractSigned = userStatus?.contract_signed || false
|
|
|
|
// NEW: if everything is done, quickaction-dashboard is no longer accessible
|
|
const allDone = emailVerified && idUploaded && additionalInfo && contractSigned
|
|
// NEW: smooth redirect (prevents snappy double navigation)
|
|
const [redirectTo, setRedirectTo] = useState<string | null>(null)
|
|
const redirectOnceRef = useRef(false)
|
|
const smoothReplace = useCallback((to: string) => {
|
|
if (redirectOnceRef.current) return
|
|
redirectOnceRef.current = true
|
|
setRedirectTo(to)
|
|
window.setTimeout(() => router.replace(to), 200)
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
if (!isClient) return
|
|
if (loading || !userStatus) return
|
|
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const forceOpen = urlParams.get('tutorial') === 'true'
|
|
|
|
if (allDone && !forceOpen) smoothReplace('/dashboard')
|
|
}, [isClient, loading, userStatus, allDone, smoothReplace])
|
|
|
|
// NEW: decide which tutorial step to start on
|
|
const getNextTutorialStep = useCallback(() => {
|
|
const noneCompleted = !emailVerified && !idUploaded && !additionalInfo && !contractSigned
|
|
if (noneCompleted) return 1
|
|
if (!emailVerified) return 2
|
|
if (!idUploaded) return 3
|
|
if (!additionalInfo) return 4
|
|
if (!contractSigned) return 5
|
|
return 6
|
|
}, [emailVerified, idUploaded, additionalInfo, contractSigned])
|
|
|
|
// CHANGED: single auto-open mechanism (works even if tutorial_seen exists)
|
|
useEffect(() => {
|
|
if (!isClient) return
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const forceOpen = urlParams.get('tutorial') === 'true'
|
|
|
|
if (forceOpen && !forceOpenRef.current && !isTutorialOpen) {
|
|
// Open immediately to avoid dashboard flash; step will be corrected once status loads
|
|
setCurrentTutorialStep(1)
|
|
setIsTutorialOpen(true)
|
|
forceOpenRef.current = true
|
|
return
|
|
}
|
|
|
|
if (loading || !userStatus) return
|
|
|
|
if (isTutorialOpen && forceOpenRef.current) {
|
|
setCurrentTutorialStep(getNextTutorialStep())
|
|
}
|
|
|
|
if (isTutorialOpen) return
|
|
|
|
const uid =
|
|
(user as any)?.id ??
|
|
(user as any)?._id ??
|
|
(user as any)?.userId ??
|
|
(user as any)?.email ??
|
|
null
|
|
if (!uid) return
|
|
|
|
const tokenSuffix = (accessToken || '').slice(-12) || 'no-token'
|
|
const sessionKey = `pp:tutorialShown:${uid}:${tokenSuffix}`
|
|
if (forceOpen) {
|
|
window.history.replaceState({}, '', window.location.pathname)
|
|
}
|
|
|
|
const alreadyShownThisLogin = sessionStorage.getItem(sessionKey) === '1'
|
|
if (alreadyShownThisLogin && !forceOpen) return
|
|
|
|
setCurrentTutorialStep(getNextTutorialStep())
|
|
setIsTutorialOpen(true)
|
|
sessionStorage.setItem(sessionKey, '1')
|
|
}, [isClient, loading, userStatus, isTutorialOpen, user, accessToken, getNextTutorialStep])
|
|
|
|
const statusItems: StatusItem[] = [
|
|
{
|
|
key: 'email',
|
|
label: 'Email Verification',
|
|
description: emailVerified ? 'Verified' : 'Missing',
|
|
complete: emailVerified,
|
|
icon: EnvelopeOpenIcon
|
|
},
|
|
{
|
|
key: 'id',
|
|
label: 'ID Document',
|
|
description: idUploaded ? 'Uploaded' : 'Missing',
|
|
complete: idUploaded,
|
|
icon: IdentificationIcon
|
|
},
|
|
{
|
|
key: 'info',
|
|
label: 'Additional Info',
|
|
description: additionalInfo ? 'Completed' : 'Missing',
|
|
complete: additionalInfo,
|
|
icon: InformationCircleIcon
|
|
},
|
|
{
|
|
key: 'contract',
|
|
label: 'Contract',
|
|
description: contractSigned ? 'Signed' : 'Missing',
|
|
complete: contractSigned,
|
|
icon: DocumentCheckIcon
|
|
}
|
|
]
|
|
|
|
// Action handlers - navigate to proper QuickAction pages with tutorial callback
|
|
const handleVerifyEmail = useCallback(() => {
|
|
if (emailVerified) return
|
|
router.push('/quickaction-dashboard/register-email-verify?tutorial=true')
|
|
}, [router, emailVerified])
|
|
|
|
const handleUploadId = useCallback(() => {
|
|
if (idUploaded) return
|
|
const userType = user?.userType || 'personal'
|
|
router.push(`/quickaction-dashboard/register-upload-id/${userType}?tutorial=true`)
|
|
}, [router, user, idUploaded])
|
|
|
|
const handleCompleteInfo = useCallback(() => {
|
|
if (additionalInfo) return
|
|
const userType = user?.userType || 'personal'
|
|
router.push(`/quickaction-dashboard/register-additional-information/${userType}?tutorial=true`)
|
|
}, [router, user, additionalInfo])
|
|
|
|
const handleSignContract = useCallback(() => {
|
|
if (contractSigned) return
|
|
const userType = user?.userType || 'personal'
|
|
router.push(`/quickaction-dashboard/register-sign-contract/${userType}?tutorial=true`)
|
|
}, [router, user, contractSigned])
|
|
|
|
// Tutorial handlers
|
|
const startTutorial = useCallback(() => {
|
|
setCurrentTutorialStep(1)
|
|
setIsTutorialOpen(true)
|
|
}, [])
|
|
|
|
const closeTutorial = useCallback(() => {
|
|
setIsTutorialOpen(false)
|
|
localStorage.setItem('tutorial_seen', 'true')
|
|
setHasSeenTutorial(true)
|
|
if (allDone) smoothReplace('/dashboard')
|
|
}, [allDone, smoothReplace])
|
|
|
|
const nextTutorialStep = useCallback(() => {
|
|
setCurrentTutorialStep(prev => prev + 1)
|
|
}, [])
|
|
|
|
const previousTutorialStep = useCallback(() => {
|
|
setCurrentTutorialStep(prev => Math.max(1, prev - 1))
|
|
}, [])
|
|
|
|
// Create tutorial steps
|
|
const tutorialSteps = createTutorialSteps(
|
|
emailVerified,
|
|
idUploaded,
|
|
additionalInfo,
|
|
contractSigned,
|
|
user?.userType || 'personal',
|
|
handleVerifyEmail,
|
|
handleUploadId,
|
|
handleCompleteInfo,
|
|
handleSignContract,
|
|
closeTutorial,
|
|
() => setCurrentTutorialStep(prev => prev + 1) // onNext function
|
|
)
|
|
|
|
const canSignContract = emailVerified && idUploaded && additionalInfo
|
|
|
|
// NEW: resend cooldown tracking (10 minutes like verify page)
|
|
const RESEND_INTERVAL_MS = 10 * 60 * 1000
|
|
const getStorageKey = (email?: string | null) => `emailVerify:lastSent:${email || 'anon'}`
|
|
const getLastSentAt = (email?: string | null) => {
|
|
if (typeof window === 'undefined') return 0
|
|
try { return parseInt(localStorage.getItem(getStorageKey(email)) || '0', 10) || 0 } catch { return 0 }
|
|
}
|
|
const [resendRemainingSec, setResendRemainingSec] = useState(0)
|
|
const formatMmSs = (total: number) => {
|
|
const m = Math.floor(total / 60)
|
|
const s = total % 60
|
|
return `${m}:${String(s).padStart(2, '0')}`
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isClient || emailVerified) return
|
|
const last = getLastSentAt(user?.email)
|
|
const remainingMs = Math.max(0, RESEND_INTERVAL_MS - (Date.now() - last))
|
|
setResendRemainingSec(Math.ceil(remainingMs / 1000))
|
|
if (remainingMs <= 0) return
|
|
const id = setInterval(() => {
|
|
setResendRemainingSec(s => (s > 0 ? s - 1 : 0))
|
|
}, 1000)
|
|
return () => clearInterval(id)
|
|
}, [isClient, emailVerified, user?.email])
|
|
|
|
// NEW: only logged-in users can access quickaction dashboard
|
|
useEffect(() => {
|
|
if (!isClient) return
|
|
if (!isAuthReady) return
|
|
if (!user) smoothReplace('/login')
|
|
}, [isClient, isAuthReady, user, smoothReplace])
|
|
|
|
return (
|
|
<PageLayout>
|
|
{/* NEW: smooth redirect overlay */}
|
|
{redirectTo && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
|
|
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
|
|
<div className="text-sm font-medium text-gray-900">Redirecting…</div>
|
|
<div className="mt-1 text-xs text-gray-600">Taking you to your dashboard</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<BlueBlurryBackground /* optionally: animate={!isMobile} */>
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Title */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900">
|
|
Welcome{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
|
|
</h1>
|
|
<p className="text-sm sm:text-base text-gray-600 mt-2">
|
|
{isClient && user?.userType === 'company' ? 'Company Account' : 'Personal Account'}
|
|
</p>
|
|
{loading && <p className="text-xs text-gray-500 mt-1">Loading status...</p>}
|
|
{error && (
|
|
<div className="mt-4 max-w-md rounded-md bg-red-50 border border-red-200 px-4 py-3">
|
|
<div className="flex">
|
|
<div className="flex-shrink-0">
|
|
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium text-red-800">
|
|
Error loading account status
|
|
</h3>
|
|
<div className="mt-2 text-sm text-red-700">
|
|
<p>{error}</p>
|
|
</div>
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={() => refreshStatus()}
|
|
className="text-sm bg-red-100 text-red-800 px-3 py-1 rounded-md hover:bg-red-200 transition-colors"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cards */}
|
|
<div className="space-y-8">
|
|
{/* Status Overview */}
|
|
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
|
|
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-5">
|
|
Status Overview
|
|
</h2>
|
|
|
|
{/* CHANGED: mobile 2x2 grid */}
|
|
<div className="grid grid-cols-2 gap-3 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
|
|
{statusItems.map(item => {
|
|
const CompleteIcon = item.complete ? CheckCircleIcon : XCircleIcon
|
|
return (
|
|
<div
|
|
key={item.key}
|
|
className={`rounded-lg px-3 py-4 sm:px-4 sm:py-6 flex flex-col items-center text-center border transition-colors ${
|
|
item.complete ? 'bg-emerald-50 border-emerald-100' : 'bg-rose-50 border-rose-100'
|
|
}`}
|
|
>
|
|
<CompleteIcon
|
|
className={`h-5 w-5 sm:h-6 sm:w-6 mb-3 sm:mb-4 ${
|
|
item.complete ? 'text-emerald-600' : 'text-rose-600'
|
|
}`}
|
|
/>
|
|
<span
|
|
className={`text-[11px] sm:text-xs font-medium uppercase tracking-wide ${
|
|
item.complete ? 'text-emerald-700' : 'text-rose-700'
|
|
}`}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
<span
|
|
className={`mt-1 text-[11px] sm:text-xs font-semibold ${
|
|
item.complete ? 'text-emerald-600' : 'text-rose-600'
|
|
}`}
|
|
>
|
|
{item.description}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Actions */}
|
|
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
|
|
<div className="flex items-center justify-between mb-5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-blue-100 text-blue-600 text-sm font-semibold">
|
|
i
|
|
</span>
|
|
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
|
Quick Actions
|
|
</h2>
|
|
</div>
|
|
<button
|
|
onClick={startTutorial}
|
|
className="relative inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
|
|
>
|
|
<AcademicCapIcon className="h-4 w-4" />
|
|
Tutorial
|
|
{!hasSeenTutorial && (
|
|
<span className="absolute -top-1 -right-1 h-3 w-3 bg-red-500 rounded-full animate-pulse" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* CHANGED: mobile 2x2 grid (order already matches desired layout) */}
|
|
<div className="grid grid-cols-2 gap-3 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
|
|
{/* Email Verification */}
|
|
<div className="flex flex-col">
|
|
<button
|
|
onClick={handleVerifyEmail}
|
|
disabled={emailVerified}
|
|
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-8 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
|
|
emailVerified
|
|
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
|
|
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
|
|
}`}
|
|
>
|
|
<EnvelopeOpenIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
|
{emailVerified ? 'Email Verified' : 'Verify Email'}
|
|
</button>
|
|
{/* NEW: resend feedback (only when not verified) */}
|
|
{!emailVerified && (
|
|
<p className="mt-2 text-[11px] text-[#112c55] text-center">
|
|
{resendRemainingSec > 0
|
|
? `Resend available in ${formatMmSs(resendRemainingSec)}`
|
|
: 'You can request a new code now'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ID Upload */}
|
|
<button
|
|
onClick={handleUploadId}
|
|
disabled={idUploaded}
|
|
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
|
|
idUploaded
|
|
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
|
|
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
|
|
}`}
|
|
>
|
|
<ArrowUpOnSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
|
{idUploaded ? 'ID Uploaded' : 'Upload ID Document'}
|
|
</button>
|
|
|
|
{/* Additional Info */}
|
|
<button
|
|
onClick={handleCompleteInfo}
|
|
disabled={additionalInfo}
|
|
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
|
|
additionalInfo
|
|
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
|
|
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
|
|
}`}
|
|
>
|
|
<PencilSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
|
{additionalInfo ? 'Profile Completed' : 'Complete Profile'}
|
|
</button>
|
|
|
|
{/* Sign Contract */}
|
|
<div className="flex flex-col">
|
|
<button
|
|
onClick={handleSignContract}
|
|
disabled={!canSignContract || contractSigned}
|
|
className={`flex flex-col flex-1 items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
|
|
contractSigned
|
|
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
|
|
: canSignContract
|
|
? 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
|
|
: 'bg-gray-300 text-gray-600 border-gray-300 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<ClipboardDocumentCheckIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
|
{contractSigned ? 'Contract Signed' : 'Sign Contract'}
|
|
</button>
|
|
{!canSignContract && !contractSigned && (
|
|
<p className="mt-2 text-[11px] text-red-600 leading-snug text-center">
|
|
Complete previous steps (email, ID, profile) before signing the contract.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Latest News */}
|
|
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
|
Latest News
|
|
</h2>
|
|
<Link href="/news" className="text-xs sm:text-sm font-medium text-blue-900 hover:text-blue-700">
|
|
View all
|
|
</Link>
|
|
</div>
|
|
|
|
{newsLoading && (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="animate-pulse space-y-2">
|
|
<div className="h-3 w-2/3 bg-gray-200 rounded" />
|
|
<div className="h-3 w-1/2 bg-gray-100 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{newsError && !newsLoading && (
|
|
<div className="text-sm text-red-600">{newsError}</div>
|
|
)}
|
|
|
|
{!newsLoading && !newsError && latestNews.length === 0 && (
|
|
<div className="text-sm text-gray-600">No news yet.</div>
|
|
)}
|
|
|
|
{!newsLoading && !newsError && latestNews.length > 0 && (
|
|
<ul className="space-y-4">
|
|
{latestNews.map(item => (
|
|
<li key={item.id} className="group">
|
|
<Link href={`/news/${item.slug}`} className="block">
|
|
<div className="text-xs text-gray-500">
|
|
{item.published_at ? new Date(item.published_at).toLocaleDateString('de-DE') : 'Recent'}
|
|
</div>
|
|
<div className="text-sm font-semibold text-blue-900 group-hover:text-blue-700 line-clamp-2">
|
|
{item.title}
|
|
</div>
|
|
{item.summary && (
|
|
<div className="text-xs text-gray-600 line-clamp-2 mt-1">
|
|
{item.summary}
|
|
</div>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</BlueBlurryBackground>
|
|
|
|
{/* Tutorial Modal */}
|
|
<TutorialModal
|
|
isOpen={isTutorialOpen}
|
|
onClose={closeTutorial}
|
|
currentStep={currentTutorialStep}
|
|
steps={tutorialSteps}
|
|
onNext={nextTutorialStep}
|
|
onPrevious={previousTutorialStep}
|
|
/>
|
|
</PageLayout>
|
|
)
|
|
} |