343 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|