feat: enhance coffee management with image upload and preview functionality + change it from "Create Subscriptions" to "Create Coffee"
This commit is contained in:
parent
51c54eb905
commit
c0a1879c95
|
Before Width: | Height: | Size: 838 KiB After Width: | Height: | Size: 838 KiB |
@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import PageLayout from '../../../components/PageLayout';
|
||||
import useCoffeeManagement from '../hooks/useCoffeeManagement';
|
||||
import { PhotoIcon } from '@heroicons/react/24/solid';
|
||||
@ -18,6 +18,7 @@ export default function CreateSubscriptionPage() {
|
||||
const [price, setPrice] = useState(0);
|
||||
const [state, setState] = useState<'available'|'unavailable'>('available');
|
||||
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [currency, setCurrency] = useState('EUR');
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
// Fixed billing defaults (locked: month / 1)
|
||||
@ -43,6 +44,32 @@ export default function CreateSubscriptionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// preview object URL management
|
||||
useEffect(() => {
|
||||
if (pictureFile) {
|
||||
const url = URL.createObjectURL(pictureFile);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [pictureFile]);
|
||||
|
||||
function handleSelectFile(file?: File) {
|
||||
if (!file) return;
|
||||
const allowed = ['image/jpeg','image/png','image/webp'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
setError('Invalid image type. Allowed: JPG, PNG, WebP');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
setError('Image exceeds 10MB limit');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setPictureFile(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
@ -51,8 +78,8 @@ export default function CreateSubscriptionPage() {
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Create Subscription</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Add a new product or subscription plan.</p>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Create Coffee</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Add a new coffee.</p>
|
||||
</div>
|
||||
<Link href="/admin/subscriptions"
|
||||
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"
|
||||
@ -86,22 +113,23 @@ export default function CreateSubscriptionPage() {
|
||||
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
||||
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
|
||||
</div>
|
||||
{/* Fixed Billing (Locked) */}
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-blue-900">Billing</label>
|
||||
<p className="mt-1 text-xs text-gray-600">Fixed monthly billing (interval count = 1). These settings are locked.</p>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<input disabled value={billingInterval} className="w-40 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||
<input disabled value={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||
{/* Subscription Billing (Locked) + Availability */}
|
||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Subscription Billing</label>
|
||||
<p className="mt-1 text-xs text-gray-600">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<input disabled value={billingInterval} className="w-40 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||
<input disabled value={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">Availability</label>
|
||||
<select id="availability" name="availability" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 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>
|
||||
{/* Availability */}
|
||||
<div>
|
||||
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">Availability</label>
|
||||
<select id="availability" name="availability" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 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>
|
||||
|
||||
@ -116,32 +144,41 @@ export default function CreateSubscriptionPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Picture</label>
|
||||
<div
|
||||
className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-blue-300 px-6 py-10 bg-blue-50 cursor-pointer"
|
||||
className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-blue-300 px-6 py-10 bg-blue-50 cursor-pointer relative"
|
||||
onClick={() => document.getElementById('file-upload')?.click()}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files?.[0]) setPictureFile(e.dataTransfer.files[0]);
|
||||
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
||||
}}
|
||||
>
|
||||
<div className="text-center w-full">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-blue-400" />
|
||||
<div className="mt-4 text-sm text-blue-700">
|
||||
<span>Drag and drop an image here</span>
|
||||
{!previewUrl && (
|
||||
<div className="text-center w-full">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-blue-400" />
|
||||
<div className="mt-4 text-sm text-blue-700">
|
||||
<span>Drag and drop an image here</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">PNG, JPG up to 10MB</p>
|
||||
{pictureFile && (
|
||||
<p className="mt-2 text-xs text-blue-900 font-medium">{pictureFile.name}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="Preview" className="absolute inset-0 w-full h-full object-cover rounded-lg" />
|
||||
)}
|
||||
<input
|
||||
id="file-upload"
|
||||
name="file-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => setPictureFile(e.target.files?.[0])}
|
||||
onChange={e => handleSelectFile(e.target.files?.[0])}
|
||||
/>
|
||||
{previewUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); setPictureFile(undefined); }}
|
||||
className="absolute top-2 right-2 bg-white/80 backdrop-blur px-2 py-1 rounded text-xs font-medium text-blue-900 shadow z-10"
|
||||
>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -151,7 +188,7 @@ export default function CreateSubscriptionPage() {
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
|
||||
Create
|
||||
Create Coffee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -27,6 +27,8 @@ export default function EditSubscriptionPage() {
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
const [state, setState] = useState(true);
|
||||
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [removeExistingPicture, setRemoveExistingPicture] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -51,6 +53,7 @@ export default function EditSubscriptionPage() {
|
||||
setCurrency(found.currency || 'EUR');
|
||||
setIsFeatured(!!found.is_featured);
|
||||
setState(!!found.state);
|
||||
setRemoveExistingPicture(false);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message ?? 'Failed to load subscription');
|
||||
@ -81,6 +84,7 @@ export default function EditSubscriptionPage() {
|
||||
is_featured: isFeatured,
|
||||
state,
|
||||
pictureFile,
|
||||
removePicture: removeExistingPicture && !pictureFile ? true : false,
|
||||
});
|
||||
router.push('/admin/subscriptions');
|
||||
} catch (e: any) {
|
||||
@ -88,6 +92,32 @@ export default function EditSubscriptionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pictureFile) {
|
||||
const url = URL.createObjectURL(pictureFile);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [pictureFile]);
|
||||
|
||||
function handleSelectFile(file?: File) {
|
||||
if (!file) return;
|
||||
const allowed = ['image/jpeg','image/png','image/webp'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
setError('Invalid image type. Allowed: JPG, PNG, WebP');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Image exceeds 10MB limit');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setPictureFile(file);
|
||||
setRemoveExistingPicture(false); // selecting new overrides removal flag
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
@ -95,8 +125,8 @@ export default function EditSubscriptionPage() {
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Edit Subscription</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Update details of the subscription product.</p>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Edit Coffee</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Update details of the coffee.</p>
|
||||
</div>
|
||||
<Link href="/admin/subscriptions"
|
||||
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"
|
||||
@ -176,34 +206,48 @@ export default function EditSubscriptionPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Picture (optional)</label>
|
||||
<div
|
||||
className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-blue-300 px-6 py-10 bg-blue-50 cursor-pointer"
|
||||
className="mt-2 flex max-w-xl justify-center rounded-lg border border-dashed border-blue-300 px-6 py-10 bg-blue-50 cursor-pointer relative"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files?.[0]) setPictureFile(e.dataTransfer.files[0]);
|
||||
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
||||
}}
|
||||
>
|
||||
<div className="text-center w-full">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-blue-400" />
|
||||
<div className="mt-4 text-sm text-blue-700">
|
||||
<span>Drag and drop a new image</span>
|
||||
{!previewUrl && !item.pictureUrl && (
|
||||
<div className="text-center w-full">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-blue-400" />
|
||||
<div className="mt-4 text-sm text-blue-700">
|
||||
<span>Drag and drop a new image</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">PNG, JPG up to 10MB</p>
|
||||
{pictureFile && (
|
||||
<p className="mt-2 text-xs text-blue-900 font-medium">{pictureFile.name}</p>
|
||||
)}
|
||||
{!pictureFile && item.pictureUrl && (
|
||||
<img src={item.pictureUrl} alt={item.title} className="mt-4 h-40 w-full object-cover rounded-xl ring-1 ring-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="Preview" className="absolute inset-0 w-full h-full object-cover rounded-lg" />
|
||||
)}
|
||||
{!previewUrl && item.pictureUrl && !removeExistingPicture && (
|
||||
<img src={item.pictureUrl} alt={item.title} className="absolute inset-0 w-full h-full object-cover rounded-lg" />
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => setPictureFile(e.target.files?.[0])}
|
||||
onChange={e => handleSelectFile(e.target.files?.[0])}
|
||||
/>
|
||||
{(previewUrl || item.pictureUrl) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); if (previewUrl) { setPictureFile(undefined); setPreviewUrl(null); } else if (item.pictureUrl) { setRemoveExistingPicture(true); } }}
|
||||
className="absolute top-2 right-2 bg-white/80 backdrop-blur px-2 py-1 rounded text-xs font-medium text-blue-900 shadow z-10"
|
||||
>Remove</button>
|
||||
)}
|
||||
{removeExistingPicture && !previewUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs text-blue-700">Image removed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -108,6 +108,7 @@ export default function useCoffeeManagement() {
|
||||
is_featured: boolean;
|
||||
state: boolean;
|
||||
pictureFile: File;
|
||||
removePicture: boolean;
|
||||
}>): Promise<CoffeeItem> => {
|
||||
const fd = new FormData();
|
||||
if (payload.title !== undefined) fd.append('title', String(payload.title));
|
||||
@ -116,6 +117,7 @@ export default function useCoffeeManagement() {
|
||||
if (payload.currency !== undefined) fd.append('currency', payload.currency);
|
||||
if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured));
|
||||
if (payload.state !== undefined) fd.append('state', String(payload.state));
|
||||
if (payload.removePicture) fd.append('removePicture', 'true');
|
||||
// Keep fixed defaults
|
||||
fd.append('billing_interval', 'month');
|
||||
fd.append('interval_count', '1');
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"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';
|
||||
@ -45,15 +46,15 @@ export default function AdminSubscriptionsPage() {
|
||||
<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">Subscription Products</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Manage all products and subscription plans.</p>
|
||||
<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 Subscription
|
||||
Create Coffee
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
@ -72,9 +73,13 @@ export default function AdminSubscriptionsPage() {
|
||||
<h3 className="text-xl font-semibold text-blue-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-xl ring-1 ring-gray-200" />
|
||||
)}
|
||||
<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>
|
||||
@ -85,7 +90,7 @@ export default function AdminSubscriptionsPage() {
|
||||
</div>
|
||||
{item.billing_interval && item.interval_count ? (
|
||||
<div className="text-gray-600">
|
||||
<span className="text-xs">Billing: {item.billing_interval} (x{item.interval_count})</span>
|
||||
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
@ -125,8 +130,8 @@ export default function AdminSubscriptionsPage() {
|
||||
<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 subscription?</h3>
|
||||
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
||||
<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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user