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:
dwindown
2025-12-24 11:42:20 +07:00
parent 4b8765885b
commit fb24e77e42
15 changed files with 779 additions and 149 deletions

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

View File

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

View File

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

View File

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