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

521 lines
25 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 { useMyAbonements } from '../hooks/getAbo'
import FinanceInvoices from '../components/financeInvoices'
import { useActiveCoffees } from '../../coffee-abonnements/hooks/getActiveCoffees'
import { changeSubscriptionStatus, editSubscriptionContent } from '../hooks/editAbo'
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled'
const normalizeSubscriptionStatus = (rawStatus?: string | null): UiLifecycleStatus => {
const status = (rawStatus || '').toLowerCase()
if (status === 'pause' || status === 'paused') return 'pause'
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
if (status === 'finished' || status === 'expired') return 'finished'
if (status === 'issued') return 'issued'
return 'ongoing'
}
const statusBadgeClass = (status: UiLifecycleStatus) => {
if (status === 'ongoing') return 'bg-green-100 text-green-800'
if (status === 'pause') return 'bg-amber-100 text-amber-800'
if (status === 'cancelled') return 'bg-red-100 text-red-700'
if (status === 'finished') return 'bg-gray-200 text-gray-700'
return 'bg-blue-100 text-blue-800'
}
const displayStatus = (status: UiLifecycleStatus) =>
status === 'pause'
? 'Pause'
: status === 'cancelled'
? 'Cancelled'
: status === 'ongoing'
? 'Ongoing'
: status === 'finished'
? 'Finished'
: 'Issued'
const formatDate = (value?: string | null) => {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleDateString('de-DE')
}
const formatMoney = (value?: string | number | null, currency?: string | null) => {
if (value == null || value === '') return '—'
const amount = typeof value === 'string' ? Number(value) : value
if (!Number.isFinite(Number(amount))) return String(value)
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(Number(amount))
}
export default function ProfileSubscriptionsPage() {
const router = useRouter()
const user = useAuthStore(state => state.user)
const isAuthReady = useAuthStore(state => state.isAuthReady)
const [hasHydrated, setHasHydrated] = React.useState(false)
const [selectedAboId, setSelectedAboId] = React.useState<string | number | null>(null)
const [refreshKey, setRefreshKey] = React.useState(0)
const [editingContent, setEditingContent] = React.useState(false)
const [draftItems, setDraftItems] = React.useState<Record<string, number>>({})
const [savingContent, setSavingContent] = React.useState(false)
const [contentError, setContentError] = React.useState<string | null>(null)
const [statusBusy, setStatusBusy] = React.useState(false)
const [statusError, setStatusError] = React.useState<string | null>(null)
const [statusConfirmTarget, setStatusConfirmTarget] = React.useState<'ongoing' | 'pause' | 'cancelled' | null>(null)
const { data: subscriptions, loading, error } = useMyAbonements(refreshKey)
const { coffees, loading: coffeesLoading, error: coffeesError } = useActiveCoffees()
useEffect(() => { setHasHydrated(true) }, [])
useEffect(() => {
if (!hasHydrated || !isAuthReady) return
if (!user) router.replace('/login')
}, [hasHydrated, isAuthReady, user, router])
useEffect(() => {
if (!subscriptions.length) {
setSelectedAboId(null)
return
}
setSelectedAboId(prev => {
if (prev != null && subscriptions.some((sub) => String(sub.id) === String(prev))) return prev
return subscriptions[0].id
})
}, [subscriptions])
const coffeeImageById = React.useMemo(() => {
const map: Record<string, string> = {}
coffees.forEach((coffee) => {
if (coffee.image) {
map[String(coffee.id)] = coffee.image
}
})
return map
}, [coffees])
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>
)
}
const selectedAbo = subscriptions.find((sub) => String(sub.id) === String(selectedAboId)) || null
const status = normalizeSubscriptionStatus(selectedAbo?.status)
const includedItems = selectedAbo?.pack_breakdown || selectedAbo?.items || []
const totalPacks = includedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0)
const draftTotalPacks = Object.values(draftItems).reduce((sum, qty) => sum + (Number(qty) || 0), 0)
const canChangeContent = status === 'ongoing' || status === 'pause' || status === 'issued'
const startEditingContent = () => {
if (!selectedAbo) return
const nextDraft: Record<string, number> = {}
;(selectedAbo.pack_breakdown || selectedAbo.items || []).forEach((item) => {
const key = String(item.coffeeId ?? '')
if (!key) return
nextDraft[key] = (nextDraft[key] || 0) + (Number(item.quantity) || 0)
})
setDraftItems(nextDraft)
setEditingContent(true)
setContentError(null)
}
const updateDraftQty = (coffeeId: string, value: number) => {
setDraftItems((prev) => ({ ...prev, [coffeeId]: Math.max(0, Math.floor(Number(value) || 0)) }))
}
const cancelEditingContent = () => {
setEditingContent(false)
setDraftItems({})
setContentError(null)
}
const saveContentChanges = async () => {
if (!selectedAbo) return
setContentError(null)
const items = Object.entries(draftItems)
.filter(([, qty]) => Number(qty) > 0)
.map(([coffeeId, quantity]) => ({ coffeeId, quantity: Number(quantity) }))
if (!items.length) {
setContentError('Please select at least one coffee with quantity greater than 0.')
return
}
if (draftTotalPacks !== 6 && draftTotalPacks !== 12) {
setContentError('Total packs must be exactly 6 or 12.')
return
}
try {
setSavingContent(true)
await editSubscriptionContent(selectedAbo.id, items)
setEditingContent(false)
setRefreshKey((k) => k + 1)
} catch (e: any) {
setContentError(e?.message || 'Failed to update subscription content.')
} finally {
setSavingContent(false)
}
}
const onStatusAction = async (targetStatus: 'ongoing' | 'pause' | 'cancelled') => {
if (!selectedAbo) return
setStatusError(null)
try {
setStatusBusy(true)
await changeSubscriptionStatus(selectedAbo.id, targetStatus)
setEditingContent(false)
setRefreshKey((k) => k + 1)
} catch (e: any) {
setStatusError(e?.message || 'Failed to update subscription status.')
} finally {
setStatusBusy(false)
}
}
const openStatusConfirm = (targetStatus: 'ongoing' | 'pause' | 'cancelled') => {
setStatusConfirmTarget(targetStatus)
setStatusError(null)
}
const closeStatusConfirm = () => {
if (statusBusy) return
setStatusConfirmTarget(null)
}
const confirmStatusChange = async () => {
if (!statusConfirmTarget) return
await onStatusAction(statusConfirmTarget)
setStatusConfirmTarget(null)
}
const confirmTitle =
statusConfirmTarget === 'pause'
? 'Pause subscription?'
: statusConfirmTarget === 'ongoing'
? 'Resume subscription?'
: 'Cancel subscription?'
const confirmDescription =
statusConfirmTarget === 'pause'
? 'Your subscription will be paused. Billing and deliveries remain stopped until you resume it.'
: statusConfirmTarget === 'ongoing'
? 'Your subscription will become ongoing again for the next billing cycle.'
: 'Your subscription will be cancelled and cannot continue automatically. This action is intended to stop future cycles.'
const confirmButtonText =
statusConfirmTarget === 'pause'
? 'Yes, pause'
: statusConfirmTarget === 'ongoing'
? 'Yes, resume'
: 'Yes, cancel subscription'
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">
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-3xl font-bold text-gray-900">My Subscriptions</h1>
<p className="text-gray-600 mt-2">Select any subscription to view details and included items.</p>
</div>
<button
onClick={() => router.push('/profile')}
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Back to profile
</button>
</div>
{loading ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
Loading subscriptions
</div>
) : error ? (
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
{error}
</div>
) : subscriptions.length === 0 ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
You dont have any subscriptions yet.
</div>
) : (
<div className="space-y-6">
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
<h2 className="text-lg font-semibold text-gray-900">All subscriptions</h2>
<p className="text-xs text-gray-600">{subscriptions.length} total</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{subscriptions.map((subscription) => {
const isSelected = String(subscription.id) === String(selectedAboId)
const subscriptionStatus = normalizeSubscriptionStatus(subscription.status)
const packs = (subscription.pack_breakdown || subscription.items || []).reduce(
(sum, item) => sum + (Number(item.quantity) || 0),
0,
)
return (
<button
key={subscription.id}
onClick={() => setSelectedAboId(subscription.id)}
className={`text-left rounded-md border px-3 py-3 transition ${
isSelected
? 'border-[#8D6B1D] bg-white shadow-md'
: 'border-gray-200 bg-white/80 hover:bg-white'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{subscription.name || `Subscription #${subscription.id}`}
</p>
</div>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusBadgeClass(subscriptionStatus)}`}
>
{displayStatus(subscriptionStatus)}
</span>
</div>
<p className="text-xs text-gray-600 mt-1">ID: {subscription.id}</p>
<p className="text-xs text-gray-600">Started: {formatDate(subscription.startedAt || subscription.createdAt)}</p>
<p className="text-xs text-gray-600">Included packs: {packs}</p>
</button>
)
})}
</div>
</section>
{selectedAbo && (
<>
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-gray-900">Subscription details</h2>
<p className="text-sm text-gray-600 mt-1">{selectedAbo.name || 'Coffee Subscription'}</p>
</div>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(status)}`}
>
{displayStatus(status)}
</span>
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
{(status === 'ongoing' || status === 'issued') && (
<button
onClick={() => openStatusConfirm('pause')}
disabled={statusBusy}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{statusBusy ? 'Updating…' : 'Pause subscription'}
</button>
)}
{status === 'pause' && (
<button
onClick={() => openStatusConfirm('ongoing')}
disabled={statusBusy}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{statusBusy ? 'Updating…' : 'Resume subscription'}
</button>
)}
{(status === 'ongoing' || status === 'pause' || status === 'issued') && (
<button
onClick={() => openStatusConfirm('cancelled')}
disabled={statusBusy}
className="rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{statusBusy ? 'Updating…' : 'Cancel subscription'}
</button>
)}
{(status === 'finished' || status === 'cancelled') && (
<p className="text-xs text-gray-600">No further status changes are available for this subscription.</p>
)}
</div>
{statusError && (
<p className="mt-2 text-xs text-red-600">{statusError}</p>
)}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
<p className="text-gray-500">Subscription ID</p>
<p className="font-medium text-gray-900">{selectedAbo.id}</p>
</div>
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
<p className="text-gray-500">Price</p>
<p className="font-medium text-gray-900">{formatMoney(selectedAbo.price, selectedAbo.currency)}</p>
</div>
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
<p className="text-gray-500">Frequency</p>
<p className="font-medium text-gray-900">{selectedAbo.frequency || '—'}</p>
</div>
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
<p className="text-gray-500">Country</p>
<p className="font-medium text-gray-900">{(selectedAbo.country || '').toUpperCase() || '—'}</p>
</div>
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
<p className="text-gray-500">Started</p>
<p className="font-medium text-gray-900">{formatDate(selectedAbo.startedAt || selectedAbo.createdAt)}</p>
</div>
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
<p className="text-gray-500">Next billing</p>
<p className="font-medium text-gray-900">{formatDate(selectedAbo.nextBillingAt)}</p>
</div>
</div>
</section>
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-gray-900">Included in your subscription</h2>
<p className="text-sm text-gray-600 mt-1">{includedItems.length} item(s), {totalPacks} total pack(s)</p>
<p className="text-xs text-gray-500 mt-1">Changes apply from your next billing cycle.</p>
</div>
{!editingContent && canChangeContent && (
<button
onClick={startEditingContent}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
>
Change coffees for next month
</button>
)}
</div>
{!canChangeContent && (
<p className="mt-3 text-xs text-gray-600">
Coffee content can only be changed while a subscription is issued, ongoing, or paused.
</p>
)}
{includedItems.length === 0 ? (
<div className="mt-4 rounded-md bg-white/80 border border-gray-200 p-3 text-sm text-gray-600">
No included items were returned for this subscription.
</div>
) : (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{includedItems.map((item, index) => {
const imageUrl = item.coffeeId ? coffeeImageById[String(item.coffeeId)] : ''
return (
<div key={`${item.coffeeId || 'coffee'}-${index}`} className="rounded-md bg-white/80 border border-gray-200 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900">{item.coffeeName || `Coffee #${item.coffeeId || index + 1}`}</p>
<p className="text-xs text-gray-600 mt-1">Coffee ID: {item.coffeeId || '—'}</p>
<p className="text-xs text-gray-600">Packs included: {Number(item.quantity) || 0}</p>
</div>
{imageUrl ? (
<img
src={imageUrl}
alt={item.coffeeName || `Coffee #${item.coffeeId || index + 1}`}
className="h-14 w-14 rounded-md object-cover border border-gray-200 shrink-0"
/>
) : (
<div className="h-14 w-14 rounded-md bg-gray-100 border border-gray-200 shrink-0" />
)}
</div>
</div>
)
})}
</div>
)}
{editingContent && canChangeContent && (
<div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4">
<div className="flex items-center justify-between gap-2 flex-wrap mb-3">
<h3 className="text-sm font-semibold text-gray-900">Edit coffee content</h3>
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (must be 6 or 12)</p>
</div>
{coffeesLoading ? (
<p className="text-sm text-gray-600">Loading available coffees</p>
) : coffeesError ? (
<p className="text-sm text-red-600">{coffeesError}</p>
) : (
<div className="space-y-2 max-h-72 overflow-auto pr-1">
{coffees.map((coffee) => {
const key = String(coffee.id)
const qty = draftItems[key] || 0
return (
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-gray-200 px-3 py-2">
<div>
<p className="text-sm font-medium text-gray-900">{coffee.name}</p>
<p className="text-xs text-gray-600 line-clamp-1">{coffee.description || 'No description'}</p>
</div>
<input
type="number"
min={0}
step={1}
value={qty}
onChange={(e) => updateDraftQty(key, Number(e.target.value))}
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-900"
/>
</div>
)
})}
</div>
)}
{contentError && (
<p className="mt-3 text-xs text-red-600">{contentError}</p>
)}
<div className="mt-4 flex items-center gap-2">
<button
onClick={saveContentChanges}
disabled={savingContent || coffeesLoading}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{savingContent ? 'Saving…' : 'Save changes'}
</button>
<button
onClick={cancelEditingContent}
disabled={savingContent}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
)}
</section>
<FinanceInvoices abonementId={selectedAbo.id} />
</>
)}
</div>
)}
</div>
</div>
</main>
</BlueBlurryBackground>
<ConfirmActionModal
open={Boolean(statusConfirmTarget)}
pending={statusBusy}
title={confirmTitle}
description={confirmDescription}
confirmText={confirmButtonText}
intent={statusConfirmTarget === 'cancelled' ? 'danger' : 'default'}
onClose={closeStatusConfirm}
onConfirm={confirmStatusChange}
/>
</PageLayout>
)
}