profit-planet-frontend/src/app/admin/subscriptions/page.tsx
DeathKaioken 4074ea4eee Bibelbumser
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 23:48:09 +02:00

345 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}