- Added ConfirmActionModal to UserDetailModal for document move confirmation. - Introduced My Subscriptions button in Header for easier navigation. - Enhanced FinanceInvoices component to display normalized invoice statuses with badges. - Created editAbo hook for managing subscription content updates. - Updated getAbo hook to include more subscription statuses and improved mapping logic. - Refactored ProfilePage to link to subscriptions page and removed unused state. - Implemented ProfileSubscriptionsPage for managing subscriptions with detailed views and actions. - Replaced custom modal in DeactivateReferralLinkModal with ConfirmActionModal for consistency.
447 lines
18 KiB
TypeScript
447 lines
18 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 { 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)
|
|
|
|
// 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>
|
|
</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>
|
|
)
|
|
} |