345 lines
16 KiB
TypeScript
345 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 { useTranslation } from '../../i18n/useTranslation';
|
||
|
||
import useCoffeeShippingFees, {
|
||
CoffeeShippingFee,
|
||
CoffeeShippingFeePieceCount,
|
||
} from './hooks/useCoffeeShippingFees';
|
||
|
||
export default function AdminSubscriptionsPage() {
|
||
const { t } = useTranslation();
|
||
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 contentClassName="flex-1 relative w-full">
|
||
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||
{/* Header */}
|
||
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||
Admin
|
||
</div>
|
||
<h1 className="mt-3 text-2xl font-bold text-slate-900 tracking-tight">Coffees</h1>
|
||
<p className="text-sm text-slate-500 mt-1">{t('autofix.k875f4054')}</p>
|
||
</div>
|
||
<Link
|
||
href="/admin/subscriptions/createSubscription"
|
||
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white px-4 py-2.5 text-sm font-semibold shadow transition self-start sm:self-auto"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
{t('autofix.kaa30f0cd')}
|
||
</Link>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||
)}
|
||
|
||
{/* Shipping Fees */}
|
||
<section className="rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] p-6">
|
||
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-slate-900">Shipping Fees (ABO)</h2>
|
||
<p className="mt-1 text-sm text-slate-500">{t('autofix.k027bd82e')}</p>
|
||
</div>
|
||
<button
|
||
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 shadow-sm transition"
|
||
onClick={loadShippingFees}
|
||
disabled={shippingFeesLoading}
|
||
>{shippingFeesLoading ? t('autofix.k14a4b43e') : 'Refresh'}</button>
|
||
</div>
|
||
|
||
{shippingFeesError && (
|
||
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{shippingFeesError}</div>
|
||
)}
|
||
|
||
<div className="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-2xl border border-slate-200 bg-white/80 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-slate-900 break-words">{pieceCount} pieces</div>
|
||
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
|
||
<div className="text-xs text-slate-500 break-words">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-rose-700">{fieldError}</div>
|
||
) : (
|
||
<div className="mt-2 text-xs text-slate-400">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-slate-400">€</span>
|
||
<input
|
||
inputMode="decimal"
|
||
className={`w-40 rounded-xl border px-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent ${
|
||
fieldError ? 'border-rose-300' : 'border-slate-200'
|
||
}`}
|
||
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-xl px-4 py-2 text-sm font-semibold shadow-sm transition ${
|
||
saving
|
||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||
: 'bg-slate-900 text-white hover:bg-slate-800'
|
||
}`}
|
||
disabled={saving}
|
||
onClick={() => saveShippingFee(pieceCount)}
|
||
>{saving ? t('autofix.kac6cedc7') : 'Save'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||
{loading && (
|
||
<div className="col-span-full text-sm text-slate-500 py-4">{t('autofix.k832387c5')}</div>
|
||
)}
|
||
{!loading && items.map(item => (
|
||
<div key={item.id} className="rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] p-5 flex flex-col gap-3 hover:shadow-[0_28px_72px_-38px_rgba(15,23,42,0.38)] transition">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<h3 className="text-base font-semibold text-slate-900 leading-snug">{item.title}</h3>
|
||
<span className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${item.state ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200'}`}>
|
||
{item.state ? 'Available' : 'Unavailable'}
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-36 rounded-xl border border-slate-100 overflow-hidden flex items-center justify-center bg-slate-50">
|
||
{item.pictureUrl ? (
|
||
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
|
||
) : (
|
||
<PhotoIcon className="w-10 h-10 text-slate-200" />
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-slate-600 line-clamp-3">{item.description}</p>
|
||
<dl className="grid grid-cols-1 gap-y-1 text-sm">
|
||
<div>
|
||
<dt className="text-xs text-slate-400">Price</dt>
|
||
<dd className="font-semibold text-slate-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-xs text-slate-400">
|
||
Subscription: {item.billing_interval} × {item.interval_count}
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
<div className="mt-auto flex flex-wrap gap-2 pt-1">
|
||
<button
|
||
className={`inline-flex items-center rounded-xl px-3 py-1.5 text-xs font-semibold transition ${
|
||
item.state
|
||
? 'bg-amber-50 text-amber-700 hover:bg-amber-100'
|
||
: 'bg-slate-900 text-white hover:bg-slate-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-xl bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-200 transition"
|
||
>
|
||
Edit
|
||
</Link>
|
||
<button
|
||
className="inline-flex items-center rounded-xl bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition"
|
||
onClick={() => setDeleteTarget(item)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!loading && !items.length && (
|
||
<div className="col-span-full py-10 text-center text-sm text-slate-400">{t('autofix.k8c75468c')}</div>
|
||
)}
|
||
</div>
|
||
{/* Confirm Delete Modal */}
|
||
{deleteTarget && (
|
||
<div className="fixed inset-0 z-50">
|
||
<div className="absolute inset-0 bg-black/35 backdrop-blur-sm" onClick={() => setDeleteTarget(null)} />
|
||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]">
|
||
<div className="px-6 pt-6">
|
||
<h3 className="text-lg font-semibold text-slate-900">{t('autofix.kddd4832f')}</h3>
|
||
<p className="mt-2 text-sm text-slate-600">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-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition"
|
||
onClick={() => setDeleteTarget(null)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
className="inline-flex items-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-500 shadow transition"
|
||
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|