Implement post-implementation refinements
Features implemented: 1. Expired QRIS order handling with dual-path approach - Product orders: QR regeneration button - Consulting orders: Immediate cancellation with slot release 2. Standardized status badge wording to "Pending" 3. Fixed TypeScript error in MemberDashboard 4. Dynamic badge colors from branding settings 5. Dynamic page title from branding settings 6. Logo/favicon file upload with auto-delete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
58
src/components/UnpaidOrderAlert.tsx
Normal file
58
src/components/UnpaidOrderAlert.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface UnpaidOrderAlertProps {
|
||||
orderId: string;
|
||||
expiresAt: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export function UnpaidOrderAlert({ orderId, expiresAt }: UnpaidOrderAlertProps) {
|
||||
// Non-dismissable alert - NO onDismiss prop
|
||||
// Alert will auto-hide when QR expires via Dashboard logic
|
||||
|
||||
const formatExpiryTime = (isoString: string) => {
|
||||
try {
|
||||
return new Date(isoString).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 border-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-orange-100 p-2 rounded-full flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-orange-900 mb-1 flex items-center gap-2">
|
||||
Pembayaran Belum Selesai
|
||||
<span className="text-xs bg-orange-200 text-orange-800 px-2 py-0.5 rounded">
|
||||
Segera
|
||||
</span>
|
||||
</h4>
|
||||
<AlertDescription className="text-orange-700">
|
||||
Anda memiliki pesanan konsultasi yang menunggu pembayaran. QRIS kode akan kedaluwarsa pada{" "}
|
||||
<strong>{formatExpiryTime(expiresAt)}</strong>.
|
||||
</AlertDescription>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="mt-3 bg-orange-600 hover:bg-orange-700 text-white shadow-md"
|
||||
>
|
||||
<Link to={`/orders/${orderId}`}>
|
||||
Lihat & Bayar Sekarang →
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Palette, Image, Mail, Home, Plus, Trash2 } from 'lucide-react';
|
||||
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X } from 'lucide-react';
|
||||
|
||||
interface HomepageFeature {
|
||||
icon: string;
|
||||
@@ -51,6 +51,11 @@ export function BrandingTab() {
|
||||
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const faviconInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -150,6 +155,108 @@ export function BrandingTab() {
|
||||
setSettings({ ...settings, homepage_features: newFeatures });
|
||||
};
|
||||
|
||||
// Handle logo upload with auto-delete
|
||||
const handleLogoUpload = async (file: File) => {
|
||||
setUploadingLogo(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const filePath = `brand-assets/logo/logo-current.${fileExt}`;
|
||||
|
||||
// Step 1: Delete old logo if exists
|
||||
const { data: existingFiles } = await supabase.storage
|
||||
.from('content')
|
||||
.list('brand-assets/logo/');
|
||||
|
||||
if (existingFiles?.length > 0) {
|
||||
const oldFile = existingFiles.find(f => f.name.startsWith('logo-current'));
|
||||
if (oldFile) {
|
||||
await supabase.storage.from('content').remove([`brand-assets/logo/${oldFile.name}`]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Upload new logo
|
||||
const { data, error } = await supabase.storage
|
||||
.from('content')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Step 3: Get public URL and update settings
|
||||
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||
setSettings({ ...settings, brand_logo_url: urlData.publicUrl });
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Logo berhasil diupload' });
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
toast({ title: 'Error', description: 'Gagal upload logo', variant: 'destructive' });
|
||||
} finally {
|
||||
setUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle favicon upload with auto-delete
|
||||
const handleFaviconUpload = async (file: File) => {
|
||||
setUploadingFavicon(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const filePath = `brand-assets/favicon/favicon-current.${fileExt}`;
|
||||
|
||||
// Step 1: Delete old favicon if exists
|
||||
const { data: existingFiles } = await supabase.storage
|
||||
.from('content')
|
||||
.list('brand-assets/favicon/');
|
||||
|
||||
if (existingFiles?.length > 0) {
|
||||
const oldFile = existingFiles.find(f => f.name.startsWith('favicon-current'));
|
||||
if (oldFile) {
|
||||
await supabase.storage.from('content').remove([`brand-assets/favicon/${oldFile.name}`]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Upload new favicon
|
||||
const { data, error } = await supabase.storage
|
||||
.from('content')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Step 3: Get public URL and update settings
|
||||
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||
setSettings({ ...settings, brand_favicon_url: urlData.publicUrl });
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Favicon berhasil diupload' });
|
||||
} catch (error) {
|
||||
console.error('Favicon upload error:', error);
|
||||
toast({ title: 'Error', description: 'Gagal upload favicon', variant: 'destructive' });
|
||||
} finally {
|
||||
setUploadingFavicon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleLogoUpload(file);
|
||||
};
|
||||
|
||||
const handleFaviconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFaviconUpload(file);
|
||||
};
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
setSettings({ ...settings, brand_logo_url: '' });
|
||||
};
|
||||
|
||||
const handleRemoveFavicon = () => {
|
||||
setSettings({ ...settings, brand_favicon_url: '' });
|
||||
};
|
||||
|
||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||
|
||||
return (
|
||||
@@ -195,47 +302,141 @@ export function BrandingTab() {
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Logo Utama (URL)
|
||||
Logo Utama
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_logo_url}
|
||||
onChange={(e) => setSettings({ ...settings, brand_logo_url: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="border-2"
|
||||
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
||||
onChange={handleLogoSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{settings.brand_logo_url && (
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<img
|
||||
src={settings.brand_logo_url}
|
||||
alt="Logo preview"
|
||||
className="h-12 object-contain"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{settings.brand_logo_url ? (
|
||||
<div className="relative">
|
||||
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||
<img
|
||||
src={settings.brand_logo_url}
|
||||
alt="Logo preview"
|
||||
className="h-16 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
toast({ title: 'Error', description: 'Gagal memuat logo', variant: 'destructive' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
disabled={uploadingLogo}
|
||||
className="flex-1 border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingLogo ? 'Mengupload...' : 'Ganti'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveLogo}
|
||||
disabled={uploadingLogo}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
disabled={uploadingLogo}
|
||||
className="w-full border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingLogo ? 'Mengupload...' : 'Upload Logo'}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PNG, SVG, JPG, atau WebP. Maks 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Favicon (URL)
|
||||
Favicon
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_favicon_url}
|
||||
onChange={(e) => setSettings({ ...settings, brand_favicon_url: e.target.value })}
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
className="border-2"
|
||||
|
||||
<input
|
||||
ref={faviconInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,image/jpeg,image/x-icon"
|
||||
onChange={handleFaviconSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{settings.brand_favicon_url && (
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<img
|
||||
src={settings.brand_favicon_url}
|
||||
alt="Favicon preview"
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{settings.brand_favicon_url ? (
|
||||
<div className="relative">
|
||||
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||
<img
|
||||
src={settings.brand_favicon_url}
|
||||
alt="Favicon preview"
|
||||
className="h-12 w-12 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
toast({ title: 'Error', description: 'Gagal memuat favicon', variant: 'destructive' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => faviconInputRef.current?.click()}
|
||||
disabled={uploadingFavicon}
|
||||
className="flex-1 border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingFavicon ? 'Mengupload...' : 'Ganti'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveFavicon}
|
||||
disabled={uploadingFavicon}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => faviconInputRef.current?.click()}
|
||||
disabled={uploadingFavicon}
|
||||
className="w-full border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingFavicon ? 'Mengupload...' : 'Upload Favicon'}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PNG, SVG, JPG, atau ICO. Maks 1MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return <Badge className="bg-accent">Selesai</Badge>;
|
||||
return <Badge className="bg-brand-accent text-white">Selesai</Badge>;
|
||||
case 'confirmed':
|
||||
return <Badge className="bg-primary">Terkonfirmasi</Badge>;
|
||||
case 'pending_payment':
|
||||
return <Badge className="bg-secondary">Menunggu Pembayaran</Badge>;
|
||||
return <Badge className="bg-secondary">Pending</Badge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="destructive">Dibatalkan</Badge>;
|
||||
default:
|
||||
|
||||
@@ -8,7 +8,7 @@ const badgeVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 hover:text-white",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
|
||||
Reference in New Issue
Block a user