profit-planet-frontend/src/app/admin/subscriptions/page.tsx
2026-03-15 18:34:19 +01:00

343 lines
16 KiB
TypeScript

"use client";
import React, { useEffect, useState } from 'react';
import { PhotoIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import PageLayout from '../../components/PageLayout';
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
import useCoffeeShippingFees, {
CoffeeShippingFee,
CoffeeShippingFeePieceCount,
} from './hooks/useCoffeeShippingFees';
export default function AdminSubscriptionsPage() {
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
const { listShippingFees, updateShippingFee } = useCoffeeShippingFees();
const formatPriceDraft = (price: number) => {
if (!Number.isFinite(price)) return '';
return price.toFixed(2).replace('.', ',');
};
const parsePriceDraft = (raw: string) => {
const normalized = (raw ?? '')
.trim()
.replace(/\s+/g, '')
.replace(/,/g, '.');
if (!normalized) return NaN;
return Number(normalized);
};
const [shippingFees, setShippingFees] = useState<CoffeeShippingFee[]>([]);
const [shippingFeesLoading, setShippingFeesLoading] = useState(false);
const [shippingFeesError, setShippingFeesError] = useState<string | null>(null);
const [shippingFeeDraft, setShippingFeeDraft] = useState<Record<CoffeeShippingFeePieceCount, string>>({
60: '',
120: '',
});
const [shippingFeeFieldError, setShippingFeeFieldError] = useState<Record<CoffeeShippingFeePieceCount, string | null>>({
60: null,
120: null,
});
const [shippingFeeSaving, setShippingFeeSaving] = useState<Record<CoffeeShippingFeePieceCount, boolean>>({
60: false,
120: false,
});
const [shippingFeeSavedAt, setShippingFeeSavedAt] = useState<Record<CoffeeShippingFeePieceCount, number | null>>({
60: null,
120: null,
});
const [items, setItems] = useState<CoffeeItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const data = await listProducts();
setItems(Array.isArray(data) ? data : []);
} catch (e: any) {
setError(e?.message ?? 'Failed to load products');
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
loadShippingFees();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadShippingFees() {
setShippingFeesLoading(true);
setShippingFeesError(null);
try {
const list = await listShippingFees();
setShippingFees(Array.isArray(list) ? list : []);
const findPrice = (pieceCount: CoffeeShippingFeePieceCount) => {
const row = (Array.isArray(list) ? list : []).find((r) => r.pieceCount === pieceCount);
return row ? row.price : 0;
};
setShippingFeeDraft({
60: formatPriceDraft(findPrice(60)),
120: formatPriceDraft(findPrice(120)),
});
setShippingFeeFieldError({ 60: null, 120: null });
} catch (e: any) {
setShippingFeesError(e?.message ?? 'Failed to load shipping fees');
} finally {
setShippingFeesLoading(false);
}
}
const saveShippingFee = async (pieceCount: CoffeeShippingFeePieceCount) => {
if (shippingFeeSaving[pieceCount]) return;
const raw = (shippingFeeDraft[pieceCount] ?? '').trim();
const price = parsePriceDraft(raw);
if (!Number.isFinite(price) || price < 0) {
setShippingFeeFieldError((prev) => ({
...prev,
[pieceCount]: 'Enter a valid price (≥ 0).',
}));
return;
}
setShippingFeeFieldError((prev) => ({ ...prev, [pieceCount]: null }));
setShippingFeeSaving((prev) => ({ ...prev, [pieceCount]: true }));
try {
const updated = await updateShippingFee(pieceCount, price);
setShippingFees((prev) => {
const next = prev.filter((r) => r.pieceCount !== pieceCount);
next.push(updated);
next.sort((a, b) => a.pieceCount - b.pieceCount);
return next;
});
setShippingFeeDraft((prev) => ({ ...prev, [pieceCount]: formatPriceDraft(updated.price) }));
setShippingFeeSavedAt((prev) => ({ ...prev, [pieceCount]: Date.now() }));
} catch (e: any) {
setShippingFeesError(e?.message ?? 'Failed to update shipping fee');
} finally {
setShippingFeeSaving((prev) => ({ ...prev, [pieceCount]: false }));
}
};
const availabilityBadge = (avail: boolean) => (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${avail ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300'}`}>
{avail ? 'Available' : 'Unavailable'}
</span>
);
const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null);
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-6 px-6 rounded-2xl shadow-lg mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Coffees</h1>
<p className="text-lg text-blue-700 mt-2">Manage all coffees.</p>
</div>
<Link
href="/admin/subscriptions/createSubscription"
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
Create Coffee
</Link>
</div>
</header>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div>
)}
{/* Shipping Fees */}
<section className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-blue-900">Shipping Fees (ABO)</h2>
<p className="mt-1 text-sm text-gray-600">Edit the shipping prices for 60 and 120 pieces.</p>
</div>
<button
className="inline-flex items-center rounded-lg bg-gray-50 px-4 py-2 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-100 shadow transition self-start"
onClick={loadShippingFees}
disabled={shippingFeesLoading}
>
{shippingFeesLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{shippingFeesError && (
<div className="mt-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{shippingFeesError}</div>
)}
<div className="mt-5 grid grid-cols-1 gap-4">
{([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => {
const saving = shippingFeeSaving[pieceCount];
const savedAt = shippingFeeSavedAt[pieceCount];
const fieldError = shippingFeeFieldError[pieceCount];
const current = shippingFees.find((r) => r.pieceCount === pieceCount);
const draft = shippingFeeDraft[pieceCount] ?? '';
return (
<div
key={pieceCount}
className="rounded-xl border border-gray-100 bg-white ring-1 ring-inset ring-gray-100 p-4"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-gray-900">{pieceCount} pieces</div>
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
<div className="text-xs text-gray-500">Current: {formatPriceDraft(current.price)}</div>
) : null}
{savedAt ? (
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
Saved
</div>
) : null}
</div>
{fieldError ? (
<div className="mt-2 text-xs text-red-700">{fieldError}</div>
) : (
<div className="mt-2 text-xs text-gray-500">Enter a price in EUR ( 0).</div>
)}
</div>
<div className="flex items-center gap-3">
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500"></span>
<input
inputMode="decimal"
className={`w-40 rounded-lg border px-8 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${
fieldError ? 'border-red-300 ring-1 ring-red-200' : 'border-gray-300'
}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
setShippingFeeDraft((prev) => ({ ...prev, [pieceCount]: v }));
setShippingFeeFieldError((prev) => ({ ...prev, [pieceCount]: null }));
}}
placeholder="0.00"
/>
</div>
<button
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-semibold shadow transition ${
saving
? 'bg-gray-200 text-gray-600 cursor-not-allowed'
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'
}`}
disabled={saving}
onClick={() => saveShippingFee(pieceCount)}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading && (
<div className="col-span-full text-sm text-gray-700">Loading</div>
)}
{!loading && items.map(item => (
<div key={item.id} className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 flex flex-col gap-3 hover:shadow-xl transition">
<div className="flex items-start justify-between gap-3">
<h3 className="text-xl font-semibold text-blue-900">{item.title}</h3>
{availabilityBadge(!!item.state)}
</div>
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50">
{item.pictureUrl ? (
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
) : (
<PhotoIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<p className="mt-3 text-sm text-gray-800 line-clamp-4">{item.description}</p>
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm">
<div>
<dt className="text-gray-500">Price</dt>
<dd className="font-medium text-gray-900">
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
</dd>
</div>
{item.billing_interval && item.interval_count ? (
<div className="text-gray-600">
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
</div>
) : null}
</dl>
<div className="mt-4 flex gap-2">
<button
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition
${item.state
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100'
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'}`}
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
>
{item.state ? 'Disable' : 'Enable'}
</button>
<Link
href={`/admin/subscriptions/edit/${item.id}`}
className="inline-flex items-center rounded-lg bg-indigo-50 px-4 py-2 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 hover:bg-indigo-100 shadow transition"
>
Edit
</Link>
<button
className="inline-flex items-center rounded-lg bg-red-50 px-4 py-2 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-200 hover:bg-red-100 shadow transition"
onClick={() => setDeleteTarget(item)}
>
Delete
</button>
</div>
</div>
))}
{!loading && !items.length && (
<div className="col-span-full py-8 text-center text-sm text-gray-500">No subscriptions found.</div>
)}
</div>
{/* Confirm Delete Modal */}
{deleteTarget && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
<div className="px-6 pt-6">
<h3 className="text-lg font-semibold text-blue-900">Delete coffee?</h3>
<p className="mt-2 text-sm text-gray-700">You are about to delete the coffee {deleteTarget.title}. This action cannot be undone.</p>
</div>
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
<button
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
onClick={() => setDeleteTarget(null)}
>
Cancel
</button>
<button
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</PageLayout>
);
}