feat: update homepage settings to use image keys and add presigned URL retrieval
This commit is contained in:
parent
a1a73cb222
commit
d07eb3498a
@ -6,6 +6,7 @@
|
||||
use App\Http\Requests\Api\PresignedUrlRequest;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class PresignedUrlController extends Controller
|
||||
{
|
||||
@ -25,4 +26,12 @@ public function store(PresignedUrlRequest $request): JsonResponse
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Admin\Settings;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Override;
|
||||
|
||||
class UpdateHRRequest extends FormRequest
|
||||
{
|
||||
@ -11,6 +12,22 @@ public function authorize(): bool
|
||||
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
|
||||
{
|
||||
return [
|
||||
|
||||
@ -14,19 +14,19 @@ public function authorize(): bool
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'hero_image_url' => ['nullable', 'string', 'max:2000'],
|
||||
'about_image_url' => ['nullable', 'string', 'max:2000'],
|
||||
'gallery_images' => ['nullable', 'array'],
|
||||
'gallery_images.*' => ['string', 'max:2000'],
|
||||
'hero_image_key' => ['nullable', 'string', 'max:2000'],
|
||||
'about_image_key' => ['nullable', 'string', 'max:2000'],
|
||||
'gallery_image_keys' => ['nullable', 'array'],
|
||||
'gallery_image_keys.*' => ['string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'hero_image_url' => 'url foto hero',
|
||||
'about_image_url' => 'url foto tentang kami',
|
||||
'gallery_images' => 'gambar galeri',
|
||||
'hero_image_key' => 'foto hero',
|
||||
'about_image_key' => 'foto tentang kami',
|
||||
'gallery_image_keys' => 'gambar galeri',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Services\S3PresignedService;
|
||||
use App\Settings\HRSettings;
|
||||
use App\Settings\HomepageSettings;
|
||||
use App\Settings\MarketplaceSettings;
|
||||
@ -11,6 +12,10 @@
|
||||
|
||||
class AdminSettingsService
|
||||
{
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service = new S3PresignedService,
|
||||
) {}
|
||||
|
||||
public function getSystemData(): array
|
||||
{
|
||||
$settings = app(SystemSettings::class);
|
||||
@ -29,12 +34,27 @@ public function getHomepageData(): array
|
||||
$settings = app(HomepageSettings::class);
|
||||
|
||||
return [
|
||||
'hero_image_url' => $settings->hero_image_url,
|
||||
'about_image_url' => $settings->about_image_url,
|
||||
'gallery_images' => $settings->gallery_images,
|
||||
'hero_image_url' => $settings->hero_image_url ? $this->getTemporaryUrl($settings->hero_image_url) : null,
|
||||
'hero_image_key' => $settings->hero_image_url,
|
||||
'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
|
||||
{
|
||||
$settings = app(SocialMediaSettings::class);
|
||||
@ -95,7 +115,11 @@ public function updateSystem(array $data): void
|
||||
public function updateHomepage(array $data): void
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FileImage, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useId } from 'react';
|
||||
import {
|
||||
Attachment,
|
||||
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) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadId = useId();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = 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}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
id={uploadId}
|
||||
/>
|
||||
|
||||
<Attachment state={state} orientation="horizontal" className='w-full'>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { FileUpload } from '@/components/file-upload';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PhoneNumberInput } from '@/components/phone-number-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 { Textarea } from '@/components/ui/textarea';
|
||||
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 { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type MarketplaceFeeRule = {
|
||||
@ -30,8 +32,11 @@ type Props = {
|
||||
};
|
||||
homepage: {
|
||||
hero_image_url: string;
|
||||
hero_image_key: string;
|
||||
about_image_url: string;
|
||||
about_image_key: string;
|
||||
gallery_images: string[];
|
||||
gallery_image_keys: string[];
|
||||
};
|
||||
socialMedia: {
|
||||
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 = [
|
||||
{ key: 'sistem', label: 'Sistem' },
|
||||
{ key: 'homepage', label: 'Homepage' },
|
||||
@ -249,32 +372,7 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
||||
)}
|
||||
|
||||
{activeTab === 'homepage' && (
|
||||
<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">
|
||||
<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>
|
||||
<HomepageSettingsTab homepage={homepage} />
|
||||
)}
|
||||
|
||||
{activeTab === 'media-sosial' && (
|
||||
@ -336,7 +434,7 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
||||
)}
|
||||
|
||||
{activeTab === 'hr' && (
|
||||
<Form action="/admin/settings/hr" options={{ preserveScroll: true }}>
|
||||
<Form action={updateHr()} options={{ preserveScroll: true }}>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
|
||||
@ -6,3 +6,7 @@
|
||||
Route::post('/presigned-url', [PresignedUrlController::class, 'store'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('presigned-url.store');
|
||||
|
||||
Route::get('/presigned-url/{key}', [PresignedUrlController::class, 'show'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('presigned-url.show');
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
|
||||
test('guest cannot update homepage settings', function () {
|
||||
$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'));
|
||||
});
|
||||
@ -257,9 +257,9 @@
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->put(route('admin.settings.update-homepage'), [
|
||||
'hero_image_url' => 'https://example.com/hero.jpg',
|
||||
'about_image_url' => 'https://example.com/about.jpg',
|
||||
'gallery_images' => ['https://example.com/g1.jpg', 'https://example.com/g2.jpg'],
|
||||
'hero_image_key' => 'homepage/hero/2026/07/31/abc123/hero.jpg',
|
||||
'about_image_key' => 'homepage/about/2026/07/31/def456/about.jpg',
|
||||
'gallery_image_keys' => ['homepage/gallery/2026/07/31/g1/img.jpg', 'homepage/gallery/2026/07/31/g2/img.jpg'],
|
||||
]);
|
||||
|
||||
$response
|
||||
@ -267,9 +267,9 @@
|
||||
->assertRedirect(route('admin.settings.index'));
|
||||
|
||||
$settings = app(HomepageSettings::class);
|
||||
expect($settings->hero_image_url)->toBe('https://example.com/hero.jpg');
|
||||
expect($settings->about_image_url)->toBe('https://example.com/about.jpg');
|
||||
expect($settings->gallery_images)->toBe(['https://example.com/g1.jpg', 'https://example.com/g2.jpg']);
|
||||
expect($settings->hero_image_url)->toBe('homepage/hero/2026/07/31/abc123/hero.jpg');
|
||||
expect($settings->about_image_url)->toBe('homepage/about/2026/07/31/def456/about.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 () {
|
||||
@ -277,44 +277,44 @@
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->put(route('admin.settings.update-homepage'), [
|
||||
'hero_image_url' => null,
|
||||
'about_image_url' => null,
|
||||
'hero_image_key' => null,
|
||||
'about_image_key' => null,
|
||||
]);
|
||||
|
||||
$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();
|
||||
$this->actingAs($user);
|
||||
|
||||
$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();
|
||||
$this->actingAs($user);
|
||||
|
||||
$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();
|
||||
$this->actingAs($user);
|
||||
|
||||
$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
|
||||
$this->put(route('admin.settings.update-homepage'), [
|
||||
'hero_image_url' => 'https://example.com/new-hero.jpg',
|
||||
'about_image_url' => 'https://example.com/new-about.jpg',
|
||||
'hero_image_key' => 'homepage/hero/2026/07/31/new-hero.jpg',
|
||||
'about_image_key' => 'homepage/about/2026/07/31/new-about.jpg',
|
||||
])->assertSessionHasNoErrors();
|
||||
|
||||
// Update social media
|
||||
@ -898,7 +898,7 @@
|
||||
$hr = app(HRSettings::class);
|
||||
|
||||
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($marketplace->tiktok_shop_platform_commission->value)->toBe(3.0);
|
||||
expect($hr->scheduled_check_in_time)->toBe('08:30');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user