profit-planet-frontend/src/app/profile/page.tsx

481 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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