diff --git a/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php b/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php index c961bead..31b7040b 100644 --- a/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php +++ b/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php @@ -2,17 +2,30 @@ namespace App\Domains\Core\Tenant\Controllers\AdminApp; +use App\Domains\Core\Tenant\Requests\AdminApp\UpdateBrandRequest; use App\Domains\Core\Tenant\Resources\AdminApp\BrandResource; +use App\Domains\Core\Tenant\Services\BrandService; use App\Domains\Core\Tenant\Services\TenantInformationService; use App\Http\Controllers\Controller; use Illuminate\Http\Request; class BrandController extends Controller { - public function __invoke(Request $request, TenantInformationService $tenantInformationService): BrandResource + public function show(Request $request, TenantInformationService $tenantInformationService): BrandResource { $tenant = $request->user()->tenant()->firstOrFail(); return BrandResource::make($tenantInformationService->load($tenant)); } + + public function update( + UpdateBrandRequest $request, + BrandService $brandService, + TenantInformationService $tenantInformationService, + ): BrandResource { + $tenant = $request->user()->tenant()->firstOrFail(); + $brandService->update($tenant, $request->validated()); + + return BrandResource::make($tenantInformationService->load($tenant->fresh())); + } } diff --git a/app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php b/app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php new file mode 100644 index 00000000..4d1d9ebd --- /dev/null +++ b/app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php @@ -0,0 +1,31 @@ + */ + public function rules(): array + { + $shopit = $this->user()->tenant()->firstOrFail()->storefront_website_type_code === 'shopit'; + + return [ + 'nombre' => ['required', 'string', 'max:255'], + 'primary_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'footer_bg_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'header_logo' => ['present', 'nullable', new ImageOrBase64Rule], + 'footer_logo' => ['present', 'nullable', new ImageOrBase64Rule], + 'banner_images' => [$shopit ? 'present' : 'prohibited', 'array', 'max:10'], + 'banner_images.*' => ['required', new CroppedImageOrBase64Rule], + ]; + } +} diff --git a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php index 0a05fb07..9f1de64c 100644 --- a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php +++ b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php @@ -20,7 +20,10 @@ class BrandResource extends JsonResource 'header_bg_color' => $this->header_bg_color, 'footer_color' => $this->footerColor(), 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), + 'header_logo_key' => $this->headerLogo?->key, 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), + 'footer_logo_key' => $this->footerLogo?->key, + 'can_edit_carousel' => $this->canEditCarousel(), 'banner_images' => $this->bannerImages(), ]; } @@ -48,7 +51,7 @@ class BrandResource extends JsonResource /** @return array> */ private function bannerImages(): array { - if ($this->storefront_website_type_code !== 'shopit') { + if (! $this->canEditCarousel()) { return []; } @@ -73,6 +76,7 @@ class BrandResource extends JsonResource ->firstWhere('variant', AttachmentCrop::DESKTOP); return [ + 'key' => $image->key, 'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440), 'original_url' => $image->getTemporaryUrl(1440), 'crop' => $desktopCrop === null ? null : [ @@ -84,4 +88,17 @@ class BrandResource extends JsonResource ->values() ->all(); } + + private function canEditCarousel(): bool + { + if ($this->storefront_website_type_code !== 'shopit') { + return false; + } + + $extra = $this->websiteExtras->first( + fn ($extra) => $extra->websiteTypeExtra->codigo === 'carousel' + ); + + return $extra === null || $extra->is_enabled; + } } diff --git a/app/Domains/Core/Tenant/Services/BrandService.php b/app/Domains/Core/Tenant/Services/BrandService.php new file mode 100644 index 00000000..0f940095 --- /dev/null +++ b/app/Domains/Core/Tenant/Services/BrandService.php @@ -0,0 +1,165 @@ + $data */ + public function update(Tenant $tenant, array $data): void + { + $carousel = $tenant->storefront_website_type_code === 'shopit' + ? $tenant->websiteExtras()->whereHas('websiteTypeExtra', + fn ($query) => $query->where('codigo', 'carousel'))->first() + : null; + $allowedBannerIds = is_array($carousel?->config) ? $carousel->config : []; + if ($carousel !== null && ! $carousel->is_enabled && ($data['banner_images'] ?? []) !== []) { + throw ValidationException::withMessages(['banner_images' => 'El carrusel está deshabilitado.']); + } + $seen = []; + + foreach (['header_logo' => $tenant->header_logo_id, 'footer_logo' => $tenant->footer_logo_id] as $field => $id) { + $this->existingImage($data[$field], $id === null ? [] : [$id], $field); + } + foreach ($data['banner_images'] ?? [] as $index => $item) { + $image = is_array($item) ? $item['image'] : $item; + $attachment = $this->existingImage($image, $allowedBannerIds, "banner_images.{$index}"); + if ($attachment !== null) { + if (isset($seen[$attachment->id])) { + throw ValidationException::withMessages(["banner_images.{$index}" => 'La imagen está repetida.']); + } + $seen[$attachment->id] = true; + } + } + + $created = []; + + try { + DB::transaction(function () use ($tenant, $data, $carousel, &$created): void { + $header = $this->logo($data['header_logo'], $tenant->header_logo_id, $tenant, $created); + $footer = $this->logo($data['footer_logo'], $tenant->footer_logo_id, $tenant, $created); + + $tenant->update([ + 'nombre' => trim($data['nombre']), + 'primary_color' => $data['primary_color'], + 'footer_bg_color' => $data['footer_bg_color'], + 'header_logo_id' => $header?->id, + 'footer_logo_id' => $footer?->id, + ]); + + if ($tenant->storefront_website_type_code !== 'shopit' + || ($carousel !== null && ! $carousel->is_enabled)) { + return; + } + + $definition = $this->websiteExtraService->definitionForTenant($tenant, 'carousel'); + $ids = []; + $recrops = []; + foreach ($data['banner_images'] as $item) { + $image = is_array($item) ? $item['image'] : $item; + $attachment = $this->existingImage($image, $carousel?->config ?? [], 'banner_images'); + $crops = $this->crops($item); + + if ($attachment === null) { + $attachment = $crops === null + ? $this->attachmentService->store($image, "tenants/{$tenant->codigo}/extras/carousel") + : $this->attachmentService->storeCroppedImageVariants( + $image, "tenants/{$tenant->codigo}/extras/carousel", $crops + ); + $created[] = $attachment; + } elseif ($crops !== null) { + $recrops[] = [$attachment, $crops]; + } + $ids[] = $attachment->id; + } + + $tenant->websiteExtras()->updateOrCreate( + ['website_type_extra_id' => $definition->id], + ['config' => $ids] + ); + + foreach ($recrops as [$attachment, $crops]) { + $this->attachmentService->updateImageCropVariants($attachment, $crops); + } + }); + } catch (Throwable $error) { + foreach ($created as $attachment) { + $paths = [$attachment->path]; + foreach ($attachment->cropVariants as $crop) { + if ($crop->croppedAttachment !== null) { + $paths[] = $crop->croppedAttachment->path; + } + } + Storage::disk('s3')->delete($paths); + } + throw $error; + } + } + + /** @param array $allowedIds */ + private function existingImage(mixed $value, array $allowedIds, string $field): ?Attachment + { + if (! is_string($value) || ! Str::isUuid($value)) { + return null; + } + + $attachment = Attachment::query()->where('key', $value)->first(); + if ($attachment === null || $attachment->type !== AttachmentType::Image + || ! in_array($attachment->id, $allowedIds, true)) { + throw ValidationException::withMessages([$field => 'La imagen no pertenece a esta marca.']); + } + + return $attachment; + } + + /** @param array $created */ + private function logo(mixed $value, ?int $currentId, Tenant $tenant, array &$created): ?Attachment + { + if ($value === null) { + return null; + } + + $existing = $this->existingImage($value, $currentId === null ? [] : [$currentId], 'logo'); + if ($existing !== null) { + return $existing; + } + + $attachment = $this->attachmentService->store($value, "tenants/{$tenant->codigo}/logos"); + $created[] = $attachment; + + return $attachment; + } + + /** @return array>|null */ + private function crops(mixed $item): ?array + { + if (! is_array($item)) { + return null; + } + + if (! isset($item['crop_horizontal'], $item['crop_vertical'])) { + return null; + } + + $crop = [ + 'crop_horizontal' => $item['crop_horizontal'], + 'crop_vertical' => $item['crop_vertical'], + ]; + + return ['desktop' => $crop, 'mobile' => $crop]; + } +} diff --git a/app/Domains/Core/Tenant/routes/adminapp.php b/app/Domains/Core/Tenant/routes/adminapp.php index caf331d1..99f744da 100644 --- a/app/Domains/Core/Tenant/routes/adminapp.php +++ b/app/Domains/Core/Tenant/routes/adminapp.php @@ -7,7 +7,8 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/tenant') ->middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { - Route::get('brand', BrandController::class); + Route::get('brand', [BrandController::class, 'show']); + Route::put('brand', [BrandController::class, 'update']); Route::get('website-extras', [WebsiteExtraController::class, 'show']); Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']); Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']); diff --git a/tests/Feature/Tenant/AdminAppBrandControllerTest.php b/tests/Feature/Tenant/AdminAppBrandControllerTest.php index db864ac7..0395c8f7 100644 --- a/tests/Feature/Tenant/AdminAppBrandControllerTest.php +++ b/tests/Feature/Tenant/AdminAppBrandControllerTest.php @@ -11,6 +11,7 @@ use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\AttachmentCrop; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -80,8 +81,10 @@ class AdminAppBrandControllerTest extends TestCase foreach (['header_logo' => $header, 'footer_logo' => $footer] as $field => $image) { $this->assertStringContainsString($image->path, $response->json("data.{$field}")); + $response->assertJsonPath("data.{$field}_key", $image->key); } foreach ([$first, $second] as $index => $image) { + $response->assertJsonPath("data.banner_images.{$index}.key", $image->key); $this->assertStringContainsString( $image->path, $response->json("data.banner_images.{$index}.original_url") @@ -94,6 +97,63 @@ class AdminAppBrandControllerTest extends TestCase $this->assertStringNotContainsString('other.png', $response->getContent()); } + public function test_update_keeps_original_carousel_attachment_and_returns_its_crop(): void + { + $websiteType = $this->websiteType('shopit'); + $carousel = $websiteType->extras()->create([ + 'codigo' => 'carousel', 'nombre' => 'Carrusel', 'is_required' => false, + 'config_schema' => ['request_rules' => [], 'transforms' => [ + '$.*' => ['handler' => 'attachment', 'attachment_type' => 'image'], + ]], + ]); + $tenant = $this->tenant('acme', $websiteType, [ + 'primary_color' => '#112233', 'footer_bg_color' => '#112233', + ]); + Sanctum::actingAs($this->admin($tenant)); + $file = UploadedFile::fake()->image('banner.png', 400, 100); + $dataUrl = 'data:image/png;base64,'.base64_encode(file_get_contents($file->getRealPath())); + + $response = $this->putJson('/api/v1/adminapp/tenant/brand', [ + 'nombre' => 'Acme Nueva', 'primary_color' => '#abcdef', + 'footer_bg_color' => '#abcdef', 'header_logo' => null, 'footer_logo' => null, + 'banner_images' => [[ + 'image' => $dataUrl, + 'crop_horizontal' => ['start_percentage' => 10, 'end_percentage' => 90], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ]], + ])->assertOk() + ->assertJsonPath('data.nombre', 'Acme Nueva') + ->assertJsonPath('data.footer_color', 'primary') + ->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 10); + + $original = Attachment::query()->where('key', $response->json('data.banner_images.0.key'))->firstOrFail(); + $this->assertSame([$original->id], $tenant->websiteExtras() + ->where('website_type_extra_id', $carousel->id)->firstOrFail()->config); + $this->assertStringContainsString($original->path, $response->json('data.banner_images.0.original_url')); + $this->assertNotSame($response->json('data.banner_images.0.original_url'), + $response->json('data.banner_images.0.url')); + + $updated = $this->putJson('/api/v1/adminapp/tenant/brand', [ + 'nombre' => 'Acme Nueva', 'primary_color' => '#abcdef', + 'footer_bg_color' => '#abcdef', 'header_logo' => null, 'footer_logo' => null, + 'banner_images' => [[ + 'image' => $original->key, + 'crop_horizontal' => ['start_percentage' => 20, 'end_percentage' => 80], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ]], + ])->assertOk() + ->assertJsonPath('data.banner_images.0.key', $original->key) + ->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 20); + $this->assertSame([$original->id], $tenant->websiteExtras() + ->where('website_type_extra_id', $carousel->id)->firstOrFail()->config); + $this->assertNotSame($response->json('data.banner_images.0.url'), + $updated->json('data.banner_images.0.url')); + + $this->assertSame('#abcdef', $tenant->refresh()->footer_bg_color); + $tenant->update(['primary_color' => '#123456']); + $this->assertSame('#abcdef', $tenant->refresh()->footer_bg_color); + } + public function test_footer_color_is_mapped_to_a_supported_option(): void { $websiteType = $this->websiteType('shopit');