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);
|
$setting = GeneralSetting::create($validated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->hasFile('logo')) {
|
if ($request->hasFile('logo_light')) {
|
||||||
$setting->addMediaFromRequest('logo')->toMediaCollection('logo');
|
$setting->addMediaFromRequest('logo_light')->toMediaCollection('logo_light');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->hasFile('icon')) {
|
if ($request->hasFile('logo_dark')) {
|
||||||
$setting->addMediaFromRequest('icon')->toMediaCollection('icon');
|
$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');
|
return redirect()->back()->with('success', 'Pengaturan berhasil diperbarui');
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\GeneralSetting;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ public function share(Request $request): array
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
|
'setting' => GeneralSetting::first(),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user() ? $request->user()->load('profile') : null,
|
'user' => $request->user() ? $request->user()->load('profile') : null,
|
||||||
'roles' => $request->user() ? $request->user()->getRoleNames() : [],
|
'roles' => $request->user() ? $request->user()->getRoleNames() : [],
|
||||||
|
|||||||
@ -30,8 +30,10 @@ public function rules(): array
|
|||||||
'description' => ['required', 'string'],
|
'description' => ['required', 'string'],
|
||||||
'address' => ['required', 'string'],
|
'address' => ['required', 'string'],
|
||||||
'phone' => ['required', 'string', 'max:20'],
|
'phone' => ['required', 'string', 'max:20'],
|
||||||
'logo' => [$settingExists ? 'nullable' : 'required', 'image', 'max:2048'],
|
'logo_light' => [$settingExists ? 'nullable' : 'required', 'image', 'max:2048'],
|
||||||
'icon' => [$settingExists ? 'nullable' : 'required', 'image', 'max:1024'],
|
'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;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[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
|
class GeneralSetting extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use HasFactory, InteractsWithMedia, LogsActivity;
|
use HasFactory, InteractsWithMedia, LogsActivity;
|
||||||
|
|
||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('logo')
|
$this->addMediaCollection('logo_light')
|
||||||
->singleFile();
|
->singleFile();
|
||||||
|
|
||||||
$this->addMediaCollection('icon')
|
$this->addMediaCollection('logo_dark')
|
||||||
|
->singleFile();
|
||||||
|
|
||||||
|
$this->addMediaCollection('icon_light')
|
||||||
|
->singleFile();
|
||||||
|
|
||||||
|
$this->addMediaCollection('icon_dark')
|
||||||
->singleFile();
|
->singleFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function logoUrl(): Attribute
|
protected function logoLightUrl(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
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(
|
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() {
|
import { cn } from '@/lib/utils';
|
||||||
return <img src="/assets/logo.webp" alt="Logo" />;
|
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() {
|
export default function AppLogo() {
|
||||||
|
const { setting } = usePage<{ setting: GeneralSetting }>().props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
{setting?.logo_light_url ? (
|
||||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
<img
|
||||||
VN Grup
|
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>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,61 +21,57 @@ export default function SettingIndex({
|
|||||||
description: setting?.description || '',
|
description: setting?.description || '',
|
||||||
address: setting?.address || '',
|
address: setting?.address || '',
|
||||||
phone: setting?.phone || '',
|
phone: setting?.phone || '',
|
||||||
logo: null as File | null,
|
logo_light: null as File | null,
|
||||||
icon: 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 logoLightInputRef = useRef<HTMLInputElement>(null);
|
||||||
const iconInputRef = useRef<HTMLInputElement>(null);
|
const logoDarkInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const iconLightInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const iconDarkInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [logoPreview, setLogoPreview] = useState<string | null>(
|
const [logoLightPreview, setLogoLightPreview] = useState<string | null>(
|
||||||
setting?.logo_url || null,
|
setting?.logo_light_url || null,
|
||||||
);
|
);
|
||||||
const [iconPreview, setIconPreview] = useState<string | null>(
|
const [logoDarkPreview, setLogoDarkPreview] = useState<string | null>(
|
||||||
setting?.icon_url || 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];
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setData('logo', file);
|
setData(field, file);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => setLogoPreview(reader.result as string);
|
reader.onloadend = () => setPreview(reader.result as string);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLogo = () => {
|
const removeFile = (
|
||||||
setData('logo', null);
|
field: 'logo_light' | 'logo_dark' | 'icon_light' | 'icon_dark',
|
||||||
setLogoPreview(null);
|
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>,
|
||||||
|
) => {
|
||||||
|
setData(field, null);
|
||||||
|
setPreview(null);
|
||||||
|
|
||||||
if (logoInputRef.current) {
|
if (inputRef.current) {
|
||||||
logoInputRef.current.value = '';
|
inputRef.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 = '';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -203,18 +199,28 @@ export default function SettingIndex({
|
|||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Logo Aplikasi</CardTitle>
|
<CardTitle>Logo Aplikasi</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="grid grid-cols-2 gap-4">
|
||||||
{logoPreview ? (
|
<div className="space-y-2">
|
||||||
<div className="relative inline-block w-full">
|
<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
|
<img
|
||||||
src={logoPreview}
|
src={logoLightPreview}
|
||||||
alt="Logo preview"
|
alt="Logo Light preview"
|
||||||
className="w-full rounded-xl border object-cover shadow"
|
className="h-full w-full object-contain p-2"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={removeLogo}
|
onClick={() =>
|
||||||
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"
|
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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -223,31 +229,88 @@ export default function SettingIndex({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
logoInputRef.current?.click()
|
logoLightInputRef.current?.click()
|
||||||
}
|
}
|
||||||
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"
|
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="mb-2 h-10 w-10 text-muted-foreground transition-colors group-hover:text-primary" />
|
<ImagePlus className="h-5 w-5 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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={logoInputRef}
|
ref={logoLightInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleLogoChange}
|
onChange={(e) =>
|
||||||
|
handleFileChange(
|
||||||
|
e,
|
||||||
|
'logo_light',
|
||||||
|
setLogoLightPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FieldError
|
<FieldError
|
||||||
error={errors.logo}
|
error={errors.logo_light}
|
||||||
label="Logo Aplikasi"
|
label="Logo Terang"
|
||||||
className="mt-2 text-xs"
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -255,53 +318,118 @@ export default function SettingIndex({
|
|||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Favicon / Icon</CardTitle>
|
<CardTitle>Favicon / Icon</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="grid grid-cols-2 gap-4">
|
||||||
{iconPreview ? (
|
<div className="space-y-2">
|
||||||
<div className="relative flex inline-block w-full justify-center">
|
<Label className="text-xs">
|
||||||
<div className="relative">
|
Icon Terang
|
||||||
|
</Label>
|
||||||
|
{iconLightPreview ? (
|
||||||
|
<div className="relative h-20 w-full overflow-hidden rounded-xl border bg-white shadow-sm">
|
||||||
<img
|
<img
|
||||||
src={iconPreview}
|
src={iconLightPreview}
|
||||||
alt="Icon preview"
|
alt="Icon Light preview"
|
||||||
className="size-24 rounded-xl border object-contain p-2 shadow"
|
className="h-full w-full object-contain p-3"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={removeIcon}
|
onClick={() =>
|
||||||
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"
|
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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
iconInputRef.current?.click()
|
iconLightInputRef.current?.click()
|
||||||
}
|
}
|
||||||
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"
|
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="mb-2 h-8 w-8 text-muted-foreground transition-colors group-hover:text-primary" />
|
<ImagePlus className="h-5 w-5 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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={iconInputRef}
|
ref={iconLightInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleIconChange}
|
onChange={(e) =>
|
||||||
|
handleFileChange(
|
||||||
|
e,
|
||||||
|
'icon_light',
|
||||||
|
setIconLightPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FieldError
|
<FieldError
|
||||||
error={errors.icon}
|
error={errors.icon_light}
|
||||||
label="Favicon / Icon"
|
label="Icon Terang"
|
||||||
className="mt-2 text-xs"
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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="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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-10 text-[10px] font-bold uppercase tracking-[0.2em] md:flex">
|
<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>
|
<p className="mb-4 text-sm font-medium opacity-60">{formatCurrency(item.retail_price)}</p>
|
||||||
|
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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 */}
|
{/* Brand Info */}
|
||||||
<div className="lg:col-span-6">
|
<div className="lg:col-span-6">
|
||||||
<div className="mb-6 flex items-center gap-2 text-xl font-bold uppercase tracking-tighter">
|
<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>
|
</div>
|
||||||
<p className="mb-8 text-sm leading-relaxed opacity-60">
|
<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.'}
|
{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">
|
<div className="mt-auto pt-8 border-t border-[#1e4a6d]/10">
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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;
|
description: string | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
logo_url: string | null;
|
logo_light_url: string | null;
|
||||||
icon_url: string | null;
|
logo_dark_url: string | null;
|
||||||
|
icon_light_url: string | null;
|
||||||
|
icon_dark_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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 --}}
|
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const appearance = '{{ $appearance ?? "system" }}';
|
const appearance = '{{ $appearance ?? 'system' }}';
|
||||||
|
|
||||||
if (appearance === 'system') {
|
if (appearance === 'system') {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
@ -30,9 +31,15 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
@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">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
@endif
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||||
@ -40,10 +47,12 @@
|
|||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||||
<x-inertia::head>
|
<x-inertia::head>
|
||||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
<title>{{ config('app.name', 'VN Grup') }}</title>
|
||||||
</x-inertia::head>
|
</x-inertia::head>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
<x-inertia::app />
|
<x-inertia::app />
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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');
|
Storage::fake('public');
|
||||||
|
|
||||||
// Ensure a setting exists first for testing update logic in Controller
|
// Ensure a setting exists first for testing update logic in Controller
|
||||||
@ -62,8 +62,10 @@
|
|||||||
'description' => 'Toko baju premium',
|
'description' => 'Toko baju premium',
|
||||||
'address' => 'Jakarta, Indonesia',
|
'address' => 'Jakarta, Indonesia',
|
||||||
'phone' => '081234567890',
|
'phone' => '081234567890',
|
||||||
'logo' => UploadedFile::fake()->image('logo.png'),
|
'logo_light' => UploadedFile::fake()->image('logo_light.png'),
|
||||||
'icon' => UploadedFile::fake()->image('icon.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)
|
postJson(route('system.settings.update'), $data)
|
||||||
@ -77,17 +79,19 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$setting->refresh();
|
$setting->refresh();
|
||||||
expect($setting->getFirstMediaUrl('logo'))->not->toBeEmpty();
|
expect($setting->getFirstMediaUrl('logo_light'))->not->toBeEmpty();
|
||||||
expect($setting->getFirstMediaUrl('icon'))->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 () {
|
it('validates settings update with new variants', function () {
|
||||||
// If setting doesn't exist, logo and icon are required
|
// If setting doesn't exist, logo_light and icon_light are required
|
||||||
GeneralSetting::truncate();
|
GeneralSetting::truncate();
|
||||||
|
|
||||||
postJson(route('system.settings.update'), [])
|
postJson(route('system.settings.update'), [])
|
||||||
->assertStatus(422)
|
->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