Merge pull request 'feature/news-pool-modDel-QoL' (#9) from feature/news-pool-modDel-QoL into dev
Reviewed-on: #9
This commit is contained in:
commit
dc6238721a
@ -21,6 +21,7 @@ export default function ContractManagementPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [section, setSection] = useState('templates');
|
const [section, setSection] = useState('templates');
|
||||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||||
|
const [editorKey, setEditorKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => { setMounted(true); }, []);
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
@ -56,7 +57,17 @@ export default function ContractManagementPage() {
|
|||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => setSection(item.key)}
|
onClick={() => {
|
||||||
|
if (section === 'editor' && item.key !== 'editor') {
|
||||||
|
setEditingTemplateId(null);
|
||||||
|
setEditorKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
if (item.key === 'editor') {
|
||||||
|
setEditingTemplateId(null);
|
||||||
|
setEditorKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
setSection(item.key);
|
||||||
|
}}
|
||||||
className={`flex flex-shrink-0 items-center gap-2 px-4 py-2 rounded-lg font-medium transition whitespace-nowrap text-sm md:text-base
|
className={`flex flex-shrink-0 items-center gap-2 px-4 py-2 rounded-lg font-medium transition whitespace-nowrap text-sm md:text-base
|
||||||
${section === item.key
|
${section === item.key
|
||||||
? 'bg-blue-900 text-blue-50 shadow'
|
? 'bg-blue-900 text-blue-50 shadow'
|
||||||
@ -99,6 +110,7 @@ export default function ContractManagementPage() {
|
|||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
onEdit={(id) => {
|
onEdit={(id) => {
|
||||||
setEditingTemplateId(id);
|
setEditingTemplateId(id);
|
||||||
|
setEditorKey((k) => k + 1);
|
||||||
setSection('editor');
|
setSection('editor');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -111,15 +123,18 @@ export default function ContractManagementPage() {
|
|||||||
Create Template
|
Create Template
|
||||||
</h2>
|
</h2>
|
||||||
<ContractEditor
|
<ContractEditor
|
||||||
|
key={`${editorKey}-${editingTemplateId ?? 'new'}`}
|
||||||
editingTemplateId={editingTemplateId}
|
editingTemplateId={editingTemplateId}
|
||||||
onCancelEdit={() => {
|
onCancelEdit={() => {
|
||||||
setEditingTemplateId(null);
|
setEditingTemplateId(null);
|
||||||
|
setEditorKey((k) => k + 1);
|
||||||
setSection('templates');
|
setSection('templates');
|
||||||
}}
|
}}
|
||||||
onSaved={(info) => {
|
onSaved={(info) => {
|
||||||
bumpRefresh();
|
bumpRefresh();
|
||||||
if (info?.action === 'revised') {
|
if (info?.action === 'revised') {
|
||||||
setEditingTemplateId(null);
|
setEditingTemplateId(null);
|
||||||
|
setEditorKey((k) => k + 1);
|
||||||
setSection('templates');
|
setSection('templates');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export default function NewsManagementPage() {
|
|||||||
const [showCreate, setShowCreate] = React.useState(false)
|
const [showCreate, setShowCreate] = React.useState(false)
|
||||||
const [selected, setSelected] = React.useState<any | null>(null)
|
const [selected, setSelected] = React.useState<any | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = React.useState<any | null>(null)
|
const [deleteTarget, setDeleteTarget] = React.useState<any | null>(null)
|
||||||
|
const [createError, setCreateError] = React.useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = React.useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
@ -71,7 +73,24 @@ export default function NewsManagementPage() {
|
|||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreateNewsModal onClose={() => setShowCreate(false)} onCreate={async (payload) => { await addNews(payload); setShowCreate(false); await refresh() }} />
|
<CreateNewsModal
|
||||||
|
onClose={() => { setShowCreate(false); setCreateError(null) }}
|
||||||
|
creating={creating}
|
||||||
|
error={createError}
|
||||||
|
onCreate={async (payload) => {
|
||||||
|
setCreateError(null)
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await addNews(payload)
|
||||||
|
setShowCreate(false)
|
||||||
|
await refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
setCreateError(e?.message || 'Failed to create news')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{selected && (
|
{selected && (
|
||||||
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
||||||
@ -107,11 +126,22 @@ export default function NewsManagementPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate: (payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) => void }) {
|
function CreateNewsModal({
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
creating,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
onCreate: (payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) => void
|
||||||
|
creating: boolean
|
||||||
|
error: string | null
|
||||||
|
}) {
|
||||||
const [title, setTitle] = React.useState('')
|
const [title, setTitle] = React.useState('')
|
||||||
const [summary, setSummary] = React.useState('')
|
const [summary, setSummary] = React.useState('')
|
||||||
const [content, setContent] = React.useState('')
|
const [content, setContent] = React.useState('')
|
||||||
const [slug, setSlug] = React.useState('')
|
const [slug, setSlug] = React.useState('')
|
||||||
|
const [slugTouched, setSlugTouched] = React.useState(false)
|
||||||
const [category, setCategory] = React.useState('')
|
const [category, setCategory] = React.useState('')
|
||||||
const [isActive, setIsActive] = React.useState(true)
|
const [isActive, setIsActive] = React.useState(true)
|
||||||
const [publishedAt, setPublishedAt] = React.useState<string>('')
|
const [publishedAt, setPublishedAt] = React.useState<string>('')
|
||||||
@ -143,9 +173,24 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
|||||||
setRawUrl(null)
|
setRawUrl(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slugify = (value: string) => value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slugTouched) {
|
||||||
|
setSlug(slugify(title))
|
||||||
|
}
|
||||||
|
}, [title, slugTouched])
|
||||||
|
|
||||||
const submit = (e: React.FormEvent) => {
|
const submit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onCreate({ title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
|
const safeSlug = slug.trim() || slugify(title)
|
||||||
|
onCreate({ title: title.trim(), summary: summary || undefined, content: content || undefined, slug: safeSlug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -158,8 +203,22 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
|||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="p-6 space-y-4">
|
<form onSubmit={submit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded px-4 py-2 text-gray-900"
|
||||||
|
placeholder="Slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={e=>{ setSlugTouched(true); setSlug(e.target.value) }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Used in the URL. Auto-generated from title unless edited.</p>
|
||||||
|
</div>
|
||||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||||
@ -188,7 +247,13 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating || !title.trim()}
|
||||||
|
className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{creating ? 'Creating…' : 'Add News'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -73,6 +73,8 @@ function PoolManagePageInner() {
|
|||||||
const [hasSearched, setHasSearched] = React.useState(false)
|
const [hasSearched, setHasSearched] = React.useState(false)
|
||||||
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
||||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
const [savingMembers, setSavingMembers] = React.useState(false)
|
||||||
|
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
||||||
|
const [removeError, setRemoveError] = React.useState<string>('')
|
||||||
|
|
||||||
async function fetchMembers() {
|
async function fetchMembers() {
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
@ -211,6 +213,23 @@ function PoolManagePageInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeMember(userId: string) {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
const user = users.find(u => u.id === userId)
|
||||||
|
const label = user?.name || user?.email || 'this user'
|
||||||
|
if (!window.confirm(`Remove ${label} from this pool?`)) return
|
||||||
|
setRemoveError('')
|
||||||
|
setRemovingMemberId(userId)
|
||||||
|
try {
|
||||||
|
await AdminAPI.removePoolMembers(token, poolId, [userId])
|
||||||
|
await fetchMembers()
|
||||||
|
} catch (e: any) {
|
||||||
|
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
||||||
|
} finally {
|
||||||
|
setRemovingMemberId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
||||||
@ -302,6 +321,11 @@ function PoolManagePageInner() {
|
|||||||
Add User
|
Add User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{removeError && (
|
||||||
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{removeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
|
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
|
||||||
@ -325,6 +349,15 @@ function PoolManagePageInner() {
|
|||||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => removeMember(u.id)}
|
||||||
|
disabled={removingMemberId === u.id}
|
||||||
|
className="px-3 py-2 text-xs font-medium rounded-lg border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{membersLoading && (
|
{membersLoading && (
|
||||||
@ -392,7 +425,7 @@ function PoolManagePageInner() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
onClick={() => { setQuery(''); setError(''); }}
|
||||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@ -81,6 +81,8 @@ export default function PoolManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleArchive(poolId: string) {
|
async function handleArchive(poolId: string) {
|
||||||
|
const confirmed = window.confirm('Archive this pool? Users will no longer be able to join or use it.')
|
||||||
|
if (!confirmed) return
|
||||||
setArchiveError('')
|
setArchiveError('')
|
||||||
const res = await setPoolInactive(poolId)
|
const res = await setPoolInactive(poolId)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -91,6 +93,8 @@ export default function PoolManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetActive(poolId: string) {
|
async function handleSetActive(poolId: string) {
|
||||||
|
const confirmed = window.confirm('Unarchive this pool and make it active again?')
|
||||||
|
if (!confirmed) return
|
||||||
setArchiveError('')
|
setArchiveError('')
|
||||||
const res = await setPoolActive(poolId)
|
const res = await setPoolActive(poolId)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { PendingUser } from '../../utils/api'
|
|||||||
type UserType = 'personal' | 'company'
|
type UserType = 'personal' | 'company'
|
||||||
type UserRole = 'user' | 'admin'
|
type UserRole = 'user' | 'admin'
|
||||||
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
||||||
type StatusFilter = 'all' | 'pending' | 'verifying' | 'active'
|
type StatusFilter = 'all' | 'pending' | 'active'
|
||||||
|
|
||||||
export default function AdminUserVerifyPage() {
|
export default function AdminUserVerifyPage() {
|
||||||
const {
|
const {
|
||||||
@ -95,7 +95,6 @@ export default function AdminUserVerifyPage() {
|
|||||||
|
|
||||||
const statusBadge = (s: PendingUser['status']) => {
|
const statusBadge = (s: PendingUser['status']) => {
|
||||||
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
|
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
|
||||||
if (s === 'verifying') return badge('Verifying', 'bg-blue-100 text-blue-700')
|
|
||||||
return badge('Active', 'bg-green-100 text-green-700')
|
return badge('Active', 'bg-green-100 text-green-700')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +244,6 @@ export default function AdminUserVerifyPage() {
|
|||||||
>
|
>
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">All Statuses</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="verifying">Verifying</option>
|
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
@ -21,6 +22,9 @@ export default function DashboardPage() {
|
|||||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||||
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
|
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
const [latestNews, setLatestNews] = useState<Array<{ id: number; title: string; summary?: string; slug: string; published_at?: string | null }>>([])
|
||||||
|
const [newsLoading, setNewsLoading] = useState(false)
|
||||||
|
const [newsError, setNewsError] = useState<string | null>(null)
|
||||||
|
|
||||||
const { userStatus, loading: statusLoading } = useUserStatus()
|
const { userStatus, loading: statusLoading } = useUserStatus()
|
||||||
|
|
||||||
@ -46,6 +50,27 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Redirect if not logged in (only after auth is ready)
|
// Redirect if not logged in (only after auth is ready)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthReady && !user) {
|
if (isAuthReady && !user) {
|
||||||
@ -231,6 +256,57 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Latest News */}
|
||||||
|
<div className="rounded-2xl bg-white border border-gray-200 shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Latest News</h2>
|
||||||
|
<Link href="/news" className="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-4 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-gray-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
|
||||||
export interface UserStatus {
|
export interface UserStatus {
|
||||||
@ -20,6 +21,7 @@ export interface UserStatusResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useUserStatus = () => {
|
export const useUserStatus = () => {
|
||||||
|
const router = useRouter()
|
||||||
const [userStatus, setUserStatus] = useState<UserStatus | null>(null)
|
const [userStatus, setUserStatus] = useState<UserStatus | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -86,6 +88,14 @@ export const useUserStatus = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
setUserStatus(null)
|
||||||
|
setError(null)
|
||||||
|
router.push('/suspended')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If 401, try token refresh once
|
// If 401, try token refresh once
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.log('Got 401, attempting token refresh...')
|
console.log('Got 401, attempting token refresh...')
|
||||||
@ -104,6 +114,14 @@ export const useUserStatus = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (retryResponse.status === 403) {
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
setUserStatus(null)
|
||||||
|
setError(null)
|
||||||
|
router.push('/suspended')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!retryResponse.ok) {
|
if (!retryResponse.ok) {
|
||||||
throw new Error(`Failed to fetch user status: ${retryResponse.status}`)
|
throw new Error(`Failed to fetch user status: ${retryResponse.status}`)
|
||||||
}
|
}
|
||||||
@ -138,7 +156,7 @@ export const useUserStatus = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [isClient, accessToken, user])
|
}, [isClient, accessToken, user, router])
|
||||||
|
|
||||||
// Fetch status on mount and when auth changes
|
// Fetch status on mount and when auth changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -81,6 +81,9 @@ export default function LoginForm() {
|
|||||||
const redirectPath = (result as any).redirectPath || '/dashboard'
|
const redirectPath = (result as any).redirectPath || '/dashboard'
|
||||||
// instant redirect; toast persists via global store
|
// instant redirect; toast persists via global store
|
||||||
router.push(redirectPath)
|
router.push(redirectPath)
|
||||||
|
} else if ((result as any)?.redirectPath) {
|
||||||
|
router.push((result as any).redirectPath)
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
|
|||||||
@ -46,8 +46,13 @@ export function useLogin() {
|
|||||||
|
|
||||||
console.log('Login response status:', response.status)
|
console.log('Login response status:', response.status)
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Handle HTTP errors
|
// Handle HTTP errors
|
||||||
|
if (response.status === 403) {
|
||||||
|
return { success: false, error: 'Account suspended', redirectPath: '/suspended' }
|
||||||
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
throw new Error('Invalid credentials')
|
throw new Error('Invalid credentials')
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
@ -58,8 +63,6 @@ export function useLogin() {
|
|||||||
throw new Error('Login failed. Please try again.')
|
throw new Error('Login failed. Please try again.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
console.log('Login response data:', data)
|
console.log('Login response data:', data)
|
||||||
|
|
||||||
if (data.success && data.accessToken && data.user) {
|
if (data.success && data.accessToken && data.user) {
|
||||||
|
|||||||
151
src/app/news/[slug]/page.tsx
Normal file
151
src/app/news/[slug]/page.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Header from '../../components/nav/Header'
|
||||||
|
import Footer from '../../components/Footer'
|
||||||
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||||
|
|
||||||
|
type PublicNewsItem = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
content?: string
|
||||||
|
slug: string
|
||||||
|
category?: string
|
||||||
|
imageUrl?: string
|
||||||
|
published_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateReadingTime(text: string) {
|
||||||
|
const words = text.trim().split(/\s+/).filter(Boolean).length
|
||||||
|
const minutes = Math.max(1, Math.ceil(words / 200))
|
||||||
|
return `${minutes} min read`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const slug = typeof params?.slug === 'string' ? params.slug : Array.isArray(params?.slug) ? params.slug[0] : ''
|
||||||
|
|
||||||
|
const [item, setItem] = React.useState<PublicNewsItem | null>(null)
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
let active = true
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const res = await fetch(`${BASE_URL}/api/news/${encodeURIComponent(slug)}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) throw new Error('News article not found.')
|
||||||
|
throw new Error('Failed to load news article.')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
if (active) setItem(json.data || null)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (active) setError(e?.message || 'Failed to load news article.')
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { active = false }
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const readingTime = item?.content ? estimateReadingTime(item.content) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTransitionEffect>
|
||||||
|
<Header />
|
||||||
|
<main className="bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||||
|
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||||
|
<nav className="text-sm text-gray-500 mb-6">
|
||||||
|
<Link href="/" className="hover:text-blue-900">Home</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link href="/news" className="hover:text-blue-900">News</Link>
|
||||||
|
{item?.title && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-gray-700 line-clamp-1">{item.title}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="rounded-2xl bg-white shadow-lg border border-gray-100 p-8">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 w-2/3 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-1/3 bg-gray-100 rounded" />
|
||||||
|
<div className="h-64 w-full bg-gray-100 rounded" />
|
||||||
|
<div className="h-4 w-3/4 bg-gray-100 rounded" />
|
||||||
|
<div className="h-4 w-5/6 bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="rounded-2xl bg-white shadow-lg border border-red-100 p-8">
|
||||||
|
<div className="text-red-700 font-medium mb-2">{error}</div>
|
||||||
|
<Link href="/news" className="text-blue-900 hover:text-blue-700 font-semibold">
|
||||||
|
Back to News
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && item && (
|
||||||
|
<article className="rounded-2xl bg-white shadow-lg border border-gray-100 overflow-hidden">
|
||||||
|
<header className="p-6 sm:p-10">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-blue-700">
|
||||||
|
{item.category && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-100 px-2.5 py-1 font-medium">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.published_at && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{readingTime && (
|
||||||
|
<span className="text-gray-500">• {readingTime}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-3xl sm:text-4xl font-extrabold text-blue-900 tracking-tight">
|
||||||
|
{item.title}
|
||||||
|
</h1>
|
||||||
|
{item.summary && (
|
||||||
|
<p className="mt-4 text-lg text-gray-700">
|
||||||
|
{item.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-6 sm:px-10 pb-10">
|
||||||
|
<div className="mx-auto max-w-[104ch]">
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<div className="mb-6 lg:-mt-24 lg:mb-2 lg:ml-16 lg:float-right lg:w-[60%] rounded-2xl overflow-hidden shadow bg-gray-50 border border-gray-100">
|
||||||
|
<img
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-full h-72 sm:h-80 lg:h-96 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||||
|
{item.content || 'No content available.'}
|
||||||
|
</div>
|
||||||
|
<div className="clear-both" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</PageTransitionEffect>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import Header from '../components/nav/Header'
|
import Header from '../components/nav/Header'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
|
||||||
|
|
||||||
type PublicNewsItem = {
|
type PublicNewsItem = {
|
||||||
id: number
|
id: number
|
||||||
@ -17,47 +17,10 @@ type PublicNewsItem = {
|
|||||||
published_at?: string | null
|
published_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewsDetailModal({ item, onClose }: { item: PublicNewsItem; onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50">
|
|
||||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
|
||||||
<div className="relative w-full max-w-4xl bg-white rounded-2xl shadow-2xl max-h-[90vh] flex flex-col">
|
|
||||||
<div className="flex-shrink-0 bg-white border-b px-6 py-4 flex items-center justify-between rounded-t-2xl">
|
|
||||||
<h2 className="text-2xl font-bold text-blue-900 pr-4">{item.title}</h2>
|
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 flex-shrink-0">
|
|
||||||
<XMarkIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 overflow-y-auto">
|
|
||||||
{item.imageUrl && (
|
|
||||||
<div className="rounded-xl overflow-hidden mb-6 max-h-80 flex items-center justify-center bg-gray-50">
|
|
||||||
<img src={item.imageUrl} alt={item.title} className="w-full h-full object-contain" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.published_at && (
|
|
||||||
<div className="text-sm text-gray-500 mb-4">
|
|
||||||
Published: {new Date(item.published_at).toLocaleDateString('de-DE')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.summary && (
|
|
||||||
<div className="text-lg text-gray-700 mb-6 font-medium">{item.summary}</div>
|
|
||||||
)}
|
|
||||||
<div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
|
||||||
{item.content || 'No content available.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsPage() {
|
export default function NewsPage() {
|
||||||
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
const [selectedItem, setSelectedItem] = React.useState<PublicNewsItem | null>(null)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -110,10 +73,10 @@ export default function NewsPage() {
|
|||||||
{!loading && items.length > 0 && (
|
{!loading && items.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<article
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setSelectedItem(item)}
|
href={`/news/${item.slug}`}
|
||||||
className="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-300 cursor-pointer transform hover:-translate-y-1"
|
className="group bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
<div className="aspect-[3/2] bg-gradient-to-br from-blue-100 to-blue-50">
|
<div className="aspect-[3/2] bg-gradient-to-br from-blue-100 to-blue-50">
|
||||||
{item.imageUrl ? (
|
{item.imageUrl ? (
|
||||||
@ -125,35 +88,38 @@ export default function NewsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h2 className="text-xl font-bold text-blue-900 mb-3 line-clamp-2 hover:text-blue-700">
|
<div className="flex items-center gap-2 text-xs text-blue-700">
|
||||||
|
{item.category && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-100 px-2 py-0.5 font-medium">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.published_at && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-xl font-bold text-blue-900 line-clamp-2 group-hover:text-blue-700">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h2>
|
</h2>
|
||||||
{item.summary && (
|
{item.summary && (
|
||||||
<p className="text-gray-700 mb-4 line-clamp-3">{item.summary}</p>
|
<p className="mt-3 text-gray-700 line-clamp-3">{item.summary}</p>
|
||||||
)}
|
)}
|
||||||
{item.published_at && (
|
<div className="mt-4 inline-flex items-center text-blue-900 font-semibold group-hover:text-blue-700">
|
||||||
<div className="text-sm text-gray-500 mb-4">
|
|
||||||
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button className="inline-flex items-center text-blue-900 font-semibold hover:text-blue-700">
|
|
||||||
Read More
|
Read More
|
||||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{selectedItem && (
|
|
||||||
<NewsDetailModal item={selectedItem} onClose={() => setSelectedItem(null)} />
|
|
||||||
)}
|
|
||||||
</PageTransitionEffect>
|
</PageTransitionEffect>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import TutorialModal, { createTutorialSteps } from '../components/TutorialModal'
|
import TutorialModal, { createTutorialSteps } from '../components/TutorialModal'
|
||||||
@ -28,6 +29,16 @@ interface StatusItem {
|
|||||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
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() {
|
export default function QuickActionDashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
@ -35,6 +46,9 @@ export default function QuickActionDashboardPage() {
|
|||||||
const accessToken = useAuthStore(s => s.accessToken) // NEW
|
const accessToken = useAuthStore(s => s.accessToken) // NEW
|
||||||
const { userStatus, loading, error, refreshStatus } = useUserStatus()
|
const { userStatus, loading, error, refreshStatus } = useUserStatus()
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [latestNews, setLatestNews] = useState<LatestNewsItem[]>([])
|
||||||
|
const [newsLoading, setNewsLoading] = useState(false)
|
||||||
|
const [newsError, setNewsError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Tutorial state
|
// Tutorial state
|
||||||
const [isTutorialOpen, setIsTutorialOpen] = useState(false)
|
const [isTutorialOpen, setIsTutorialOpen] = useState(false)
|
||||||
@ -49,6 +63,27 @@ export default function QuickActionDashboardPage() {
|
|||||||
setHasSeenTutorial(!!tutorialSeen)
|
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
|
// Derive status from real backend data
|
||||||
const emailVerified = userStatus?.email_verified || false
|
const emailVerified = userStatus?.email_verified || false
|
||||||
const idUploaded = userStatus?.documents_uploaded || false
|
const idUploaded = userStatus?.documents_uploaded || false
|
||||||
@ -458,18 +493,55 @@ export default function QuickActionDashboardPage() {
|
|||||||
|
|
||||||
{/* Latest News */}
|
{/* Latest News */}
|
||||||
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
|
<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-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
Latest News
|
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
||||||
</h2>
|
Latest News
|
||||||
<ul className="list-disc pl-5 space-y-2 text-sm text-gray-700">
|
</h2>
|
||||||
<li>
|
<Link href="/news" className="text-xs sm:text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||||
<span className="font-medium text-[#8D6B1D]">New:</span> Referral system
|
View all
|
||||||
launch – invite friends and earn rewards.
|
</Link>
|
||||||
</li>
|
</div>
|
||||||
<li>
|
|
||||||
Profile completion unlocks more features. Keep progressing!
|
{newsLoading && (
|
||||||
</li>
|
<div className="space-y-3">
|
||||||
</ul>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
32
src/app/suspended/page.tsx
Normal file
32
src/app/suspended/page.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageLayout from '../components/PageLayout'
|
||||||
|
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||||
|
|
||||||
|
export default function SuspendedPage() {
|
||||||
|
return (
|
||||||
|
<PageTransitionEffect>
|
||||||
|
<PageLayout showFooter={true} className="bg-[#F5F5F0] text-slate-900">
|
||||||
|
<div className="min-h-[70vh] flex items-center justify-center px-6">
|
||||||
|
<div className="max-w-xl w-full rounded-3xl border border-slate-200 bg-white/80 shadow-xl p-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-rose-100 text-rose-700 flex items-center justify-center text-2xl">!</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-slate-900">Account suspended</h1>
|
||||||
|
<p className="mt-3 text-slate-700">
|
||||||
|
Your account has been suspended. For more information, contact
|
||||||
|
{' '}<a className="text-[#8D6B1D] font-semibold" href="mailto:office@profit-planet.com">office@profit-planet.com</a>.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-800 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
</PageTransitionEffect>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -154,8 +154,15 @@ export class ApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async delete(endpoint: string, token?: string): Promise<Response> {
|
static async delete(endpoint: string, token?: string, data?: any): Promise<Response> {
|
||||||
return this.makeRequest(endpoint, { method: 'DELETE' }, token)
|
return this.makeRequest(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
},
|
||||||
|
token
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,6 +372,16 @@ export class AdminAPI {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async removePoolMembers(token: string, poolId: string | number, userIds: Array<string | number>) {
|
||||||
|
const endpoint = API_ENDPOINTS.ADMIN_POOL_MEMBERS.replace(':id', String(poolId))
|
||||||
|
const response = await ApiClient.delete(endpoint, token, { userIds })
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Failed to remove pool members' }))
|
||||||
|
throw new Error(error.message || 'Failed to remove pool members')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
static async updateUserStatus(token: string, userId: string, status: string) {
|
static async updateUserStatus(token: string, userId: string, status: string) {
|
||||||
const endpoint = API_ENDPOINTS.ADMIN_UPDATE_USER_STATUS.replace(':id', userId)
|
const endpoint = API_ENDPOINTS.ADMIN_UPDATE_USER_STATUS.replace(':id', userId)
|
||||||
const response = await ApiClient.patch(endpoint, { status }, token)
|
const response = await ApiClient.patch(endpoint, { status }, token)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user