481 lines
20 KiB
TypeScript
481 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import React, { useEffect } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import useAuthStore from '../store/authStore'
|
||
import PageLayout from '../components/PageLayout'
|
||
import BlueBlurryBackground from '../components/background/blueblurry'
|
||
import ProfileCompletion from './components/profileCompletion'
|
||
import BasicInformation from './components/basicInformation'
|
||
import MediaSection from './components/mediaSection'
|
||
import BankInformation from './components/bankInformation'
|
||
import EditModal from './components/editModal'
|
||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||
import { useProfileData } from './hooks/getProfileData'
|
||
import { useMedia } from './hooks/getMedia'
|
||
import { useMyAbonements } from './hooks/getAbo'
|
||
import { editProfileBasic } from './hooks/editProfile'
|
||
import { authFetch } from '../utils/authFetch'
|
||
|
||
// Helper to display missing fields in subtle gray italic (no yellow highlight)
|
||
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return (
|
||
<span className="italic text-gray-400">
|
||
Not provided
|
||
</span>
|
||
);
|
||
}
|
||
return <>{children}</>;
|
||
}
|
||
|
||
// Helper for safe access to profileData fields
|
||
function getProfileField<T extends keyof typeof defaultProfileData>(
|
||
obj: typeof defaultProfileData,
|
||
key: T
|
||
) {
|
||
return obj[key];
|
||
}
|
||
|
||
// Default profile data for typing
|
||
const defaultProfileData = {
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
phone: '',
|
||
address: '',
|
||
joinDate: '',
|
||
memberStatus: '',
|
||
profileComplete: 0,
|
||
accountHolder: '',
|
||
iban: '',
|
||
contactPersonName: '',
|
||
userType: '',
|
||
};
|
||
|
||
// Define fields for EditModal
|
||
const basicFields = [
|
||
{ key: 'firstName', label: 'First Name', type: 'text' },
|
||
{ key: 'lastName', label: 'Last Name', type: 'text' },
|
||
{ key: 'phone', label: 'Phone', type: 'text' },
|
||
{ key: 'address', label: 'Address', type: 'text' },
|
||
];
|
||
|
||
const bankFields = [
|
||
{ key: 'accountHolder', label: 'Account Holder', type: 'text' },
|
||
{ key: 'iban', label: 'IBAN', type: 'text' },
|
||
];
|
||
|
||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||
|
||
export default function ProfilePage() {
|
||
const router = useRouter()
|
||
const user = useAuthStore(state => state.user)
|
||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||
const [hasHydrated, setHasHydrated] = React.useState(false)
|
||
const [userId, setUserId] = React.useState<string | number | undefined>(undefined)
|
||
|
||
// --- declare ALL hooks before any early return (Rules of Hooks) ---
|
||
const [refreshKey, setRefreshKey] = React.useState(0)
|
||
const [showRefreshing, setShowRefreshing] = React.useState(false)
|
||
const [completionLoading, setCompletionLoading] = React.useState(false)
|
||
|
||
// Progress bar state (MOVED ABOVE EARLY RETURN)
|
||
const [progressPercent, setProgressPercent] = React.useState<number>(0)
|
||
const [completedSteps, setCompletedSteps] = React.useState<string[]>([])
|
||
const [allSteps, setAllSteps] = React.useState<string[]>([])
|
||
|
||
// Bank/edit state (keep, but bank editing disabled)
|
||
const [bankInfo, setBankInfo] = React.useState({ accountHolder: '', iban: '' })
|
||
const [editingBank, setEditingBank] = React.useState(false)
|
||
const [bankDraft, setBankDraft] = React.useState(bankInfo)
|
||
|
||
const [editModalOpen, setEditModalOpen] = React.useState(false)
|
||
const [editModalType, setEditModalType] = React.useState<'basic' | 'bank'>('basic')
|
||
const [editModalValues, setEditModalValues] = React.useState<Record<string, string>>({})
|
||
const [editModalError, setEditModalError] = React.useState<string | null>(null)
|
||
const [downloadLoading, setDownloadLoading] = React.useState(false)
|
||
const [downloadError, setDownloadError] = React.useState<string | null>(null)
|
||
|
||
useEffect(() => { setHasHydrated(true) }, [])
|
||
|
||
useEffect(() => {
|
||
if (user?.id) setUserId(user.id)
|
||
}, [user])
|
||
|
||
// Fetch hooks can run with undefined userId; they should handle it internally
|
||
const { data: profileDataApi, loading: profileLoading } = useProfileData(userId, refreshKey)
|
||
const { data: mediaData, loading: mediaLoading } = useMedia(userId, refreshKey)
|
||
const { data: subscriptions, loading: subscriptionsLoading, error: subscriptionsError } = useMyAbonements(refreshKey)
|
||
|
||
// Redirect only after hydration + auth ready
|
||
useEffect(() => {
|
||
if (!hasHydrated || !isAuthReady) return
|
||
if (!user) router.replace('/login')
|
||
}, [hasHydrated, isAuthReady, user, router])
|
||
|
||
// Completion fetch (gated inside effect)
|
||
useEffect(() => {
|
||
if (!hasHydrated || !isAuthReady || !user) return
|
||
let cancelled = false
|
||
|
||
;(async () => {
|
||
setCompletionLoading(true)
|
||
const progress = await getProfileCompletion()
|
||
|
||
if (cancelled) return
|
||
|
||
if (progress && typeof progress === 'object') {
|
||
const pct = progress.progressPercent ?? 0
|
||
const isAdminVerified = Boolean(profileDataApi?.userStatus?.is_admin_verified)
|
||
setProgressPercent(isAdminVerified ? pct : Math.min(pct, 95))
|
||
setCompletedSteps(progress.completedSteps ?? [])
|
||
setAllSteps(progress.steps?.map((s: any) => s.name || s.title || '') ?? [])
|
||
} else if (typeof progress === 'number') {
|
||
setProgressPercent(progress)
|
||
}
|
||
|
||
setCompletionLoading(false)
|
||
})()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [hasHydrated, isAuthReady, user, refreshKey, profileDataApi?.userStatus?.is_admin_verified])
|
||
|
||
useEffect(() => {
|
||
const verified = Boolean(profileDataApi?.userStatus?.is_admin_verified)
|
||
if (verified) setProgressPercent(prev => (prev < 100 ? 100 : prev))
|
||
}, [profileDataApi?.userStatus?.is_admin_verified])
|
||
|
||
const profileData = React.useMemo(() => {
|
||
if (!profileDataApi) {
|
||
return {
|
||
firstName: 'Admin',
|
||
lastName: 'User',
|
||
email: user?.email || 'office@profit-planet.com',
|
||
phone: '+49 123 456 789',
|
||
address: 'Musterstraße 123, 12345 Berlin',
|
||
joinDate: 'Oktober 2024',
|
||
memberStatus: 'Gold Member',
|
||
profileComplete: progressPercent,
|
||
accountHolder: '',
|
||
iban: '',
|
||
contactPersonName: '',
|
||
userType: user?.userType || '',
|
||
}
|
||
}
|
||
const { user: apiUser = {}, profile: apiProfile = {}, userStatus = {} } = profileDataApi
|
||
return {
|
||
firstName: apiUser.firstName ?? apiProfile.first_name ?? '',
|
||
lastName: apiUser.lastName ?? apiProfile.last_name ?? '',
|
||
email: apiUser.email ?? '',
|
||
phone: apiUser.phone ?? apiProfile.phone ?? '',
|
||
address: apiProfile.address ?? '',
|
||
joinDate: apiUser.createdAt
|
||
? new Date(apiUser.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'long' })
|
||
: '',
|
||
memberStatus: userStatus.status ?? '',
|
||
profileComplete: progressPercent,
|
||
accountHolder: apiProfile.account_holder_name ?? '',
|
||
iban: apiUser.iban ?? '',
|
||
contactPersonName: apiProfile.contact_person_name ?? '',
|
||
userType: apiUser.userType ?? '',
|
||
}
|
||
}, [profileDataApi, user, progressPercent])
|
||
|
||
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : []
|
||
|
||
useEffect(() => {
|
||
if (showRefreshing && !profileLoading && !mediaLoading && !completionLoading) {
|
||
const t = setTimeout(() => setShowRefreshing(false), 200)
|
||
return () => clearTimeout(t)
|
||
}
|
||
}, [showRefreshing, profileLoading, mediaLoading, completionLoading])
|
||
|
||
function openEditModal(type: 'basic' | 'bank', values: Record<string, string>) {
|
||
setEditModalType(type)
|
||
setEditModalValues(values)
|
||
setEditModalOpen(true)
|
||
}
|
||
|
||
async function handleEditModalSave() {
|
||
setEditModalError(null)
|
||
if (editModalType === 'basic') {
|
||
const payload: Partial<typeof defaultProfileData> = {}
|
||
;(['firstName', 'lastName', 'phone', 'address'] as const).forEach(key => {
|
||
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
||
payload[key] = editModalValues[key]?.trim()
|
||
}
|
||
})
|
||
const res = await editProfileBasic(payload)
|
||
if (res.success) {
|
||
setEditModalOpen(false)
|
||
setShowRefreshing(true)
|
||
setRefreshKey(k => k + 1)
|
||
} else if (res.status === 401) {
|
||
router.push('/login')
|
||
} else {
|
||
setEditModalError(res.error || 'Failed to update profile.')
|
||
}
|
||
} else {
|
||
setEditModalError('Bank information editing is disabled for now.')
|
||
}
|
||
}
|
||
|
||
function handleEditModalChange(key: string, value: string) {
|
||
setEditModalValues(prev => ({ ...prev, [key]: value }))
|
||
}
|
||
|
||
async function handleDownloadAccountData() {
|
||
if (!userId) return
|
||
setDownloadError(null)
|
||
setDownloadLoading(true)
|
||
|
||
try {
|
||
const fullRes = await authFetch(`${BASE_URL}/api/users/${userId}/full`, { method: 'GET' })
|
||
|
||
if (fullRes.status === 401) {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
|
||
if (!fullRes.ok) {
|
||
throw new Error('Failed to fetch account data')
|
||
}
|
||
|
||
const fullData = await fullRes.json()
|
||
const statusOnly = fullData?.userStatus?.status ?? null
|
||
const cleanedUser = fullData?.user && typeof fullData.user === 'object'
|
||
? (() => {
|
||
const { id, user_id, ...rest } = fullData.user as Record<string, any>
|
||
return rest
|
||
})()
|
||
: fullData?.user
|
||
const cleanedProfile = fullData?.profile && typeof fullData.profile === 'object'
|
||
? (() => {
|
||
const { id, user_id, ...rest } = fullData.profile as Record<string, any>
|
||
return rest
|
||
})()
|
||
: fullData?.profile
|
||
|
||
const exportPayload = {
|
||
exportedAt: new Date().toISOString(),
|
||
userType: user?.userType || null,
|
||
account: {
|
||
success: fullData?.success ?? true,
|
||
user: cleanedUser,
|
||
profile: cleanedProfile,
|
||
userStatus: statusOnly
|
||
}
|
||
}
|
||
|
||
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `profit-planet-account-data-${userId}-${new Date().toISOString().slice(0, 10)}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
} catch (error: any) {
|
||
setDownloadError(error?.message || 'Failed to download account data.')
|
||
} finally {
|
||
setDownloadLoading(false)
|
||
}
|
||
}
|
||
|
||
// --- EARLY RETURN AFTER ALL HOOKS ---
|
||
if (!hasHydrated || !isAuthReady || !user) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||
<p className="text-[#4A4A4A]">Loading...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<PageLayout className="bg-transparent text-gray-900">
|
||
<BlueBlurryBackground>
|
||
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* MASTER GLASS PANEL (prevents non-translucent gaps between cards) */}
|
||
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8">
|
||
{/* Page Header */}
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||
<p className="text-gray-600 mt-2">
|
||
Manage your account information and preferences
|
||
</p>
|
||
</div>
|
||
|
||
{/* Pending admin verification notice (above progress) */}
|
||
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
||
<div className="rounded-md bg-yellow-50/80 backdrop-blur border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">
|
||
Your account is fully submitted. Our team will verify your account shortly.
|
||
</div>
|
||
)}
|
||
|
||
<ProfileCompletion profileComplete={profileData.profileComplete} />
|
||
|
||
{/* Basic Info + Sidebar */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 sm:gap-8 mb-8">
|
||
{/* Basic Information */}
|
||
<div className="lg:col-span-2 space-y-6">
|
||
<BasicInformation
|
||
profileData={profileData}
|
||
HighlightIfMissing={HighlightIfMissing}
|
||
// Add edit button handler
|
||
onEdit={() => openEditModal('basic', {
|
||
firstName: profileData.firstName,
|
||
lastName: profileData.lastName,
|
||
phone: profileData.phone,
|
||
address: profileData.address,
|
||
})}
|
||
/>
|
||
</div>
|
||
{/* Sidebar: Account Status + Quick Actions */}
|
||
<div className="space-y-6">
|
||
{/* Account Status (make translucent) */}
|
||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">Member Since</span>
|
||
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">Status</span>
|
||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
||
{profileData.memberStatus}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">Profile</span>
|
||
<span className="text-sm font-medium text-green-600">Verified</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* Quick Actions (make translucent) */}
|
||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||
|
||
<div className="space-y-3">
|
||
<button
|
||
onClick={() => router.push('/dashboard')}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||
>
|
||
Go to Dashboard
|
||
</button>
|
||
<button
|
||
onClick={handleDownloadAccountData}
|
||
disabled={downloadLoading}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||
>
|
||
{downloadLoading ? 'Preparing download...' : 'Download Account Data'}
|
||
</button>
|
||
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||
Delete Account
|
||
</button>
|
||
</div>
|
||
{downloadError && (
|
||
<p className="mt-2 text-xs text-red-600">{downloadError}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bank Info, Media */}
|
||
<div className="space-y-6 sm:space-y-8 mb-8">
|
||
<section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
View your active subscriptions, included items and subscription details on a dedicated page.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => router.push('/profile/subscriptions')}
|
||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||
>
|
||
Open subscriptions
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-md border border-white/60 bg-white/70 p-3">
|
||
{subscriptionsLoading ? (
|
||
<p className="text-sm text-gray-600">Loading subscriptions…</p>
|
||
) : subscriptionsError ? (
|
||
<p className="text-sm text-red-700">{subscriptionsError}</p>
|
||
) : subscriptions.length === 0 ? (
|
||
<p className="text-sm text-gray-600">You don’t have any subscriptions yet.</p>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{subscriptions.map((subscription) => {
|
||
const packs = (subscription.pack_breakdown || subscription.items || []).reduce(
|
||
(sum, item) => sum + (Number(item.quantity) || 0),
|
||
0,
|
||
)
|
||
const started = subscription.startedAt || subscription.createdAt
|
||
const startedLabel = started ? new Date(started).toLocaleDateString('de-DE') : '—'
|
||
return (
|
||
<li key={subscription.id} className="flex items-center justify-between gap-3 text-sm border-b border-gray-200/60 pb-2 last:border-0 last:pb-0">
|
||
<div className="min-w-0">
|
||
<p className="font-medium text-gray-900 truncate">{subscription.name || `Subscription #${subscription.id}`}</p>
|
||
<p className="text-xs text-gray-600">Started: {startedLabel} • Packs: {packs}</p>
|
||
</div>
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||
{String(subscription.status || 'ongoing')}
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</section>
|
||
{/* --- Edit Bank Information Section --- */}
|
||
<BankInformation
|
||
profileData={profileData}
|
||
editingBank={false} // force read-only
|
||
bankDraft={bankDraft}
|
||
setEditingBank={setEditingBank}
|
||
setBankDraft={setBankDraft}
|
||
setBankInfo={setBankInfo}
|
||
// onEdit disabled for now
|
||
// onEdit={() => openEditModal('bank', { ... })}
|
||
/>
|
||
{/* --- Media Section --- */}
|
||
<MediaSection documents={documents} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Edit Modal */}
|
||
<EditModal
|
||
open={editModalOpen}
|
||
type={editModalType}
|
||
fields={editModalType === 'basic' ? basicFields : bankFields}
|
||
values={editModalValues}
|
||
onChange={handleEditModalChange}
|
||
onSave={handleEditModalSave}
|
||
onCancel={() => { setEditModalOpen(false); setEditModalError(null); }}
|
||
>
|
||
{/* Show error message if present */}
|
||
{editModalError && (
|
||
<div className="text-sm text-red-600 mb-2">{editModalError}</div>
|
||
)}
|
||
</EditModal>
|
||
</BlueBlurryBackground>
|
||
</PageLayout>
|
||
)
|
||
} |