feat: Implement Create Subscription page and Coffee management hooks
This commit is contained in:
parent
6f8573fe16
commit
ea1cba42bd
180
src/app/admin/subscriptions/createSubscription/page.tsx
Normal file
180
src/app/admin/subscriptions/createSubscription/page.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PageLayout from '../../../components/PageLayout';
|
||||
import useCoffeeManagement from '../hooks/useCoffeeManagement';
|
||||
import { PhotoIcon } from '@heroicons/react/24/solid';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function CreateSubscriptionPage() {
|
||||
const { createProduct } = useCoffeeManagement();
|
||||
const router = useRouter();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [price, setPrice] = useState(0);
|
||||
const [state, setState] = useState<'available'|'unavailable'>('available');
|
||||
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
||||
const [currency, setCurrency] = useState('EUR');
|
||||
const [taxRate, setTaxRate] = useState<number | undefined>(undefined);
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
const [billingInterval, setBillingInterval] = useState<'day'|'week'|'month'|'year'|''>('');
|
||||
const [intervalCount, setIntervalCount] = useState<number | undefined>(undefined);
|
||||
const [sku, setSku] = useState<string>('');
|
||||
const [slug, setSlug] = useState<string>('');
|
||||
|
||||
const onCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const normalizedIntervalCount = billingInterval ? (intervalCount && intervalCount > 0 ? intervalCount : 1) : undefined;
|
||||
await createProduct({
|
||||
title,
|
||||
description,
|
||||
quantity,
|
||||
price,
|
||||
currency,
|
||||
tax_rate: taxRate,
|
||||
is_featured: isFeatured,
|
||||
billing_interval: billingInterval || undefined,
|
||||
interval_count: normalizedIntervalCount,
|
||||
sku: sku || undefined,
|
||||
slug: slug || undefined,
|
||||
state: state === 'available',
|
||||
pictureFile
|
||||
});
|
||||
router.push('/admin/subscriptions');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to create');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Subscription</h1>
|
||||
<p className="text-sm sm:text-base text-gray-700 mt-2">Add a new product or subscription plan.</p>
|
||||
</div>
|
||||
<Link href="/admin/subscriptions" className="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
||||
Back to list
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<form onSubmit={onCreate} className="space-y-8">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-900">Title</label>
|
||||
<input id="title" name="title" required className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="quantity" className="block text-sm font-medium text-gray-900">Quantity</label>
|
||||
<input id="quantity" name="quantity" required min={1} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="Quantity" type="number" value={quantity} onChange={e => setQuantity(Number(e.target.value))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-900">Price</label>
|
||||
<input id="price" name="price" required min={0.01} step={0.01} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="Price" type="number" value={price} onChange={e => setPrice(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="currency" className="block text-sm font-medium text-gray-900">Currency (e.g., EUR)</label>
|
||||
<input id="currency" name="currency" required maxLength={3} pattern="[A-Za-z]{3}" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="EUR" value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tax_rate" className="block text-sm font-medium text-gray-900">Tax rate (%)</label>
|
||||
<input id="tax_rate" name="tax_rate" min={0} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="e.g. 19" type="number" step="0.01" value={taxRate ?? ''} onChange={e => setTaxRate(e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
||||
<label htmlFor="featured" className="text-sm font-medium text-gray-900">Featured</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="billing_interval" className="block text-sm font-medium text-gray-900">Billing interval</label>
|
||||
<select id="billing_interval" name="billing_interval" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black" value={billingInterval} onChange={e => {
|
||||
const v = e.target.value as any;
|
||||
setBillingInterval(v);
|
||||
if (v) {
|
||||
setIntervalCount(c => (c && c > 0 ? c : 1));
|
||||
} else {
|
||||
setIntervalCount(undefined);
|
||||
}
|
||||
}}>
|
||||
<option value="">One-time or N/A</option>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="year">Year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="interval_count" className="block text-sm font-medium text-gray-900">Interval count</label>
|
||||
<input id="interval_count" name="interval_count" min={1} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400 disabled:bg-gray-100 disabled:text-gray-500" placeholder="e.g. 6 for 6 months (defaults to 1)" type="number" value={intervalCount ?? ''} onChange={e => setIntervalCount(e.target.value === '' ? undefined : Number(e.target.value))} disabled={!billingInterval} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="sku" className="block text-sm font-medium text-gray-900">SKU (optional)</label>
|
||||
<input id="sku" name="sku" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="SKU" value={sku} onChange={e => setSku(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-900">Slug (optional)</label>
|
||||
<input id="slug" name="slug" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" placeholder="slug" value={slug} onChange={e => setSlug(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="availability" className="block text-sm font-medium text-gray-900">Availability</label>
|
||||
<select id="availability" name="availability" required className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black" value={state} onChange={e => setState(e.target.value as any)}>
|
||||
<option value="available">Available</option>
|
||||
<option value="unavailable">Unavailable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-900">Description</label>
|
||||
<textarea id="description" name="description" required className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2.5 text-black placeholder:text-gray-400" rows={3} placeholder="Describe the product" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-gray-600">Shown to users in the shop and checkout.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900">Picture</label>
|
||||
<div className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-500" />
|
||||
<div className="mt-4 flex text-sm text-gray-700">
|
||||
<label htmlFor="file-upload" className="relative cursor-pointer rounded-md bg-white font-medium text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 hover:text-indigo-500 px-2 py-1 ring-1 ring-gray-200">
|
||||
<span>Upload a file</span>
|
||||
<input id="file-upload" name="file-upload" type="file" accept="image/*" className="sr-only" onChange={e => setPictureFile(e.target.files?.[0])} />
|
||||
</label>
|
||||
<p className="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">PNG, JPG up to 10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-x-3">
|
||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="inline-flex justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
169
src/app/admin/subscriptions/hooks/useCoffeeManagement.ts
Normal file
169
src/app/admin/subscriptions/hooks/useCoffeeManagement.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { useCallback } from 'react';
|
||||
import useAuthStore from '../../../store/authStore';
|
||||
|
||||
export type CoffeeItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
currency?: string;
|
||||
tax_rate?: number;
|
||||
is_featured?: boolean;
|
||||
billing_interval?: 'day'|'week'|'month'|'year'|null;
|
||||
interval_count?: number|null;
|
||||
sku?: string|null;
|
||||
slug?: string|null;
|
||||
object_storage_id?: string|null;
|
||||
original_filename?: string|null;
|
||||
state: boolean;
|
||||
pictureUrl?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
function isFormData(body: any): body is FormData {
|
||||
return typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
}
|
||||
|
||||
export default function useCoffeeManagement() {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||
const getState = useAuthStore.getState;
|
||||
|
||||
const authorizedFetch = useCallback(
|
||||
async <T = any>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
responseType: 'json' | 'text' | 'blob' = 'json'
|
||||
): Promise<T> => {
|
||||
let token = getState().accessToken;
|
||||
if (!token) {
|
||||
const ok = await getState().refreshAuthToken();
|
||||
if (ok) token = getState().accessToken;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers as Record<string, string> || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (responseType === 'blob') return (await res.blob()) as unknown as T;
|
||||
if (responseType === 'text') return (await res.text()) as unknown as T;
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text) as T; } catch { return {} as T; }
|
||||
},
|
||||
[base]
|
||||
);
|
||||
|
||||
const listProducts = useCallback(async (): Promise<CoffeeItem[]> => {
|
||||
const data = await authorizedFetch<any[]>('/api/admin/coffee', { method: 'GET' });
|
||||
if (!Array.isArray(data)) return [];
|
||||
// Normalize numeric fields in case API returns strings (e.g., MySQL DECIMAL)
|
||||
return data.map((r: any) => ({
|
||||
...r,
|
||||
id: Number(r.id),
|
||||
quantity: r.quantity != null ? Number(r.quantity) : 0,
|
||||
price: r.price != null && r.price !== '' ? Number(r.price) : 0,
|
||||
tax_rate: r.tax_rate != null && r.tax_rate !== '' ? Number(r.tax_rate) : undefined,
|
||||
interval_count: r.interval_count != null && r.interval_count !== '' ? Number(r.interval_count) : null,
|
||||
state: !!r.state,
|
||||
})) as CoffeeItem[];
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const createProduct = useCallback(async (payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
currency?: string;
|
||||
tax_rate?: number;
|
||||
is_featured?: boolean;
|
||||
billing_interval?: 'day'|'week'|'month'|'year';
|
||||
interval_count?: number;
|
||||
sku?: string;
|
||||
slug?: string;
|
||||
state?: boolean;
|
||||
pictureFile?: File;
|
||||
}): Promise<CoffeeItem> => {
|
||||
const fd = new FormData();
|
||||
fd.append('title', payload.title);
|
||||
fd.append('description', payload.description);
|
||||
fd.append('quantity', String(payload.quantity));
|
||||
fd.append('price', String(payload.price));
|
||||
if (payload.currency) fd.append('currency', payload.currency);
|
||||
if (typeof payload.tax_rate === 'number') fd.append('tax_rate', String(payload.tax_rate));
|
||||
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
|
||||
if (payload.billing_interval) fd.append('billing_interval', payload.billing_interval);
|
||||
if (typeof payload.interval_count === 'number') fd.append('interval_count', String(payload.interval_count));
|
||||
if (payload.sku) fd.append('sku', payload.sku);
|
||||
if (payload.slug) fd.append('slug', payload.slug);
|
||||
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
|
||||
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
||||
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const updateProduct = useCallback(async (id: number, payload: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
tax_rate: number;
|
||||
is_featured: boolean;
|
||||
billing_interval: 'day'|'week'|'month'|'year';
|
||||
interval_count: number;
|
||||
sku: string;
|
||||
slug: string;
|
||||
state: boolean;
|
||||
pictureFile: File;
|
||||
}>): Promise<CoffeeItem> => {
|
||||
const fd = new FormData();
|
||||
if (payload.title !== undefined) fd.append('title', String(payload.title));
|
||||
if (payload.description !== undefined) fd.append('description', String(payload.description));
|
||||
if (payload.quantity !== undefined) fd.append('quantity', String(payload.quantity));
|
||||
if (payload.price !== undefined) fd.append('price', String(payload.price));
|
||||
if (payload.currency !== undefined) fd.append('currency', payload.currency);
|
||||
if (payload.tax_rate !== undefined) fd.append('tax_rate', String(payload.tax_rate));
|
||||
if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured));
|
||||
if (payload.billing_interval !== undefined) fd.append('billing_interval', payload.billing_interval);
|
||||
if (payload.interval_count !== undefined) fd.append('interval_count', String(payload.interval_count));
|
||||
if (payload.sku !== undefined) fd.append('sku', payload.sku);
|
||||
if (payload.slug !== undefined) fd.append('slug', payload.slug);
|
||||
if (payload.state !== undefined) fd.append('state', String(payload.state));
|
||||
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
||||
return authorizedFetch<CoffeeItem>(`/api/admin/coffee/${id}`, { method: 'PUT', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const setProductState = useCallback(async (id: number, state: boolean): Promise<CoffeeItem> => {
|
||||
return authorizedFetch<CoffeeItem>(`/api/admin/coffee/${id}/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state })
|
||||
});
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const deleteProduct = useCallback(async (id: number): Promise<{success?: boolean}> => {
|
||||
return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
return {
|
||||
listProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
setProductState,
|
||||
deleteProduct,
|
||||
};
|
||||
}
|
||||
104
src/app/admin/subscriptions/page.tsx
Normal file
104
src/app/admin/subscriptions/page.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
|
||||
|
||||
export default function AdminSubscriptionsPage() {
|
||||
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
|
||||
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Subscription Products</h1>
|
||||
<p className="text-sm sm:text-base text-gray-700 mt-2">Manage all products and subscription plans.</p>
|
||||
</div>
|
||||
<Link href="/admin/subscriptions/createSubscription" className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||
Create Subscription
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{loading && (
|
||||
<div className="col-span-full text-sm text-gray-700">Loading…</div>
|
||||
)}
|
||||
{!loading && items.map(item => (
|
||||
<div key={item.id} className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{item.title}</h3>
|
||||
{availabilityBadge(!!item.state)}
|
||||
</div>
|
||||
{item.pictureUrl && (
|
||||
<img src={item.pictureUrl} alt={item.title} className="mt-3 w-full h-40 object-cover rounded-md ring-1 ring-gray-200" />
|
||||
)}
|
||||
<p className="mt-3 text-sm text-gray-800 line-clamp-4">{item.description}</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">Qty</dt>
|
||||
<dd className="font-medium text-gray-900">{item.quantity}</dd>
|
||||
</div>
|
||||
<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)}
|
||||
{Number.isFinite(Number(item.tax_rate)) ? ` + ${Number(item.tax_rate)}%` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
{item.billing_interval && item.interval_count ? (
|
||||
<div className="col-span-2 text-gray-600">
|
||||
<span className="text-xs">Every {item.interval_count} {item.billing_interval}{item.interval_count > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="inline-flex items-center rounded-md bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100" onClick={async () => { await setProductState(item.id, !item.state); await load(); }}>
|
||||
{item.state ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button className="inline-flex items-center rounded-md bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-200 hover:bg-red-100" onClick={async () => { await deleteProduct(item.id); await load(); }}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -46,6 +46,12 @@ export const API_ENDPOINTS = {
|
||||
ADMIN_UPDATE_USER_VERIFICATION: '/api/admin/update-verification/:id',
|
||||
ADMIN_UPDATE_USER_PROFILE: '/api/admin/update-user-profile/:id',
|
||||
ADMIN_UPDATE_USER_STATUS: '/api/admin/update-user-status/:id',
|
||||
// Coffee products (admin)
|
||||
ADMIN_COFFEE_LIST: '/api/admin/coffee',
|
||||
ADMIN_COFFEE_CREATE: '/api/admin/coffee',
|
||||
ADMIN_COFFEE_UPDATE: '/api/admin/coffee/:id',
|
||||
ADMIN_COFFEE_SET_STATE: '/api/admin/coffee/:id/state',
|
||||
ADMIN_COFFEE_DELETE: '/api/admin/coffee/:id',
|
||||
}
|
||||
|
||||
// API Helper Functions
|
||||
@ -344,6 +350,94 @@ export class AdminAPI {
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Coffee products (admin)
|
||||
static async listCoffee(token: string) {
|
||||
const response = await ApiClient.get(API_ENDPOINTS.ADMIN_COFFEE_LIST, token)
|
||||
if (!response.ok) throw new Error('Failed to list products')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
static async createCoffee(token: string, data: { title: string; description: string; quantity: number; price: number; currency?: string; tax_rate?: number; is_featured?: boolean; billing_interval?: 'day'|'week'|'month'|'year'; interval_count?: number; sku?: string; slug?: string; state?: boolean; pictureFile?: File }) {
|
||||
if (data.pictureFile) {
|
||||
const fd = new FormData()
|
||||
fd.append('title', data.title)
|
||||
fd.append('description', data.description)
|
||||
fd.append('quantity', String(data.quantity))
|
||||
fd.append('price', String(data.price))
|
||||
if (data.currency) fd.append('currency', data.currency)
|
||||
if (data.tax_rate !== undefined) fd.append('tax_rate', String(data.tax_rate))
|
||||
if (data.is_featured !== undefined) fd.append('is_featured', String(!!data.is_featured))
|
||||
if (data.billing_interval) fd.append('billing_interval', data.billing_interval)
|
||||
if (data.interval_count !== undefined) fd.append('interval_count', String(data.interval_count))
|
||||
if (data.sku) fd.append('sku', data.sku)
|
||||
if (data.slug) fd.append('slug', data.slug)
|
||||
if (data.state !== undefined) fd.append('state', String(!!data.state))
|
||||
fd.append('picture', data.pictureFile)
|
||||
const resp = await ApiClient.postFormData(API_ENDPOINTS.ADMIN_COFFEE_CREATE, fd, token)
|
||||
if (!resp.ok) throw new Error('Failed to create product')
|
||||
return resp.json()
|
||||
} else {
|
||||
const resp = await ApiClient.post(API_ENDPOINTS.ADMIN_COFFEE_CREATE, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
quantity: data.quantity,
|
||||
price: data.price,
|
||||
currency: data.currency,
|
||||
tax_rate: data.tax_rate,
|
||||
is_featured: data.is_featured,
|
||||
billing_interval: data.billing_interval,
|
||||
interval_count: data.interval_count,
|
||||
sku: data.sku,
|
||||
slug: data.slug,
|
||||
state: data.state,
|
||||
}, token)
|
||||
if (!resp.ok) throw new Error('Failed to create product')
|
||||
return resp.json()
|
||||
}
|
||||
}
|
||||
|
||||
static async updateCoffee(token: string, id: number, data: { title?: string; description?: string; quantity?: number; price?: number; currency?: string; tax_rate?: number; is_featured?: boolean; billing_interval?: 'day'|'week'|'month'|'year'; interval_count?: number; sku?: string; slug?: string; state?: boolean; pictureFile?: File }) {
|
||||
if (data.pictureFile) {
|
||||
const fd = new FormData()
|
||||
if (data.title !== undefined) fd.append('title', data.title)
|
||||
if (data.description !== undefined) fd.append('description', data.description)
|
||||
if (data.quantity !== undefined) fd.append('quantity', String(data.quantity))
|
||||
if (data.price !== undefined) fd.append('price', String(data.price))
|
||||
if (data.currency !== undefined) fd.append('currency', data.currency)
|
||||
if (data.tax_rate !== undefined) fd.append('tax_rate', String(data.tax_rate))
|
||||
if (data.is_featured !== undefined) fd.append('is_featured', String(!!data.is_featured))
|
||||
if (data.billing_interval !== undefined) fd.append('billing_interval', data.billing_interval)
|
||||
if (data.interval_count !== undefined) fd.append('interval_count', String(data.interval_count))
|
||||
if (data.sku !== undefined) fd.append('sku', data.sku)
|
||||
if (data.slug !== undefined) fd.append('slug', data.slug)
|
||||
if (data.state !== undefined) fd.append('state', String(!!data.state))
|
||||
fd.append('picture', data.pictureFile)
|
||||
const endpoint = API_ENDPOINTS.ADMIN_COFFEE_UPDATE.replace(':id', String(id))
|
||||
const resp = await ApiClient.postFormData(endpoint, fd, token)
|
||||
if (!resp.ok) throw new Error('Failed to update product')
|
||||
return resp.json()
|
||||
} else {
|
||||
const endpoint = API_ENDPOINTS.ADMIN_COFFEE_UPDATE.replace(':id', String(id))
|
||||
const resp = await ApiClient.put(endpoint, data, token)
|
||||
if (!resp.ok) throw new Error('Failed to update product')
|
||||
return resp.json()
|
||||
}
|
||||
}
|
||||
|
||||
static async setCoffeeState(token: string, id: number, state: boolean) {
|
||||
const endpoint = API_ENDPOINTS.ADMIN_COFFEE_SET_STATE.replace(':id', String(id))
|
||||
const resp = await ApiClient.patch(endpoint, { state }, token)
|
||||
if (!resp.ok) throw new Error('Failed to set state')
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
static async deleteCoffee(token: string, id: number) {
|
||||
const endpoint = API_ENDPOINTS.ADMIN_COFFEE_DELETE.replace(':id', String(id))
|
||||
const resp = await ApiClient.delete(endpoint, token)
|
||||
if (!resp.ok) throw new Error('Failed to delete product')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Response Types
|
||||
|
||||
Loading…
Reference in New Issue
Block a user