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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await refresh?.()
|
await refresh?.()
|
||||||
} else {
|
} 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(''); }}
|
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>
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
let lastError: string | null = null;
|
||||||
|
|
||||||
|
for (const url of candidateUrls) {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
});
|
||||||
.then(async (response) => {
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
console.log('[useActiveCoffees] Response status:', response.status);
|
console.log('[useActiveCoffees] Response for', url, response.status, contentType);
|
||||||
console.log('[useActiveCoffees] Response content-type:', contentType);
|
|
||||||
|
|
||||||
if (!response.ok || !contentType.includes('application/json')) {
|
if (!response.ok || !contentType.includes('application/json')) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200));
|
lastError = `Request failed: ${response.status} ${text.slice(0, 160)}`;
|
||||||
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`);
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(lastError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
console.log('[useActiveCoffees] Raw JSON response:', json);
|
|
||||||
|
|
||||||
const data: ActiveCoffee[] =
|
const data: ActiveCoffee[] =
|
||||||
Array.isArray(json?.data) ? json.data :
|
Array.isArray(json?.data) ? json.data :
|
||||||
Array.isArray(json) ? json :
|
Array.isArray(json) ? json :
|
||||||
[]
|
[];
|
||||||
console.log('[useActiveCoffees] Parsed coffee data:', data);
|
|
||||||
|
|
||||||
const mapped: CoffeeItem[] = data
|
const mapped: CoffeeItem[] = data
|
||||||
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
||||||
.map((coffee) => {
|
.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 {
|
return {
|
||||||
id: String(coffee.id),
|
id: String(coffee.id),
|
||||||
name: coffee.title || `Coffee ${coffee.id}`,
|
name: coffee.title || `Coffee ${coffee.id}`,
|
||||||
description: coffee.description || '',
|
description: coffee.description || '',
|
||||||
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
||||||
image: coffee.pictureUrl || '',
|
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) => {
|
.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');
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) */}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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 = {
|
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))
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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'
|
'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>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<span className="text-xs uppercase text-gray-500">Link</span>
|
<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 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}
|
{linkPreview}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user