feat: add light and dark mode variants for logo and icon with dynamic theme switching support
This commit is contained in:
parent
dc68c73c2a
commit
098c2a9cf1
@ -29,12 +29,20 @@ public function update(GeneralSettingRequest $request): RedirectResponse
|
||||
$setting = GeneralSetting::create($validated);
|
||||
}
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$setting->addMediaFromRequest('logo')->toMediaCollection('logo');
|
||||
if ($request->hasFile('logo_light')) {
|
||||
$setting->addMediaFromRequest('logo_light')->toMediaCollection('logo_light');
|
||||
}
|
||||
|
||||
if ($request->hasFile('icon')) {
|
||||
$setting->addMediaFromRequest('icon')->toMediaCollection('icon');
|
||||
if ($request->hasFile('logo_dark')) {
|
||||
$setting->addMediaFromRequest('logo_dark')->toMediaCollection('logo_dark');
|
||||
}
|
||||
|
||||
if ($request->hasFile('icon_light')) {
|
||||
$setting->addMediaFromRequest('icon_light')->toMediaCollection('icon_light');
|
||||
}
|
||||
|
||||
if ($request->hasFile('icon_dark')) {
|
||||
$setting->addMediaFromRequest('icon_dark')->toMediaCollection('icon_dark');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Pengaturan berhasil diperbarui');
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\GeneralSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@ -38,6 +39,7 @@ public function share(Request $request): array
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'setting' => GeneralSetting::first(),
|
||||
'auth' => [
|
||||
'user' => $request->user() ? $request->user()->load('profile') : null,
|
||||
'roles' => $request->user() ? $request->user()->getRoleNames() : [],
|
||||
|
||||
@ -30,8 +30,10 @@ public function rules(): array
|
||||
'description' => ['required', 'string'],
|
||||
'address' => ['required', 'string'],
|
||||
'phone' => ['required', 'string', 'max:20'],
|
||||
'logo' => [$settingExists ? 'nullable' : 'required', 'image', 'max:2048'],
|
||||
'icon' => [$settingExists ? 'nullable' : 'required', 'image', 'max:1024'],
|
||||
'logo_light' => [$settingExists ? 'nullable' : 'required', 'image', 'max:2048'],
|
||||
'logo_dark' => ['nullable', 'image', 'max:2048'],
|
||||
'icon_light' => [$settingExists ? 'nullable' : 'required', 'image', 'max:1024'],
|
||||
'icon_dark' => ['nullable', 'image', 'max:1024'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,31 +14,51 @@
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['logo_url', 'icon_url'])]
|
||||
#[Appends(['logo_light_url', 'logo_dark_url', 'icon_light_url', 'icon_dark_url'])]
|
||||
class GeneralSetting extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, InteractsWithMedia, LogsActivity;
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('logo')
|
||||
$this->addMediaCollection('logo_light')
|
||||
->singleFile();
|
||||
|
||||
$this->addMediaCollection('icon')
|
||||
$this->addMediaCollection('logo_dark')
|
||||
->singleFile();
|
||||
|
||||
$this->addMediaCollection('icon_light')
|
||||
->singleFile();
|
||||
|
||||
$this->addMediaCollection('icon_dark')
|
||||
->singleFile();
|
||||
}
|
||||
|
||||
protected function logoUrl(): Attribute
|
||||
protected function logoLightUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('logo') ?: null,
|
||||
get: fn () => $this->getFirstMediaUrl('logo_light') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function iconUrl(): Attribute
|
||||
protected function logoDarkUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('icon') ?: null,
|
||||
get: fn () => $this->getFirstMediaUrl('logo_dark') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function iconLightUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('icon_light') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function iconDarkUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('icon_dark') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,32 @@
|
||||
export default function AppLogoIcon() {
|
||||
return <img src="/assets/logo.webp" alt="Logo" />;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import type { GeneralSetting } from '@/types';
|
||||
|
||||
export default function AppLogoIcon({ className }: { className?: string }) {
|
||||
const { setting } = usePage<{ setting: GeneralSetting }>().props;
|
||||
|
||||
if (!setting?.icon_light_url && !setting?.icon_dark_url) {
|
||||
return (
|
||||
<img src="/assets/logo.webp" alt="Logo" className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{setting?.icon_light_url && (
|
||||
<img
|
||||
src={setting.icon_light_url}
|
||||
alt="Logo Light"
|
||||
className={cn('dark:hidden', className)}
|
||||
/>
|
||||
)}
|
||||
{setting?.icon_dark_url && (
|
||||
<img
|
||||
src={setting.icon_dark_url}
|
||||
alt="Logo Dark"
|
||||
className={cn('hidden dark:block', className)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import type { GeneralSetting } from '@/types';
|
||||
|
||||
export default function AppLogo() {
|
||||
const { setting } = usePage<{ setting: GeneralSetting }>().props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
VN Grup
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{setting?.logo_light_url ? (
|
||||
<img
|
||||
src={setting.logo_light_url}
|
||||
alt={setting.name}
|
||||
className="max-h-8 w-auto dark:hidden"
|
||||
/>
|
||||
) : null}
|
||||
{setting?.logo_dark_url ? (
|
||||
<img
|
||||
src={setting.logo_dark_url}
|
||||
alt={setting.name}
|
||||
className="hidden max-h-8 w-auto dark:block"
|
||||
/>
|
||||
) : null}
|
||||
{!setting?.logo_light_url && !setting?.logo_dark_url && (
|
||||
<span className="truncate font-semibold tracking-tight">
|
||||
{setting?.name || 'VN Grup'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,61 +21,57 @@ export default function SettingIndex({
|
||||
description: setting?.description || '',
|
||||
address: setting?.address || '',
|
||||
phone: setting?.phone || '',
|
||||
logo: null as File | null,
|
||||
icon: null as File | null,
|
||||
logo_light: null as File | null,
|
||||
logo_dark: null as File | null,
|
||||
icon_light: null as File | null,
|
||||
icon_dark: null as File | null,
|
||||
});
|
||||
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const iconInputRef = useRef<HTMLInputElement>(null);
|
||||
const logoLightInputRef = useRef<HTMLInputElement>(null);
|
||||
const logoDarkInputRef = useRef<HTMLInputElement>(null);
|
||||
const iconLightInputRef = useRef<HTMLInputElement>(null);
|
||||
const iconDarkInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(
|
||||
setting?.logo_url || null,
|
||||
const [logoLightPreview, setLogoLightPreview] = useState<string | null>(
|
||||
setting?.logo_light_url || null,
|
||||
);
|
||||
const [iconPreview, setIconPreview] = useState<string | null>(
|
||||
setting?.icon_url || null,
|
||||
const [logoDarkPreview, setLogoDarkPreview] = useState<string | null>(
|
||||
setting?.logo_dark_url || null,
|
||||
);
|
||||
const [iconLightPreview, setIconLightPreview] = useState<string | null>(
|
||||
setting?.icon_light_url || null,
|
||||
);
|
||||
const [iconDarkPreview, setIconDarkPreview] = useState<string | null>(
|
||||
setting?.icon_dark_url || null,
|
||||
);
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: 'logo_light' | 'logo_dark' | 'icon_light' | 'icon_dark',
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData('logo', file);
|
||||
setData(field, file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setLogoPreview(reader.result as string);
|
||||
reader.onloadend = () => setPreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const removeLogo = () => {
|
||||
setData('logo', null);
|
||||
setLogoPreview(null);
|
||||
const removeFile = (
|
||||
field: 'logo_light' | 'logo_dark' | 'icon_light' | 'icon_dark',
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
inputRef: React.RefObject<HTMLInputElement | null>,
|
||||
) => {
|
||||
setData(field, null);
|
||||
setPreview(null);
|
||||
|
||||
if (logoInputRef.current) {
|
||||
logoInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData('icon', file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setIconPreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const removeIcon = () => {
|
||||
setData('icon', null);
|
||||
setIconPreview(null);
|
||||
|
||||
if (iconInputRef.current) {
|
||||
iconInputRef.current.value = '';
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@ -203,51 +199,118 @@ export default function SettingIndex({
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Logo Aplikasi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logoPreview ? (
|
||||
<div className="relative inline-block w-full">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="w-full rounded-xl border object-cover shadow"
|
||||
/>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
Logo Terang
|
||||
</Label>
|
||||
{logoLightPreview ? (
|
||||
<div className="relative h-20 w-full overflow-hidden rounded-xl border bg-white shadow-sm">
|
||||
<img
|
||||
src={logoLightPreview}
|
||||
alt="Logo Light preview"
|
||||
className="h-full w-full object-contain p-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeFile(
|
||||
'logo_light',
|
||||
setLogoLightPreview,
|
||||
logoLightInputRef,
|
||||
)
|
||||
}
|
||||
className="absolute top-1 right-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md transition-colors hover:bg-red-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeLogo}
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md transition-colors hover:bg-red-600"
|
||||
onClick={() =>
|
||||
logoLightInputRef.current?.click()
|
||||
}
|
||||
className="group flex h-20 w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<ImagePlus className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
logoInputRef.current?.click()
|
||||
)}
|
||||
<input
|
||||
ref={logoLightInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
handleFileChange(
|
||||
e,
|
||||
'logo_light',
|
||||
setLogoLightPreview,
|
||||
)
|
||||
}
|
||||
className="group flex h-40 w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<ImagePlus className="mb-2 h-10 w-10 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
<span className="text-sm font-medium text-muted-foreground transition-colors group-hover:text-primary">
|
||||
Klik untuk upload logo
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
PNG, JPG, WEBP — maks. 2MB
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.logo}
|
||||
label="Logo Aplikasi"
|
||||
className="mt-2 text-xs"
|
||||
/>
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.logo_light}
|
||||
label="Logo Terang"
|
||||
className="mt-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
Logo Gelap
|
||||
</Label>
|
||||
{logoDarkPreview ? (
|
||||
<div className="relative h-20 w-full overflow-hidden rounded-xl border bg-slate-900 shadow-sm">
|
||||
<img
|
||||
src={logoDarkPreview}
|
||||
alt="Logo Dark preview"
|
||||
className="h-full w-full object-contain p-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeFile(
|
||||
'logo_dark',
|
||||
setLogoDarkPreview,
|
||||
logoDarkInputRef,
|
||||
)
|
||||
}
|
||||
className="absolute top-1 right-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md transition-colors hover:bg-red-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
logoDarkInputRef.current?.click()
|
||||
}
|
||||
className="group flex h-20 w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<ImagePlus className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={logoDarkInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
handleFileChange(
|
||||
e,
|
||||
'logo_dark',
|
||||
setLogoDarkPreview,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.logo_dark}
|
||||
label="Logo Gelap"
|
||||
className="mt-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -255,53 +318,118 @@ export default function SettingIndex({
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Favicon / Icon</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{iconPreview ? (
|
||||
<div className="relative flex inline-block w-full justify-center">
|
||||
<div className="relative">
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
Icon Terang
|
||||
</Label>
|
||||
{iconLightPreview ? (
|
||||
<div className="relative h-20 w-full overflow-hidden rounded-xl border bg-white shadow-sm">
|
||||
<img
|
||||
src={iconPreview}
|
||||
alt="Icon preview"
|
||||
className="size-24 rounded-xl border object-contain p-2 shadow"
|
||||
src={iconLightPreview}
|
||||
alt="Icon Light preview"
|
||||
className="h-full w-full object-contain p-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeIcon}
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md transition-colors hover:bg-red-600"
|
||||
onClick={() =>
|
||||
removeFile(
|
||||
'icon_light',
|
||||
setIconLightPreview,
|
||||
iconLightInputRef,
|
||||
)
|
||||
}
|
||||
className="absolute top-1 right-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md transition-colors hover:bg-red-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
iconInputRef.current?.click()
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
iconLightInputRef.current?.click()
|
||||
}
|
||||
className="group flex h-20 w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<ImagePlus className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={iconLightInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
handleFileChange(
|
||||
e,
|
||||
'icon_light',
|
||||
setIconLightPreview,
|
||||
)
|
||||
}
|
||||
className="group flex h-32 w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<ImagePlus className="mb-2 h-8 w-8 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
<span className="text-sm font-medium text-muted-foreground transition-colors group-hover:text-primary">
|
||||
Klik untuk upload icon
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
ICO, PNG — maks. 1MB
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={iconInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.icon}
|
||||
label="Favicon / Icon"
|
||||
className="mt-2 text-xs"
|
||||
/>
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.icon_light}
|
||||
label="Icon Terang"
|
||||
className="mt-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
Icon Gelap
|
||||
</Label>
|
||||
{iconDarkPreview ? (
|
||||
<div className="relative h-20 w-full overflow-hidden rounded-xl border bg-slate-900 shadow-sm">
|
||||
<img
|
||||
src={iconDarkPreview}
|
||||
alt="Icon Dark preview"
|
||||
className="h-full w-full object-contain p-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeFile(
|
||||
'icon_dark',
|
||||
setIconDarkPreview,
|
||||
iconDarkInputRef,
|
||||
)
|
||||
}
|
||||
className="absolute top-1 right-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md transition-colors hover:bg-red-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
iconDarkInputRef.current?.click()
|
||||
}
|
||||
className="group flex h-20 w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
<ImagePlus className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={iconDarkInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
handleFileChange(
|
||||
e,
|
||||
'icon_dark',
|
||||
setIconDarkPreview,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.icon_dark}
|
||||
label="Icon Gelap"
|
||||
className="mt-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -138,7 +138,15 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
|
||||
}`}>
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 lg:px-12">
|
||||
<div className="flex items-center gap-2 text-xl font-bold uppercase tracking-tighter">
|
||||
VN Grup
|
||||
{setting?.logo_light_url ? (
|
||||
<img
|
||||
src={setting.logo_light_url}
|
||||
alt={setting.name}
|
||||
className="max-h-8 w-auto"
|
||||
/>
|
||||
) : (
|
||||
setting?.name || 'VN Grup'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="hidden items-center gap-10 text-[10px] font-bold uppercase tracking-[0.2em] md:flex">
|
||||
@ -326,7 +334,7 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
|
||||
<p className="mb-4 text-sm font-medium opacity-60">{formatCurrency(item.retail_price)}</p>
|
||||
|
||||
<a
|
||||
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo VN Grup, saya ingin memesan ${item.name} seharga ${formatCurrency(item.retail_price)}. Mohon info detail pembayarannya.`)}`}
|
||||
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo ${setting?.name || 'VN Grup'}, saya ingin memesan ${item.name} seharga ${formatCurrency(item.retail_price)}. Mohon info detail pembayarannya.`)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 border border-[#1e4a6d] px-6 py-2 text-[8px] font-bold uppercase tracking-widest transition-all hover:bg-[#1e4a6d] hover:text-white"
|
||||
@ -404,7 +412,7 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
|
||||
{/* Brand Info */}
|
||||
<div className="lg:col-span-6">
|
||||
<div className="mb-6 flex items-center gap-2 text-xl font-bold uppercase tracking-tighter">
|
||||
{setting?.logo_url ? <img src={setting.logo_url} alt={setting.name} className="max-h-8" /> : (setting?.name || 'VN Grup')}
|
||||
{setting?.logo_light_url ? <img src={setting.logo_light_url} alt={setting.name} className="max-h-8" /> : (setting?.name || 'VN Grup')}
|
||||
</div>
|
||||
<p className="mb-8 text-sm leading-relaxed opacity-60">
|
||||
{setting?.description || 'Menghadirkan produk esensial yang tak lekang oleh waktu dengan fokus pada kualitas, keberlanjutan, dan gaya yang abadi.'}
|
||||
@ -556,7 +564,7 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
|
||||
|
||||
<div className="mt-auto pt-8 border-t border-[#1e4a6d]/10">
|
||||
<a
|
||||
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo VN Grup, saya tertarik dengan produk ${selectedProduct?.name} seharga ${formatCurrency(selectedProduct?.retail_price || 0)}. Bisa tanya-tanya dulu?`)}`}
|
||||
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo ${setting?.name || 'VN Grup'}, saya tertarik dengan produk ${selectedProduct?.name} seharga ${formatCurrency(selectedProduct?.retail_price || 0)}. Bisa tanya-tanya dulu?`)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full bg-[#1e4a6d] py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-white transition-all hover:bg-[#15344e] flex items-center justify-center gap-2"
|
||||
|
||||
@ -4,8 +4,10 @@ export interface GeneralSetting {
|
||||
description: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
logo_url: string | null;
|
||||
icon_url: string | null;
|
||||
logo_light_url: string | null;
|
||||
logo_dark_url: string | null;
|
||||
icon_light_url: string | null;
|
||||
icon_dark_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@ -7,7 +8,7 @@
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
<script>
|
||||
(function() {
|
||||
const appearance = '{{ $appearance ?? "system" }}';
|
||||
const appearance = '{{ $appearance ?? 'system' }}';
|
||||
|
||||
if (appearance === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
@ -30,9 +31,15 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
@php
|
||||
$setting = \App\Models\GeneralSetting::first();
|
||||
@endphp
|
||||
|
||||
@if ($setting && $setting->icon_light_url)
|
||||
<link rel="icon" href="{{ $setting->icon_light_url }}" sizes="any">
|
||||
@else
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
@endif
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
@ -40,10 +47,12 @@
|
||||
@viteReactRefresh
|
||||
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||
<x-inertia::head>
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
<title>{{ config('app.name', 'VN Grup') }}</title>
|
||||
</x-inertia::head>
|
||||
</head>
|
||||
|
||||
<body class="font-sans antialiased">
|
||||
<x-inertia::app />
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
);
|
||||
});
|
||||
|
||||
it('can update general settings with logo and icon', function () {
|
||||
it('can update general settings with logo and icon variants', function () {
|
||||
Storage::fake('public');
|
||||
|
||||
// Ensure a setting exists first for testing update logic in Controller
|
||||
@ -62,8 +62,10 @@
|
||||
'description' => 'Toko baju premium',
|
||||
'address' => 'Jakarta, Indonesia',
|
||||
'phone' => '081234567890',
|
||||
'logo' => UploadedFile::fake()->image('logo.png'),
|
||||
'icon' => UploadedFile::fake()->image('icon.png'),
|
||||
'logo_light' => UploadedFile::fake()->image('logo_light.png'),
|
||||
'logo_dark' => UploadedFile::fake()->image('logo_dark.png'),
|
||||
'icon_light' => UploadedFile::fake()->image('icon_light.png'),
|
||||
'icon_dark' => UploadedFile::fake()->image('icon_dark.png'),
|
||||
];
|
||||
|
||||
postJson(route('system.settings.update'), $data)
|
||||
@ -77,17 +79,19 @@
|
||||
]);
|
||||
|
||||
$setting->refresh();
|
||||
expect($setting->getFirstMediaUrl('logo'))->not->toBeEmpty();
|
||||
expect($setting->getFirstMediaUrl('icon'))->not->toBeEmpty();
|
||||
expect($setting->getFirstMediaUrl('logo_light'))->not->toBeEmpty();
|
||||
expect($setting->getFirstMediaUrl('logo_dark'))->not->toBeEmpty();
|
||||
expect($setting->getFirstMediaUrl('icon_light'))->not->toBeEmpty();
|
||||
expect($setting->getFirstMediaUrl('icon_dark'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('validates settings update', function () {
|
||||
// If setting doesn't exist, logo and icon are required
|
||||
it('validates settings update with new variants', function () {
|
||||
// If setting doesn't exist, logo_light and icon_light are required
|
||||
GeneralSetting::truncate();
|
||||
|
||||
postJson(route('system.settings.update'), [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name', 'description', 'address', 'phone', 'logo', 'icon']);
|
||||
->assertJsonValidationErrors(['name', 'description', 'address', 'phone', 'logo_light', 'icon_light']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user