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.
This commit is contained in:
seaznCode 2026-02-20 21:45:54 +01:00
parent ab003be9fa
commit 7526e5c2e5
13 changed files with 920 additions and 192 deletions

View File

@ -2,6 +2,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement'; import useContractManagement from '../hooks/useContractManagement';
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
type Props = { type Props = {
editingTemplateId?: string | null; editingTemplateId?: string | null;
@ -23,6 +24,8 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null); const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
const [publishConfirmMessage, setPublishConfirmMessage] = useState('')
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
@ -152,7 +155,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
lang lang
) )
const save = async (publish: boolean) => { const doSave = async (publish: boolean) => {
const html = htmlCode.trim(); const html = htmlCode.trim();
// NEW: validate all fields // NEW: validate all fields
if (!canSave) { if (!canSave) {
@ -160,14 +163,6 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
return; return;
} }
if (publish && type === 'contract') {
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
const ok = window.confirm(
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
);
if (!ok) return;
}
setSaving(true); setSaving(true);
setStatusMsg(null); setStatusMsg(null);
@ -218,6 +213,21 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
} }
}; };
const save = async (publish: boolean) => {
if (publish && type === 'contract') {
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the same user type and language.`)
setPublishConfirmOpen(true)
return
}
await doSave(publish)
}
const confirmPublish = async () => {
setPublishConfirmOpen(false)
await doSave(true)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{editingMeta && ( {editingMeta && (
@ -372,6 +382,16 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
{saving && <span className="text-xs text-gray-500">Saving</span>} {saving && <span className="text-xs text-gray-500">Saving</span>}
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>} {statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
</div> </div>
<ConfirmActionModal
open={publishConfirmOpen}
pending={saving}
title="Activate template now?"
description={publishConfirmMessage || 'This will activate this template.'}
confirmText="Activate"
onClose={() => !saving && setPublishConfirmOpen(false)}
onConfirm={confirmPublish}
/>
</div> </div>
); );
} }

View File

@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement'; import useContractManagement from '../hooks/useContractManagement';
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
type Props = { type Props = {
refreshKey?: number; refreshKey?: number;
@ -37,6 +38,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
const [items, setItems] = useState<ContractTemplate[]>([]); const [items, setItems] = useState<ContractTemplate[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null)
const { const {
listTemplates, listTemplates,
@ -83,29 +85,39 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]); }, [refreshKey]);
const onToggleState = async (id: string, current: string) => { const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
const target = current === 'published' ? 'inactive' : 'active';
// Confirmation: activating a contract/GDPR will deactivate other active templates of the same kind
if (target === 'active') {
const tpl = items.find((i) => i.id === id);
if (tpl?.type === 'contract') {
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
const ok = window.confirm(
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
);
if (!ok) return;
}
}
try { try {
const updated = await updateTemplateState(id, target as 'active' | 'inactive'); const updated = await updateTemplateState(id, target as 'active' | 'inactive');
// Update clicked item immediately, then refresh list to reflect any auto-deactivations. // Update clicked item immediately, then refresh list to reflect any auto-deactivations.
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i)); setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
await load(); await load();
} catch {} } catch {}
}
const onToggleState = async (id: string, current: string) => {
const target = current === 'published' ? 'inactive' : 'active';
if (target === 'active') {
const tpl = items.find((i) => i.id === id);
if (tpl?.type === 'contract') {
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
setPendingToggle({
id,
target,
requiresConfirm: true,
message: `This will deactivate other active ${kind} templates that apply to the same user type and language.`,
});
return;
}
}
await executeToggleState(id, target);
}; };
const confirmToggleState = async () => {
if (!pendingToggle) return
await executeToggleState(pendingToggle.id, pendingToggle.target)
setPendingToggle(null)
}
const onPreview = (id: string) => openPreviewInNewTab(id); const onPreview = (id: string) => openPreviewInNewTab(id);
const onGenPdf = async (id: string) => { const onGenPdf = async (id: string) => {
@ -191,6 +203,15 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div> <div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
)} )}
</div> </div>
<ConfirmActionModal
open={Boolean(pendingToggle?.requiresConfirm)}
title="Activate template now?"
description={pendingToggle?.message || 'This action will update template activation status.'}
confirmText="Activate"
onClose={() => setPendingToggle(null)}
onConfirm={confirmToggleState}
/>
</div> </div>
); );
} }

View File

@ -9,6 +9,7 @@ import useAuthStore from '../../../store/authStore'
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect' import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
import { AdminAPI } from '../../../utils/api' import { AdminAPI } from '../../../utils/api'
import { authFetch } from '../../../utils/authFetch' import { authFetch } from '../../../utils/authFetch'
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
type PoolUser = { type PoolUser = {
id: string id: string
@ -78,6 +79,7 @@ function PoolManagePageInner() {
const [savingMembers, setSavingMembers] = React.useState(false) const [savingMembers, setSavingMembers] = React.useState(false)
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null) const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
const [removeError, setRemoveError] = React.useState<string>('') const [removeError, setRemoveError] = React.useState<string>('')
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([]) const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
const [linkedSubscriptionId, setLinkedSubscriptionId] = React.useState<string>(initialSubscriptionId) const [linkedSubscriptionId, setLinkedSubscriptionId] = React.useState<string>(initialSubscriptionId)
const [savingSubscription, setSavingSubscription] = React.useState(false) const [savingSubscription, setSavingSubscription] = React.useState(false)
@ -293,10 +295,14 @@ function PoolManagePageInner() {
} }
async function removeMember(userId: string) { async function removeMember(userId: string) {
if (!token || !poolId || poolId === 'pool-unknown') return
const user = users.find(u => u.id === userId) const user = users.find(u => u.id === userId)
const label = user?.name || user?.email || 'this user' const label = user?.name || user?.email || 'this user'
if (!window.confirm(`Remove ${label} from this pool?`)) return setRemoveConfirm({ userId, label })
}
async function confirmRemoveMember() {
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
const userId = removeConfirm.userId
setRemoveError('') setRemoveError('')
setRemovingMemberId(userId) setRemovingMemberId(userId)
try { try {
@ -306,6 +312,7 @@ function PoolManagePageInner() {
setRemoveError(e?.message || 'Failed to remove user from pool.') setRemoveError(e?.message || 'Failed to remove user from pool.')
} finally { } finally {
setRemovingMemberId(null) setRemovingMemberId(null)
setRemoveConfirm(null)
} }
} }
@ -638,6 +645,17 @@ function PoolManagePageInner() {
</div> </div>
</div> </div>
)} )}
<ConfirmActionModal
open={Boolean(removeConfirm)}
pending={Boolean(removingMemberId)}
intent="danger"
title="Remove member from pool?"
description={`This will remove ${removeConfirm?.label || 'this user'} from the pool.`}
confirmText="Remove"
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
onConfirm={confirmRemoveMember}
/>
</div> </div>
</PageTransitionEffect> </PageTransitionEffect>
) )

View File

@ -12,6 +12,7 @@ import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect' import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import CreateNewPoolModal from './components/createNewPoolModal' import CreateNewPoolModal from './components/createNewPoolModal'
import { authFetch } from '../../utils/authFetch' import { authFetch } from '../../utils/authFetch'
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
type Pool = { type Pool = {
id: string id: string
@ -35,6 +36,8 @@ export default function PoolManagementPage() {
const [createSuccess, setCreateSuccess] = React.useState<string>('') const [createSuccess, setCreateSuccess] = React.useState<string>('')
const [createModalOpen, setCreateModalOpen] = React.useState(false) const [createModalOpen, setCreateModalOpen] = React.useState(false)
const [archiveError, setArchiveError] = React.useState<string>('') const [archiveError, setArchiveError] = React.useState<string>('')
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
// Token and API URL // Token and API URL
const token = useAuthStore(s => s.accessToken) const token = useAuthStore(s => s.accessToken)
@ -128,26 +131,28 @@ export default function PoolManagementPage() {
} }
async function handleArchive(poolId: string) { async function handleArchive(poolId: string) {
const confirmed = window.confirm('Archive this pool? Users will no longer be able to join or use it.') setPoolStatusConfirm({ poolId, action: 'archive' })
if (!confirmed) return
setArchiveError('')
const res = await setPoolInactive(poolId)
if (res.ok) {
await refresh?.()
} else {
setArchiveError(res.message || 'Failed to deactivate pool.')
}
} }
async function handleSetActive(poolId: string) { async function handleSetActive(poolId: string) {
const confirmed = window.confirm('Unarchive this pool and make it active again?') setPoolStatusConfirm({ poolId, action: 'activate' })
if (!confirmed) return }
async function confirmPoolStatusChange() {
if (!poolStatusConfirm) return
const { poolId, action } = poolStatusConfirm
setPoolStatusPending(true)
setArchiveError('') setArchiveError('')
const res = await setPoolActive(poolId) try {
if (res.ok) { const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
await refresh?.() if (res.ok) {
} else { await refresh?.()
setArchiveError(res.message || 'Failed to activate pool.') } else {
setArchiveError(res.message || (action === 'archive' ? 'Failed to deactivate pool.' : 'Failed to activate pool.'))
}
} finally {
setPoolStatusPending(false)
setPoolStatusConfirm(null)
} }
} }
@ -344,6 +349,21 @@ export default function PoolManagementPage() {
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }} clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
/> />
<ConfirmActionModal
open={Boolean(poolStatusConfirm)}
pending={poolStatusPending}
intent={poolStatusConfirm?.action === 'archive' ? 'danger' : 'default'}
title={poolStatusConfirm?.action === 'archive' ? 'Archive pool?' : 'Activate pool?'}
description={
poolStatusConfirm?.action === 'archive'
? 'Users will no longer be able to join or use this pool while archived.'
: 'This pool will be active again and available for use.'
}
confirmText={poolStatusConfirm?.action === 'archive' ? 'Archive' : 'Set Active'}
onClose={() => { if (!poolStatusPending) setPoolStatusConfirm(null) }}
onConfirm={confirmPoolStatusChange}
/>
<Footer /> <Footer />
</div> </div>
</PageTransitionEffect> </PageTransitionEffect>

View File

@ -25,54 +25,74 @@ export function useActiveCoffees() {
useEffect(() => { useEffect(() => {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, ''); const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
const url = `${base}/api/admin/coffee/active`;
console.log('[useActiveCoffees] Fetching active coffees from:', url); const candidateUrls = [
`${base}/api/coffee/active`,
`${base}/api/admin/coffee/active`,
];
console.log('[useActiveCoffees] Fetching active coffees from candidates:', candidateUrls);
setLoading(true); setLoading(true);
setError(null); setError(null);
authFetch(url, { const tryFetch = async () => {
method: 'GET', let lastError: string | null = null;
headers: { Accept: 'application/json' },
credentials: 'include',
})
.then(async (response) => {
const contentType = response.headers.get('content-type') || '';
console.log('[useActiveCoffees] Response status:', response.status);
console.log('[useActiveCoffees] Response content-type:', contentType);
if (!response.ok || !contentType.includes('application/json')) { for (const url of candidateUrls) {
const text = await response.text().catch(() => ''); try {
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200)); const response = await authFetch(url, {
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`); method: 'GET',
} headers: { Accept: 'application/json' },
credentials: 'include',
});
const json = await response.json(); const contentType = response.headers.get('content-type') || '';
console.log('[useActiveCoffees] Raw JSON response:', json); console.log('[useActiveCoffees] Response for', url, response.status, contentType);
const data: ActiveCoffee[] = if (!response.ok || !contentType.includes('application/json')) {
Array.isArray(json?.data) ? json.data : const text = await response.text().catch(() => '');
Array.isArray(json) ? json : lastError = `Request failed: ${response.status} ${text.slice(0, 160)}`;
[]
console.log('[useActiveCoffees] Parsed coffee data:', data);
const mapped: CoffeeItem[] = data if (response.status === 404) {
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1') continue;
.map((coffee) => {
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price
return {
id: String(coffee.id),
name: coffee.title || `Coffee ${coffee.id}`,
description: coffee.description || '',
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
image: coffee.pictureUrl || '',
} }
}) throw new Error(lastError);
}
console.log('[useActiveCoffees] Mapped coffee items:', mapped) const json = await response.json();
setCoffees(mapped) const data: ActiveCoffee[] =
}) Array.isArray(json?.data) ? json.data :
Array.isArray(json) ? json :
[];
const mapped: CoffeeItem[] = data
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
.map((coffee) => {
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price;
return {
id: String(coffee.id),
name: coffee.title || `Coffee ${coffee.id}`,
description: coffee.description || '',
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
image: coffee.pictureUrl || '',
};
});
setCoffees(mapped);
return;
} catch (e: any) {
lastError = e?.message || 'Failed to load active coffees';
}
}
throw new Error(
lastError ||
'Active coffee list endpoint is not available. Please restart/update the backend and try again.'
);
};
tryFetch()
.catch((error: any) => { .catch((error: any) => {
console.error('[useActiveCoffees] Error fetching coffees:', error); console.error('[useActiveCoffees] Error fetching coffees:', error);
setError(error?.message || 'Failed to load active coffees'); setError(error?.message || 'Failed to load active coffees');

View File

@ -20,6 +20,7 @@ import {
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import { AdminAPI, DetailedUserInfo } from '../utils/api' import { AdminAPI, DetailedUserInfo } from '../utils/api'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import ConfirmActionModal from './modals/ConfirmActionModal'
interface UserDetailModalProps { interface UserDetailModalProps {
isOpen: boolean isOpen: boolean
@ -73,6 +74,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
const [docsLoading, setDocsLoading] = useState(false) const [docsLoading, setDocsLoading] = useState(false)
const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({}) const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({})
const [selectedFile, setSelectedFile] = useState<{ contract?: string; gdpr?: string }>({}) const [selectedFile, setSelectedFile] = useState<{ contract?: string; gdpr?: string }>({})
const [moveConfirm, setMoveConfirm] = useState<{
documentId?: number
targetType: 'contract' | 'gdpr'
filename?: string | null
objectKey?: string
} | null>(null)
const missingIdOrContract = !!userDetails?.userStatus && ( const missingIdOrContract = !!userDetails?.userStatus && (
userDetails.userStatus.documents_uploaded !== 1 || userDetails.userStatus.documents_uploaded !== 1 ||
@ -296,10 +303,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
const moveContractDoc = async (documentId: number | undefined, targetType: 'contract' | 'gdpr', filename?: string | null, objectKey?: string) => { const moveContractDoc = async (documentId: number | undefined, targetType: 'contract' | 'gdpr', filename?: string | null, objectKey?: string) => {
if (!userId || !token) return if (!userId || !token) return
const label = targetType === 'gdpr' ? 'GDPR' : 'Contract' setMoveConfirm({ documentId, targetType, filename, objectKey })
const name = filename ? `\n\nFile: ${filename}` : '' }
const ok = window.confirm(`Move this document to ${label}?${name}`)
if (!ok) return const confirmMoveContractDoc = async () => {
if (!userId || !token || !moveConfirm) return
const { documentId, targetType, objectKey } = moveConfirm
const loadingKey = objectKey || String(documentId || '') const loadingKey = objectKey || String(documentId || '')
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true })) setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
try { try {
@ -309,6 +318,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
console.error('UserDetailModal.moveContractDoc error:', e) console.error('UserDetailModal.moveContractDoc error:', e)
} finally { } finally {
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false })) setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
setMoveConfirm(null)
} }
} }
@ -943,6 +953,21 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</div> </div>
</div> </div>
</Dialog> </Dialog>
<ConfirmActionModal
open={Boolean(moveConfirm)}
pending={Boolean(moveConfirm && moveLoading[(moveConfirm.objectKey || String(moveConfirm.documentId || ''))])}
title={`Move document to ${moveConfirm?.targetType === 'gdpr' ? 'GDPR' : 'Contract'}?`}
description="This will reclassify the selected document under the chosen contract type."
confirmText="Move document"
onClose={() => setMoveConfirm(null)}
onConfirm={confirmMoveContractDoc}
extraContent={
moveConfirm?.filename ? (
<div className="text-xs text-gray-600">File: {moveConfirm.filename}</div>
) : null
}
/>
</Transition.Root> </Transition.Root>
) )
} }

View File

@ -658,6 +658,15 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
> >
Profile Profile
</button> </button>
<button
onClick={() => {
router.push('/profile/subscriptions')
setMobileMenuOpen(false)
}}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
My Subscriptions
</button>
</div> </div>
{/* Main navigation (info + links + referral + ADMIN LAST) */} {/* Main navigation (info + links + referral + ADMIN LAST) */}

View File

@ -32,6 +32,38 @@ const resolveInvoiceUrl = (invoice: AboInvoice) => {
return isAbsUrl(raw) ? raw : `${BASE_URL}${raw.startsWith('/') ? '' : '/'}${raw}` return isAbsUrl(raw) ? raw : `${BASE_URL}${raw.startsWith('/') ? '' : '/'}${raw}`
} }
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled'
const normalizeInvoiceStatus = (rawStatus?: string | null): UiLifecycleStatus => {
const status = (rawStatus || '').toLowerCase()
if (!status) return 'issued'
if (status === 'cancelled' || status === 'canceled' || status === 'void') return 'cancelled'
if (status === 'pause' || status === 'paused') return 'pause'
if (status === 'finished' || status === 'paid' || status === 'closed' || status === 'settled') return 'finished'
if (status === 'ongoing' || status === 'active' || status === 'processing') return 'ongoing'
if (status === 'issued' || status === 'draft' || status === 'pending') return 'issued'
return 'issued'
}
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'
function downloadBlob(content: Blob, fileName: string) { function downloadBlob(content: Blob, fileName: string) {
const url = URL.createObjectURL(content) const url = URL.createObjectURL(content)
const a = document.createElement('a') const a = document.createElement('a')
@ -153,7 +185,13 @@ export default function FinanceInvoices({ abonementId }: Props) {
<tr key={invoice.id} className="border-t border-gray-200/70"> <tr key={invoice.id} className="border-t border-gray-200/70">
<td className="px-4 py-3 text-gray-800">{formatDate(invoice.issuedAt || invoice.createdAt)}</td> <td className="px-4 py-3 text-gray-800">{formatDate(invoice.issuedAt || invoice.createdAt)}</td>
<td className="px-4 py-3 text-gray-800">{invoice.invoiceNumber || `#${invoice.id}`}</td> <td className="px-4 py-3 text-gray-800">{invoice.invoiceNumber || `#${invoice.id}`}</td>
<td className="px-4 py-3 text-gray-700">{invoice.status || '—'}</td> <td className="px-4 py-3 text-gray-700">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(normalizeInvoiceStatus(invoice.status))}`}
>
{displayStatus(normalizeInvoiceStatus(invoice.status))}
</span>
</td>
<td className="px-4 py-3 text-gray-900 font-medium">{formatMoney(invoice.totalGross, invoice.currency)}</td> <td className="px-4 py-3 text-gray-900 font-medium">{formatMoney(invoice.totalGross, invoice.currency)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@ -0,0 +1,56 @@
import { authFetch } from '../../utils/authFetch'
type EditAboItem = {
coffeeId: string | number
quantity: number
}
export async function editSubscriptionContent(abonementId: string | number, items: EditAboItem[]) {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/abonements/${abonementId}/content`
const res = await authFetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ items }),
})
const ct = res.headers.get('content-type') || ''
const json = ct.includes('application/json') ? await res.json().catch(() => ({})) : null
if (!res.ok || !json?.success) {
throw new Error(json?.message || `Update failed: ${res.status}`)
}
return json.data
}
export async function changeSubscriptionStatus(
abonementId: string | number,
targetStatus: 'ongoing' | 'pause' | 'cancelled'
) {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const action =
targetStatus === 'pause'
? 'pause'
: targetStatus === 'ongoing'
? 'resume'
: 'cancel'
const url = `${base}/api/abonements/${abonementId}/${action}`
const res = await authFetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
},
})
const ct = res.headers.get('content-type') || ''
const json = ct.includes('application/json') ? await res.json().catch(() => ({})) : null
if (!res.ok || !json?.success) {
throw new Error(json?.message || `Status update failed: ${res.status}`)
}
return json.data
}

View File

@ -9,7 +9,7 @@ type AbonementItem = {
export type CurrentAbo = { export type CurrentAbo = {
id: number | string id: number | string
status: 'active' | 'paused' | 'canceled' status: 'active' | 'paused' | 'canceled' | 'cancelled' | 'expired' | 'issued' | 'ongoing' | 'finished' | 'pause'
nextBillingAt?: string | null nextBillingAt?: string | null
email?: string email?: string
price?: string | number price?: string | number
@ -19,6 +19,85 @@ export type CurrentAbo = {
frequency?: string frequency?: string
country?: string country?: string
startedAt?: string | null startedAt?: string | null
endedAt?: string | null
createdAt?: string | null
currency?: string | null
}
function mapAbonement(rawAbo: any): CurrentAbo {
return {
id: rawAbo.id,
status: (rawAbo.status || 'active') as CurrentAbo['status'],
nextBillingAt: rawAbo.next_billing_at ?? rawAbo.nextBillingAt ?? null,
email: rawAbo.email,
price: rawAbo.price,
name: `${rawAbo.first_name ?? ''} ${rawAbo.last_name ?? ''}`.trim() || rawAbo.name || 'Coffee Subscription',
frequency: rawAbo.frequency,
country: rawAbo.country,
startedAt: rawAbo.started_at ?? rawAbo.startedAt ?? null,
endedAt: rawAbo.ended_at ?? rawAbo.endedAt ?? null,
createdAt: rawAbo.created_at ?? rawAbo.createdAt ?? null,
currency: rawAbo.currency ?? null,
pack_breakdown: Array.isArray(rawAbo.pack_breakdown)
? rawAbo.pack_breakdown.map((it: any) => ({
coffeeName: it.coffee_name ?? it.coffee_title ?? it.coffeeName,
coffeeId: it.coffee_table_id ?? it.coffeeId,
quantity: Number(it.packs ?? it.quantity ?? 0),
}))
: Array.isArray(rawAbo.items)
? rawAbo.items.map((it: any) => ({
coffeeName: it.coffee_name ?? it.coffeeName,
coffeeId: it.coffee_table_id ?? it.coffeeId,
quantity: Number(it.packs ?? it.quantity ?? 0),
}))
: undefined,
}
}
export function useMyAbonements(refreshKey: number = 0) {
const [data, setData] = useState<CurrentAbo[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
setError(null)
try {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/abonements/mine`
const res = await authFetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
const ct = res.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
const json = isJson ? await res.json().catch(() => ({})) : null
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
const rows = Array.isArray(json?.data) ? json.data : []
const mapped = rows.map((row: any) => mapAbonement(row))
if (active) setData(mapped)
} catch (e: any) {
if (active) setError(e?.message || 'Failed to load subscriptions.')
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [refreshKey])
return { data, loading, error }
} }
export function useMyAboStatus() { export function useMyAboStatus() {
@ -52,32 +131,7 @@ export function useMyAboStatus() {
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`) if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
const rawAbo = json?.data?.abonement ?? null const rawAbo = json?.data?.abonement ?? null
const mappedAbo: CurrentAbo | null = rawAbo const mappedAbo: CurrentAbo | null = rawAbo ? mapAbonement(rawAbo) : null
? {
id: rawAbo.id,
status: (rawAbo.status || 'active') as 'active' | 'paused' | 'canceled',
nextBillingAt: rawAbo.next_billing_at ?? rawAbo.nextBillingAt ?? null,
email: rawAbo.email,
price: rawAbo.price,
name: `${rawAbo.first_name ?? ''} ${rawAbo.last_name ?? ''}`.trim() || rawAbo.name || 'Coffee Subscription',
frequency: rawAbo.frequency,
country: rawAbo.country,
startedAt: rawAbo.started_at ?? rawAbo.startedAt ?? null,
pack_breakdown: Array.isArray(rawAbo.pack_breakdown)
? rawAbo.pack_breakdown.map((it: any) => ({
coffeeName: it.coffee_name ?? it.coffeeName,
coffeeId: it.coffee_table_id ?? it.coffeeId,
quantity: Number(it.packs ?? it.quantity ?? 0),
}))
: Array.isArray(rawAbo.items)
? rawAbo.items.map((it: any) => ({
coffeeName: it.coffee_name ?? it.coffeeName,
coffeeId: it.coffee_table_id ?? it.coffeeId,
quantity: Number(it.packs ?? it.quantity ?? 0),
}))
: undefined,
}
: null
if (active) { if (active) {
setHasAbo(Boolean(json?.data?.hasAbo)) setHasAbo(Boolean(json?.data?.hasAbo))

View File

@ -10,8 +10,6 @@ import BasicInformation from './components/basicInformation'
import MediaSection from './components/mediaSection' import MediaSection from './components/mediaSection'
import BankInformation from './components/bankInformation' import BankInformation from './components/bankInformation'
import EditModal from './components/editModal' import EditModal from './components/editModal'
import UserAbo from './components/userAbo'
import FinanceInvoices from './components/financeInvoices'
import { getProfileCompletion } from './hooks/getProfileCompletion' import { getProfileCompletion } from './hooks/getProfileCompletion'
import { useProfileData } from './hooks/getProfileData' import { useProfileData } from './hooks/getProfileData'
import { useMedia } from './hooks/getMedia' import { useMedia } from './hooks/getMedia'
@ -97,7 +95,6 @@ export default function ProfilePage() {
const [editModalError, setEditModalError] = React.useState<string | null>(null) const [editModalError, setEditModalError] = React.useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = React.useState(false) const [downloadLoading, setDownloadLoading] = React.useState(false)
const [downloadError, setDownloadError] = React.useState<string | null>(null) const [downloadError, setDownloadError] = React.useState<string | null>(null)
const [currentAboId, setCurrentAboId] = React.useState<string | number | null>(null)
useEffect(() => { setHasHydrated(true) }, []) useEffect(() => { setHasHydrated(true) }, [])
@ -395,10 +392,22 @@ export default function ProfilePage() {
{/* Bank Info, Media */} {/* Bank Info, Media */}
<div className="space-y-6 sm:space-y-8 mb-8"> <div className="space-y-6 sm:space-y-8 mb-8">
{/* --- My Abo Section (above bank info) --- */} <section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
<UserAbo onAboChange={setCurrentAboId} /> <div className="flex items-start justify-between gap-4 flex-wrap">
{/* --- Finance Section (invoices by current abo) --- */} <div>
<FinanceInvoices abonementId={currentAboId} /> <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 --- */} {/* --- Edit Bank Information Section --- */}
<BankInformation <BankInformation
profileData={profileData} profileData={profileData}

View File

@ -0,0 +1,492 @@
'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])
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">
<p className="text-sm font-semibold text-gray-900 truncate">
{subscription.name || `Subscription #${subscription.id}`}
</p>
<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) => (
<div key={`${item.coffeeId || 'coffee'}-${index}`} className="rounded-md bg-white/80 border border-gray-200 p-3">
<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>
))}
</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>
)
}

View File

@ -1,8 +1,6 @@
'use client' 'use client'
import { Fragment } from 'react' import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
import { Dialog, Transition } from '@headlessui/react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
interface DeactivateReferralLinkModalProps { interface DeactivateReferralLinkModalProps {
open: boolean open: boolean
@ -22,77 +20,25 @@ export default function DeactivateReferralLinkModal({
onConfirm, onConfirm,
}: DeactivateReferralLinkModalProps) { }: DeactivateReferralLinkModalProps) {
return ( return (
<Transition show={open} as={Fragment}> <ConfirmActionModal
<Dialog onClose={onClose} className="relative z-[1100]"> open={open}
<Transition.Child pending={pending}
as={Fragment} intent="danger"
enter="transition-opacity ease-out duration-200" title="Deactivate referral link?"
enterFrom="opacity-0" description="This will immediately deactivate the selected referral link so it can no longer be used."
enterTo="opacity-100" confirmText="Deactivate"
leave="transition-opacity ease-in duration-150" onClose={onClose}
leaveFrom="opacity-100" onConfirm={onConfirm}
leaveTo="opacity-0" extraContent={
> linkPreview ? (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" /> <div>
</Transition.Child> <span className="text-xs uppercase text-gray-500">Link</span>
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
<div className="fixed inset-0 overflow-y-auto"> {linkPreview}
<div className="flex min-h-full items-center justify-center p-4"> </div>
<Transition.Child
as={Fragment}
enter="transition-all ease-out duration-200"
enterFrom="opacity-0 translate-y-2 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="transition-all ease-in duration-150"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-2 sm:scale-95"
>
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl ring-1 ring-black/10">
<div className="flex items-start gap-3">
<div className="shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-gray-900">
Deactivate referral link?
</Dialog.Title>
<div className="mt-2 text-sm text-gray-600">
<p>This will immediately deactivate the selected referral link so it can no longer be used.</p>
{linkPreview && (
<div className="mt-3">
<span className="text-xs uppercase text-gray-500">Link</span>
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
{linkPreview}
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<button
type="button"
disabled={pending}
onClick={onClose}
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50 disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
disabled={pending}
onClick={onConfirm}
className="inline-flex items-center rounded-md border border-red-300 bg-red-600 px-3 py-2 text-sm text-white hover:bg-red-700 disabled:opacity-60"
>
{pending ? 'Deactivating…' : 'Deactivate'}
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div> </div>
</div> ) : null
</Dialog> }
</Transition> />
) )
} }