profit-planet-frontend/src/app/profile/page.tsx
seaznCode 7526e5c2e5 feat: enhance user detail modal with confirmation for document moves, add subscriptions navigation, and improve invoice status handling
- 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.
2026-02-20 21:45:54 +01:00

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>
)
}