feat: enhance coffee management with image upload and preview functionality + change it from "Create Subscriptions" to "Create Coffee"

This commit is contained in:
seaznCode 2025-11-29 13:50:40 +01:00
parent 51c54eb905
commit c0a1879c95
5 changed files with 145 additions and 57 deletions

View File

Before

Width:  |  Height:  |  Size: 838 KiB

After

Width:  |  Height:  |  Size: 838 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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');

View File

@ -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