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:
parent
ab003be9fa
commit
7526e5c2e5
@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import useContractManagement from '../hooks/useContractManagement';
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||
|
||||
type Props = {
|
||||
editingTemplateId?: string | null;
|
||||
@ -23,6 +24,8 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
const [description, setDescription] = useState<string>('');
|
||||
|
||||
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);
|
||||
|
||||
@ -152,7 +155,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
lang
|
||||
)
|
||||
|
||||
const save = async (publish: boolean) => {
|
||||
const doSave = async (publish: boolean) => {
|
||||
const html = htmlCode.trim();
|
||||
// NEW: validate all fields
|
||||
if (!canSave) {
|
||||
@ -160,14 +163,6 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
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);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{editingMeta && (
|
||||
@ -372,6 +382,16 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
||||
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import useContractManagement from '../hooks/useContractManagement';
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||
|
||||
type Props = {
|
||||
refreshKey?: number;
|
||||
@ -37,6 +38,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
const [items, setItems] = useState<ContractTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null)
|
||||
|
||||
const {
|
||||
listTemplates,
|
||||
@ -83,29 +85,39 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshKey]);
|
||||
|
||||
const onToggleState = async (id: string, current: string) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
|
||||
try {
|
||||
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
||||
// 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));
|
||||
await load();
|
||||
} 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 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import useAuthStore from '../../../store/authStore'
|
||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||
import { AdminAPI } from '../../../utils/api'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||
|
||||
type PoolUser = {
|
||||
id: string
|
||||
@ -78,6 +79,7 @@ function PoolManagePageInner() {
|
||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
||||
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
||||
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 [linkedSubscriptionId, setLinkedSubscriptionId] = React.useState<string>(initialSubscriptionId)
|
||||
const [savingSubscription, setSavingSubscription] = React.useState(false)
|
||||
@ -293,10 +295,14 @@ function PoolManagePageInner() {
|
||||
}
|
||||
|
||||
async function removeMember(userId: string) {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
const user = users.find(u => u.id === userId)
|
||||
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('')
|
||||
setRemovingMemberId(userId)
|
||||
try {
|
||||
@ -306,6 +312,7 @@ function PoolManagePageInner() {
|
||||
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
||||
} finally {
|
||||
setRemovingMemberId(null)
|
||||
setRemoveConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -638,6 +645,17 @@ function PoolManagePageInner() {
|
||||
</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>
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
|
||||
@ -12,6 +12,7 @@ import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
|
||||
type Pool = {
|
||||
id: string
|
||||
@ -35,6 +36,8 @@ export default function PoolManagementPage() {
|
||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||
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
|
||||
const token = useAuthStore(s => s.accessToken)
|
||||
@ -128,26 +131,28 @@ export default function PoolManagementPage() {
|
||||
}
|
||||
|
||||
async function handleArchive(poolId: string) {
|
||||
const confirmed = window.confirm('Archive this pool? Users will no longer be able to join or use it.')
|
||||
if (!confirmed) return
|
||||
setArchiveError('')
|
||||
const res = await setPoolInactive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || 'Failed to deactivate pool.')
|
||||
}
|
||||
setPoolStatusConfirm({ poolId, action: 'archive' })
|
||||
}
|
||||
|
||||
async function handleSetActive(poolId: string) {
|
||||
const confirmed = window.confirm('Unarchive this pool and make it active again?')
|
||||
if (!confirmed) return
|
||||
setPoolStatusConfirm({ poolId, action: 'activate' })
|
||||
}
|
||||
|
||||
async function confirmPoolStatusChange() {
|
||||
if (!poolStatusConfirm) return
|
||||
const { poolId, action } = poolStatusConfirm
|
||||
setPoolStatusPending(true)
|
||||
setArchiveError('')
|
||||
const res = await setPoolActive(poolId)
|
||||
try {
|
||||
const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || 'Failed to activate pool.')
|
||||
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(''); }}
|
||||
/>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</PageTransitionEffect>
|
||||
|
||||
@ -25,54 +25,74 @@ export function useActiveCoffees() {
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
setError(null);
|
||||
|
||||
authFetch(url, {
|
||||
const tryFetch = async () => {
|
||||
let lastError: string | null = null;
|
||||
|
||||
for (const url of candidateUrls) {
|
||||
try {
|
||||
const response = await authFetch(url, {
|
||||
method: 'GET',
|
||||
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);
|
||||
console.log('[useActiveCoffees] Response for', url, response.status, contentType);
|
||||
|
||||
if (!response.ok || !contentType.includes('application/json')) {
|
||||
const text = await response.text().catch(() => '');
|
||||
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200));
|
||||
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`);
|
||||
lastError = `Request failed: ${response.status} ${text.slice(0, 160)}`;
|
||||
|
||||
if (response.status === 404) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('[useActiveCoffees] Raw JSON response:', json);
|
||||
|
||||
const data: ActiveCoffee[] =
|
||||
Array.isArray(json?.data) ? json.data :
|
||||
Array.isArray(json) ? json :
|
||||
[]
|
||||
console.log('[useActiveCoffees] Parsed coffee data:', data);
|
||||
[];
|
||||
|
||||
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
|
||||
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 || '',
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[useActiveCoffees] Mapped coffee items:', mapped)
|
||||
setCoffees(mapped)
|
||||
})
|
||||
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) => {
|
||||
console.error('[useActiveCoffees] Error fetching coffees:', error);
|
||||
setError(error?.message || 'Failed to load active coffees');
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import ConfirmActionModal from './modals/ConfirmActionModal'
|
||||
|
||||
interface UserDetailModalProps {
|
||||
isOpen: boolean
|
||||
@ -73,6 +74,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
const [docsLoading, setDocsLoading] = useState(false)
|
||||
const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({})
|
||||
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 && (
|
||||
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) => {
|
||||
if (!userId || !token) return
|
||||
const label = targetType === 'gdpr' ? 'GDPR' : 'Contract'
|
||||
const name = filename ? `\n\nFile: ${filename}` : ''
|
||||
const ok = window.confirm(`Move this document to ${label}?${name}`)
|
||||
if (!ok) return
|
||||
setMoveConfirm({ documentId, targetType, filename, objectKey })
|
||||
}
|
||||
|
||||
const confirmMoveContractDoc = async () => {
|
||||
if (!userId || !token || !moveConfirm) return
|
||||
const { documentId, targetType, objectKey } = moveConfirm
|
||||
const loadingKey = objectKey || String(documentId || '')
|
||||
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
|
||||
try {
|
||||
@ -309,6 +318,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
console.error('UserDetailModal.moveContractDoc error:', e)
|
||||
} finally {
|
||||
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
|
||||
setMoveConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -943,6 +953,21 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -658,6 +658,15 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
>
|
||||
Profile
|
||||
</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>
|
||||
|
||||
{/* Main navigation (info + links + referral + ADMIN LAST) */}
|
||||
|
||||
@ -32,6 +32,38 @@ const resolveInvoiceUrl = (invoice: AboInvoice) => {
|
||||
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) {
|
||||
const url = URL.createObjectURL(content)
|
||||
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">
|
||||
<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-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">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
56
src/app/profile/hooks/editAbo.ts
Normal file
56
src/app/profile/hooks/editAbo.ts
Normal 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
|
||||
}
|
||||
@ -9,7 +9,7 @@ type AbonementItem = {
|
||||
|
||||
export type CurrentAbo = {
|
||||
id: number | string
|
||||
status: 'active' | 'paused' | 'canceled'
|
||||
status: 'active' | 'paused' | 'canceled' | 'cancelled' | 'expired' | 'issued' | 'ongoing' | 'finished' | 'pause'
|
||||
nextBillingAt?: string | null
|
||||
email?: string
|
||||
price?: string | number
|
||||
@ -19,6 +19,85 @@ export type CurrentAbo = {
|
||||
frequency?: string
|
||||
country?: string
|
||||
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() {
|
||||
@ -52,32 +131,7 @@ export function useMyAboStatus() {
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||
|
||||
const rawAbo = json?.data?.abonement ?? null
|
||||
const mappedAbo: CurrentAbo | null = rawAbo
|
||||
? {
|
||||
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
|
||||
const mappedAbo: CurrentAbo | null = rawAbo ? mapAbonement(rawAbo) : null
|
||||
|
||||
if (active) {
|
||||
setHasAbo(Boolean(json?.data?.hasAbo))
|
||||
|
||||
@ -10,8 +10,6 @@ import BasicInformation from './components/basicInformation'
|
||||
import MediaSection from './components/mediaSection'
|
||||
import BankInformation from './components/bankInformation'
|
||||
import EditModal from './components/editModal'
|
||||
import UserAbo from './components/userAbo'
|
||||
import FinanceInvoices from './components/financeInvoices'
|
||||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||
import { useProfileData } from './hooks/getProfileData'
|
||||
import { useMedia } from './hooks/getMedia'
|
||||
@ -97,7 +95,6 @@ export default function ProfilePage() {
|
||||
const [editModalError, setEditModalError] = React.useState<string | null>(null)
|
||||
const [downloadLoading, setDownloadLoading] = React.useState(false)
|
||||
const [downloadError, setDownloadError] = React.useState<string | null>(null)
|
||||
const [currentAboId, setCurrentAboId] = React.useState<string | number | null>(null)
|
||||
|
||||
useEffect(() => { setHasHydrated(true) }, [])
|
||||
|
||||
@ -395,10 +392,22 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Bank Info, Media */}
|
||||
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||
{/* --- My Abo Section (above bank info) --- */}
|
||||
<UserAbo onAboChange={setCurrentAboId} />
|
||||
{/* --- Finance Section (invoices by current abo) --- */}
|
||||
<FinanceInvoices abonementId={currentAboId} />
|
||||
<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}
|
||||
|
||||
492
src/app/profile/subscriptions/page.tsx
Normal file
492
src/app/profile/subscriptions/page.tsx
Normal 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 don’t 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>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
|
||||
interface DeactivateReferralLinkModalProps {
|
||||
open: boolean
|
||||
@ -22,77 +20,25 @@ export default function DeactivateReferralLinkModal({
|
||||
onConfirm,
|
||||
}: DeactivateReferralLinkModalProps) {
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={onClose} className="relative z-[1100]">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<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">
|
||||
<ConfirmActionModal
|
||||
open={open}
|
||||
pending={pending}
|
||||
intent="danger"
|
||||
title="Deactivate referral link?"
|
||||
description="This will immediately deactivate the selected referral link so it can no longer be used."
|
||||
confirmText="Deactivate"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
extraContent={
|
||||
linkPreview ? (
|
||||
<div>
|
||||
<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>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user