feat: update homepage settings to use image keys and add presigned URL retrieval

This commit is contained in:
Yoga Pangestu 2026-07-31 10:30:25 +07:00
parent a1a73cb222
commit d07eb3498a
8 changed files with 215 additions and 62 deletions

View File

@ -6,6 +6,7 @@
use App\Http\Requests\Api\PresignedUrlRequest; use App\Http\Requests\Api\PresignedUrlRequest;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
class PresignedUrlController extends Controller class PresignedUrlController extends Controller
{ {
@ -25,4 +26,12 @@ public function store(PresignedUrlRequest $request): JsonResponse
return response()->json($result); return response()->json($result);
} }
public function show(string $key): RedirectResponse
{
$minutes = (int) request('minutes', 60);
$url = $this->service->getTemporaryUrl(urldecode($key), $minutes);
return redirect($url);
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin\Settings; namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Override;
class UpdateHRRequest extends FormRequest class UpdateHRRequest extends FormRequest
{ {
@ -11,6 +12,22 @@ public function authorize(): bool
return true; return true;
} }
#[Override]
public function prepareForValidation()
{
if ($this->has('late_penalty_amount')) {
$this->merge([
'late_penalty_amount' => str_replace('.', '', $this->late_penalty_amount),
]);
}
if ($this->has('absent_penalty_amount')) {
$this->merge([
'absent_penalty_amount' => str_replace('.', '', $this->absent_penalty_amount),
]);
}
}
public function rules(): array public function rules(): array
{ {
return [ return [

View File

@ -14,19 +14,19 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'hero_image_url' => ['nullable', 'string', 'max:2000'], 'hero_image_key' => ['nullable', 'string', 'max:2000'],
'about_image_url' => ['nullable', 'string', 'max:2000'], 'about_image_key' => ['nullable', 'string', 'max:2000'],
'gallery_images' => ['nullable', 'array'], 'gallery_image_keys' => ['nullable', 'array'],
'gallery_images.*' => ['string', 'max:2000'], 'gallery_image_keys.*' => ['string', 'max:2000'],
]; ];
} }
public function attributes(): array public function attributes(): array
{ {
return [ return [
'hero_image_url' => 'url foto hero', 'hero_image_key' => 'foto hero',
'about_image_url' => 'url foto tentang kami', 'about_image_key' => 'foto tentang kami',
'gallery_images' => 'gambar galeri', 'gallery_image_keys' => 'gambar galeri',
]; ];
} }
} }

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin; namespace App\Services\Admin;
use App\Services\S3PresignedService;
use App\Settings\HRSettings; use App\Settings\HRSettings;
use App\Settings\HomepageSettings; use App\Settings\HomepageSettings;
use App\Settings\MarketplaceSettings; use App\Settings\MarketplaceSettings;
@ -11,6 +12,10 @@
class AdminSettingsService class AdminSettingsService
{ {
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function getSystemData(): array public function getSystemData(): array
{ {
$settings = app(SystemSettings::class); $settings = app(SystemSettings::class);
@ -29,12 +34,27 @@ public function getHomepageData(): array
$settings = app(HomepageSettings::class); $settings = app(HomepageSettings::class);
return [ return [
'hero_image_url' => $settings->hero_image_url, 'hero_image_url' => $settings->hero_image_url ? $this->getTemporaryUrl($settings->hero_image_url) : null,
'about_image_url' => $settings->about_image_url, 'hero_image_key' => $settings->hero_image_url,
'gallery_images' => $settings->gallery_images, 'about_image_url' => $settings->about_image_url ? $this->getTemporaryUrl($settings->about_image_url) : null,
'about_image_key' => $settings->about_image_url,
'gallery_images' => array_map(
fn ($key) => $this->getTemporaryUrl($key),
$settings->gallery_images ?? [],
),
'gallery_image_keys' => $settings->gallery_images ?? [],
]; ];
} }
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
public function getSocialMediaData(): array public function getSocialMediaData(): array
{ {
$settings = app(SocialMediaSettings::class); $settings = app(SocialMediaSettings::class);
@ -95,7 +115,11 @@ public function updateSystem(array $data): void
public function updateHomepage(array $data): void public function updateHomepage(array $data): void
{ {
$settings = app(HomepageSettings::class); $settings = app(HomepageSettings::class);
$settings->fill($data);
$settings->hero_image_url = $data['hero_image_key'] ?? null;
$settings->about_image_url = $data['about_image_key'] ?? null;
$settings->gallery_images = $data['gallery_image_keys'] ?? [];
$settings->save(); $settings->save();
} }

View File

@ -1,5 +1,5 @@
import { FileImage, Upload, X } from 'lucide-react'; import { FileImage, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useId } from 'react';
import { import {
Attachment, Attachment,
AttachmentAction, AttachmentAction,
@ -36,6 +36,7 @@ function formatFileSize(bytes: number): string {
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) { export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const uploadId = useId();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
@ -106,7 +107,7 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
accept={accept} accept={accept}
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
id="file-upload" id={uploadId}
/> />
<Attachment state={state} orientation="horizontal" className='w-full'> <Attachment state={state} orientation="horizontal" className='w-full'>

View File

@ -1,3 +1,4 @@
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input'; import { PhoneNumberInput } from '@/components/phone-number-input';
import { RupiahInput } from '@/components/rupiah-input'; import { RupiahInput } from '@/components/rupiah-input';
@ -10,8 +11,9 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { updateHomepage, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings'; import { updateHomepage, updateHr, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings';
import { Form, Head } from '@inertiajs/react'; import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
type MarketplaceFeeRule = { type MarketplaceFeeRule = {
@ -30,8 +32,11 @@ type Props = {
}; };
homepage: { homepage: {
hero_image_url: string; hero_image_url: string;
hero_image_key: string;
about_image_url: string; about_image_url: string;
about_image_key: string;
gallery_images: string[]; gallery_images: string[];
gallery_image_keys: string[];
}; };
socialMedia: { socialMedia: {
instagram_url: string; instagram_url: string;
@ -50,6 +55,124 @@ type Props = {
}; };
}; };
type HomepageSettingsTabProps = {
homepage: Props['homepage'];
};
function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
const [heroKey, setHeroKey] = useState<string | null>(homepage.hero_image_key ?? null);
const [heroUploading, setHeroUploading] = useState(false);
const [aboutKey, setAboutKey] = useState<string | null>(homepage.about_image_key ?? null);
const [aboutUploading, setAboutUploading] = useState(false);
const [galleryKeys, setGalleryKeys] = useState<string[]>(homepage.gallery_image_keys ?? []);
const [galleryUploading, setGalleryUploading] = useState(false);
function addGalleryImage() {
setGalleryKeys((prev) => [...prev, '']);
}
function removeGalleryImage(index: number) {
setGalleryKeys((prev) => prev.filter((_, i) => i !== index));
}
function updateGalleryKey(index: number, key: string | null) {
setGalleryKeys((prev) => prev.map((k, i) => (i === index ? (key ?? '') : k)));
}
return (
<Form action={updateHomepage()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Homepage</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} />
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} />
{galleryKeys.map((key, index) => (
<input key={index} type="hidden" name={`gallery_image_keys[${index}]`} value={key} />
))}
<div className="grid gap-2">
<Label>Foto Hero</Label>
<FileUpload
value={heroKey}
onChange={setHeroKey}
folder="homepage/hero"
existingUrl={homepage.hero_image_url}
onUploadingChange={setHeroUploading}
/>
<InputError message={errors.hero_image_key} />
</div>
<div className="grid gap-2">
<Label>Foto Tentang Kami</Label>
<FileUpload
value={aboutKey}
onChange={setAboutKey}
folder="homepage/about"
existingUrl={homepage.about_image_url}
onUploadingChange={setAboutUploading}
/>
<InputError message={errors.about_image_key} />
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Galeri Foto</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addGalleryImage}
>
<Plus className="h-4 w-4" />
Tambah
</Button>
</div>
<div className="grid gap-3">
{galleryKeys.map((key, index) => (
<div key={index} className="flex items-start gap-2">
<div className="flex-1">
<FileUpload
value={key || null}
onChange={(k) => updateGalleryKey(index, k)}
folder="homepage/gallery"
existingUrl={homepage.gallery_images[index] ?? null}
onUploadingChange={setGalleryUploading}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 text-destructive hover:text-destructive"
onClick={() => removeGalleryImage(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{galleryKeys.length === 0 && (
<p className="text-sm text-muted-foreground">Belum ada gambar. Klik "Tambah" untuk menambahkan gambar galeri.</p>
)}
</div>
<InputError message={errors.gallery_image_keys} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing || heroUploading || aboutUploading || galleryUploading}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</div>
)}
</Form>
);
}
const sidebarTabs = [ const sidebarTabs = [
{ key: 'sistem', label: 'Sistem' }, { key: 'sistem', label: 'Sistem' },
{ key: 'homepage', label: 'Homepage' }, { key: 'homepage', label: 'Homepage' },
@ -249,32 +372,7 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)} )}
{activeTab === 'homepage' && ( {activeTab === 'homepage' && (
<Form action={updateHomepage()} options={{ preserveScroll: true }}> <HomepageSettingsTab homepage={homepage} />
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Homepage</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<div className="grid gap-2">
<Label htmlFor="hero_image_url">URL Foto Hero</Label>
<Input id="hero_image_url" name="hero_image_url" placeholder="https://..." defaultValue={homepage.hero_image_url} />
<InputError message={errors.hero_image_url} />
</div>
<div className="grid gap-2">
<Label htmlFor="about_image_url">URL Foto Tentang Kami</Label>
<Input id="about_image_url" name="about_image_url" placeholder="https://..." defaultValue={homepage.about_image_url} />
<InputError message={errors.about_image_url} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
)} )}
{activeTab === 'media-sosial' && ( {activeTab === 'media-sosial' && (
@ -336,7 +434,7 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)} )}
{activeTab === 'hr' && ( {activeTab === 'hr' && (
<Form action="/admin/settings/hr" options={{ preserveScroll: true }}> <Form action={updateHr()} options={{ preserveScroll: true }}>
{({ processing, errors }) => ( {({ processing, errors }) => (
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>

View File

@ -6,3 +6,7 @@
Route::post('/presigned-url', [PresignedUrlController::class, 'store']) Route::post('/presigned-url', [PresignedUrlController::class, 'store'])
->middleware(['web', 'auth', 'verified']) ->middleware(['web', 'auth', 'verified'])
->name('presigned-url.store'); ->name('presigned-url.store');
Route::get('/presigned-url/{key}', [PresignedUrlController::class, 'show'])
->middleware(['web', 'auth', 'verified'])
->name('presigned-url.show');

View File

@ -32,7 +32,7 @@
test('guest cannot update homepage settings', function () { test('guest cannot update homepage settings', function () {
$response = $this->put(route('admin.settings.update-homepage'), [ $response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://unauthorized.com/image.jpg', 'hero_image_key' => 'unauthorized/image.jpg',
]); ]);
$response->assertRedirect(route('login')); $response->assertRedirect(route('login'));
}); });
@ -257,9 +257,9 @@
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [ $response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://example.com/hero.jpg', 'hero_image_key' => 'homepage/hero/2026/07/31/abc123/hero.jpg',
'about_image_url' => 'https://example.com/about.jpg', 'about_image_key' => 'homepage/about/2026/07/31/def456/about.jpg',
'gallery_images' => ['https://example.com/g1.jpg', 'https://example.com/g2.jpg'], 'gallery_image_keys' => ['homepage/gallery/2026/07/31/g1/img.jpg', 'homepage/gallery/2026/07/31/g2/img.jpg'],
]); ]);
$response $response
@ -267,9 +267,9 @@
->assertRedirect(route('admin.settings.index')); ->assertRedirect(route('admin.settings.index'));
$settings = app(HomepageSettings::class); $settings = app(HomepageSettings::class);
expect($settings->hero_image_url)->toBe('https://example.com/hero.jpg'); expect($settings->hero_image_url)->toBe('homepage/hero/2026/07/31/abc123/hero.jpg');
expect($settings->about_image_url)->toBe('https://example.com/about.jpg'); expect($settings->about_image_url)->toBe('homepage/about/2026/07/31/def456/about.jpg');
expect($settings->gallery_images)->toBe(['https://example.com/g1.jpg', 'https://example.com/g2.jpg']); expect($settings->gallery_images)->toBe(['homepage/gallery/2026/07/31/g1/img.jpg', 'homepage/gallery/2026/07/31/g2/img.jpg']);
}); });
test('homepage fields are nullable', function () { test('homepage fields are nullable', function () {
@ -277,44 +277,44 @@
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [ $response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => null, 'hero_image_key' => null,
'about_image_url' => null, 'about_image_key' => null,
]); ]);
$response->assertSessionHasNoErrors(); $response->assertSessionHasNoErrors();
}); });
test('homepage hero_image_url must not exceed 2000 characters', function () { test('homepage hero_image_key must not exceed 2000 characters', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [ $response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => str_repeat('a', 2001), 'hero_image_key' => str_repeat('a', 2001),
]); ]);
$response->assertSessionHasErrors('hero_image_url'); $response->assertSessionHasErrors('hero_image_key');
}); });
test('homepage gallery_images must be array', function () { test('homepage gallery_image_keys must be array', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [ $response = $this->put(route('admin.settings.update-homepage'), [
'gallery_images' => 'not-an-array', 'gallery_image_keys' => 'not-an-array',
]); ]);
$response->assertSessionHasErrors('gallery_images'); $response->assertSessionHasErrors('gallery_image_keys');
}); });
test('homepage gallery_images items must not exceed 2000 characters', function () { test('homepage gallery_image_keys items must not exceed 2000 characters', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [ $response = $this->put(route('admin.settings.update-homepage'), [
'gallery_images' => [str_repeat('a', 2001)], 'gallery_image_keys' => [str_repeat('a', 2001)],
]); ]);
$response->assertSessionHasErrors('gallery_images.0'); $response->assertSessionHasErrors('gallery_image_keys.0');
}); });
/* /*
@ -851,8 +851,8 @@
// Update homepage // Update homepage
$this->put(route('admin.settings.update-homepage'), [ $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://example.com/new-hero.jpg', 'hero_image_key' => 'homepage/hero/2026/07/31/new-hero.jpg',
'about_image_url' => 'https://example.com/new-about.jpg', 'about_image_key' => 'homepage/about/2026/07/31/new-about.jpg',
])->assertSessionHasNoErrors(); ])->assertSessionHasNoErrors();
// Update social media // Update social media
@ -898,7 +898,7 @@
$hr = app(HRSettings::class); $hr = app(HRSettings::class);
expect($system->app_name)->toBe('DST Updated'); expect($system->app_name)->toBe('DST Updated');
expect($homepage->hero_image_url)->toBe('https://example.com/new-hero.jpg'); expect($homepage->hero_image_url)->toBe('homepage/hero/2026/07/31/new-hero.jpg');
expect($socialMedia->instagram_url)->toBe('https://instagram.com/newdst'); expect($socialMedia->instagram_url)->toBe('https://instagram.com/newdst');
expect($marketplace->tiktok_shop_platform_commission->value)->toBe(3.0); expect($marketplace->tiktok_shop_platform_commission->value)->toBe(3.0);
expect($hr->scheduled_check_in_time)->toBe('08:30'); expect($hr->scheduled_check_in_time)->toBe('08:30');