diff --git a/database/migrations/2026_09_18_000000_add_onticket_immersive_hero_configuration.php b/database/migrations/2026_09_18_000000_add_onticket_immersive_hero_configuration.php new file mode 100644 index 0000000..d6b236e --- /dev/null +++ b/database/migrations/2026_09_18_000000_add_onticket_immersive_hero_configuration.php @@ -0,0 +1,93 @@ +updateOrInsert( + ['codigo' => 'onticket_multi_event'], + [ + 'nombre' => 'OnTicket - múltiples eventos', + 'header_type' => 'immersive', + 'updated_at' => $now, + ] + (DB::table('storefront_website_types')->where('codigo', 'onticket_multi_event')->exists() + ? [] + : ['created_at' => $now]), + ); + + $extraKey = [ + 'storefront_website_type_code' => 'onticket_multi_event', + 'codigo' => 'immersiveHero', + ]; + + DB::table('storefront_website_type_extras')->updateOrInsert( + $extraKey, + [ + 'nombre' => 'Textos del hero inmersivo', + 'descripcion' => 'Frase superior, título y descripción de la portada.', + 'is_required' => false, + 'config_schema' => json_encode([ + 'request_rules' => [ + '$' => 'required|array:eyebrow,title,description', + 'eyebrow' => 'required|string|max:120', + 'title' => 'required|string|max:120', + 'description' => 'required|string|max:255', + ], + 'transforms' => [], + ], JSON_THROW_ON_ERROR), + 'updated_at' => $now, + ] + (DB::table('storefront_website_type_extras')->where($extraKey)->exists() + ? [] + : ['created_at' => $now]), + ); + + $tenant = DB::table('tenants') + ->where('codigo', 'onticket') + ->whereIn('storefront_website_type_code', ['onticket', 'onticket_multi_event']) + ->first(['codigo']); + + if ($tenant === null) { + return; + } + + DB::table('tenants')->where('codigo', $tenant->codigo)->update([ + 'storefront_website_type_code' => 'onticket_multi_event', + 'display_seach_bar' => true, + 'updated_at' => $now, + ]); + + $extraId = DB::table('storefront_website_type_extras') + ->where($extraKey) + ->value('id'); + + if (! DB::table('websites_extras') + ->where('website_code', $tenant->codigo) + ->where('website_type_extra_id', $extraId) + ->exists()) { + DB::table('websites_extras')->insert([ + 'website_code' => $tenant->codigo, + 'website_type_extra_id' => $extraId, + 'config' => json_encode([ + 'eyebrow' => 'Encendé tu', + 'title' => 'experiencia', + 'description' => 'Reservá tu entrada y formá parte', + ], JSON_THROW_ON_ERROR), + 'is_enabled' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + }); + } + + public function down(): void + { + // This data change is intentionally retained to avoid removing tenant edits. + } +}; diff --git a/database/migrations/2026_09_18_000100_add_onticket_immersive_hero_carousel.php b/database/migrations/2026_09_18_000100_add_onticket_immersive_hero_carousel.php new file mode 100644 index 0000000..5ac752d --- /dev/null +++ b/database/migrations/2026_09_18_000100_add_onticket_immersive_hero_carousel.php @@ -0,0 +1,53 @@ + 'onticket_multi_event']; + $definitions = DB::table('storefront_website_type_extras')->where($key); + + if ((clone $definitions)->where('codigo', 'immersiveHeroCarousel')->exists()) { + return; + } + + if ((clone $definitions)->where('codigo', 'heroCarousel')->exists()) { + (clone $definitions)->where('codigo', 'heroCarousel')->update([ + 'codigo' => 'immersiveHeroCarousel', + 'updated_at' => now(), + ]); + + return; + } + + DB::table('storefront_website_type_extras')->insert([ + ...$key, + 'codigo' => 'immersiveHeroCarousel', + 'nombre' => 'Carrusel del hero', + 'descripcion' => 'Lista ordenada de imágenes del hero.', + 'is_required' => false, + 'config_schema' => json_encode([ + 'request_rules' => [ + '$' => 'required|array|max:10', + '$.*' => 'required|image_or_base64|distinct', + ], + 'transforms' => [ + '$.*' => [ + 'handler' => 'attachment', + 'attachment_type' => 'image', + ], + ], + ], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + // Preserve configured images and tenant edits. + } +}; diff --git a/database/migrations/2026_09_18_000200_add_onticket_categories.php b/database/migrations/2026_09_18_000200_add_onticket_categories.php new file mode 100644 index 0000000..1f60c2b --- /dev/null +++ b/database/migrations/2026_09_18_000200_add_onticket_categories.php @@ -0,0 +1,35 @@ +where('codigo', 'onticket')->exists()) { + return; + } + + DB::transaction(function (): void { + $now = now(); + + foreach (['Música', 'Teatro', 'Deportes', 'Infantiles', 'Otros'] as $nombre) { + $category = ['tenant_code' => 'onticket', 'nombre' => $nombre]; + + if (! DB::table('categorias')->where($category)->exists()) { + DB::table('categorias')->insert($category + [ + 'is_enabled' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + }); + } + + public function down(): void + { + // Retain categories because they may have been edited or assigned to products. + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php index c6f51ba..5003970 100644 --- a/database/seeders/CategorySeeder.php +++ b/database/seeders/CategorySeeder.php @@ -13,6 +13,17 @@ class CategorySeeder extends Seeder */ public function run(): void { + $onTicket = Tenant::query()->where('codigo', 'onticket')->first(); + + if ($onTicket instanceof Tenant) { + foreach (['Música', 'Teatro', 'Deportes', 'Infantiles', 'Otros'] as $nombre) { + Category::firstOrCreate([ + 'nombre' => $nombre, + 'tenant_code' => $onTicket->codigo, + ]); + } + } + $tenant = Tenant::query()->where('codigo', 'sonder')->first(); if (! $tenant instanceof Tenant) { diff --git a/database/seeders/OnticketImmersiveHeroCarouselSeeder.php b/database/seeders/OnticketImmersiveHeroCarouselSeeder.php new file mode 100644 index 0000000..0340594 --- /dev/null +++ b/database/seeders/OnticketImmersiveHeroCarouselSeeder.php @@ -0,0 +1,59 @@ +where('codigo', 'onticket')->first(); + + if ($tenant?->storefront_website_type_code !== 'onticket_multi_event') { + return; + } + + if ($tenant->websiteExtras()->whereHas( + 'websiteTypeExtra', + fn ($query) => $query->where('codigo', 'immersiveHeroCarousel') + )->exists()) { + return; + } + + $images = array_map(function (string $filename): UploadedFile { + $path = public_path("images/tennants/onticket/onticket_tennant_hero_carousel/{$filename}"); + + if (! is_file($path)) { + throw new RuntimeException("Image not found at path: {$path}"); + } + + $mimeType = match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) { + 'jpg', 'jpeg', 'jfif' => 'image/jpeg', + 'avif' => 'image/avif', + default => 'image/png', + }; + + return new UploadedFile($path, $filename, $mimeType, null, true); + }, self::IMAGES); + + $this->websiteExtraService->updateForTenant($tenant, 'immersiveHeroCarousel', $images); + } +} diff --git a/database/seeders/TenantSeeder.php b/database/seeders/TenantSeeder.php index d158e5d..b3c9c7d 100644 --- a/database/seeders/TenantSeeder.php +++ b/database/seeders/TenantSeeder.php @@ -20,17 +20,6 @@ class TenantSeeder extends Seeder private const SONDER_CLIENT_CODE = 'sonder'; - private const ONTICKET_HERO_CAROUSEL_IMAGES = [ - 'onticket_hero_carousel_1.png', - 'onticket_hero_carousel_2.png', - 'onticket_hero_carousel_3.avif', - 'onticket_hero_carousel_4.jfif', - 'onticket_hero_carousel_5.jfif', - 'onticket_hero_carousel_6.jfif', - 'onticket_hero_carousel_7.avif', - 'onticket_hero_carousel_8.avif', - ]; - private const SOCIAL_MEDIA = [ [ 'code' => 'instagram', @@ -89,7 +78,7 @@ class TenantSeeder extends Seeder 'header_bg_color' => $onTicketType->surface_color, 'footer_bg_color' => $onTicketType->surface_color, 'display_categories' => false, - 'display_seach_bar' => false, + 'display_seach_bar' => true, 'admin_website_type_code' => $onTicketType->codigo, 'storefront_website_type_code' => 'onticket_multi_event', 'header_logo' => $this->uploadedImage( @@ -124,23 +113,23 @@ class TenantSeeder extends Seeder $onTicketTenant = Tenant::query()->where('codigo', 'onticket')->firstOrFail(); + if ($onTicketTenant->storefront_website_type_code === 'onticket_multi_event' && ! $onTicketTenant->display_seach_bar) { + $onTicketTenant->update(['display_seach_bar' => true]); + } + if ( $onTicketTenant->storefront_website_type_code === 'onticket_multi_event' - && ! $onTicketTenant->websiteExtras()->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'heroCarousel'))->exists() + && ! $onTicketTenant->websiteExtras()->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'immersiveHero'))->exists() ) { - $this->websiteExtraService->updateForTenant( - $onTicketTenant, - 'heroCarousel', - array_map( - fn (string $filename): UploadedFile => $this->uploadedImage( - "images/tennants/onticket/onticket_tennant_hero_carousel/{$filename}", - $filename, - ), - self::ONTICKET_HERO_CAROUSEL_IMAGES, - ), - ); + $this->websiteExtraService->updateForTenant($onTicketTenant, 'immersiveHero', [ + 'eyebrow' => 'Encendé tu', + 'title' => 'experiencia', + 'description' => 'Reservá tu entrada y formá parte', + ]); } + $this->call(OnticketImmersiveHeroCarouselSeeder::class); + $this->deleteTenant('sonder'); Tenant::query() diff --git a/database/seeders/WebsiteTypeSeeder.php b/database/seeders/WebsiteTypeSeeder.php index c6ebef2..20e0a0c 100644 --- a/database/seeders/WebsiteTypeSeeder.php +++ b/database/seeders/WebsiteTypeSeeder.php @@ -94,7 +94,7 @@ class WebsiteTypeSeeder extends Seeder ); $multiEventStorefront->extras()->updateOrCreate( - ['codigo' => 'heroCarousel'], + ['codigo' => 'immersiveHeroCarousel'], [ 'nombre' => 'Carrusel del hero', 'descripcion' => 'Lista ordenada de imágenes del hero.', @@ -103,6 +103,24 @@ class WebsiteTypeSeeder extends Seeder ], ); + $multiEventStorefront->extras()->updateOrCreate( + ['codigo' => 'immersiveHero'], + [ + 'nombre' => 'Textos del hero inmersivo', + 'descripcion' => 'Frase superior, título y descripción de la portada.', + 'is_required' => false, + 'config_schema' => [ + 'request_rules' => [ + '$' => 'required|array:eyebrow,title,description', + 'eyebrow' => 'required|string|max:120', + 'title' => 'required|string|max:120', + 'description' => 'required|string|max:255', + ], + 'transforms' => [], + ], + ], + ); + $onTicketStorefront->extras()->updateOrCreate( ['codigo' => 'heroConfig'], [ diff --git a/storage/framework/lsp-3d8bcef093e37c90.php b/storage/framework/lsp-3d8bcef093e37c90.php new file mode 100644 index 0000000..f14d849 --- /dev/null +++ b/storage/framework/lsp-3d8bcef093e37c90.php @@ -0,0 +1,130 @@ +hasDefaultValue()) { +return ['default' => $property->getDefaultValue()]; +} + +if ($parameter?->isDefaultValueAvailable()) { +return ['default' => $parameter->getDefaultValue()]; +} + +return []; +} + +public static function formatDefaultValue(mixed $value): mixed +{ +return match (true) { +is_array($value) => 'array(...)', +$value instanceof UnitEnum => get_class($value) . '::' . $value->name, +$value instanceof Closure => 'Closure', +is_object($value) => get_class($value), +is_string($value) => var_export($value, true), +is_null($value) => 'null', +is_bool($value) => $value ? 'true' : 'false', +default => $value, +}; +} +} + +use Pest\Expectation; +use Pest\TestSuite; + +$pest = new class +{ +public function __construct() +{ +if ($this->isInstalled()) { +$this->boot(); +} +} + +public function isInstalled(): bool +{ +return class_exists(TestSuite::class); +} + +protected function boot(): void +{ +require_once base_path('vendor/pestphp/pest/overrides/Runner/TestSuiteLoader.php'); + +TestSuite::getInstance(base_path(), 'tests'); + +if (file_exists($pestFile = base_path('tests/Pest.php'))) { +require_once $pestFile; +} +} + +public function config(): ?array +{ +if (!$this->isInstalled()) { +return null; +} + +return [ +'uses' => $this->uses(), +'expectations' => $this->expectations(), +]; +} + +protected function uses(): array +{ +if (is_null($instance = TestSuite::getInstance())) { +return []; +} + +$reflection = new ReflectionProperty($instance->tests, 'uses'); +$uses = $reflection->getValue($instance->tests); + +return collect($uses)->map(function (array $use, string $path) { +[$classOrTraits] = $use; + +return [ +'path' => LspHelper::relativePath($path), +'classes' => array_values(array_filter($classOrTraits, fn ($c) => class_exists($c))), +'traits' => array_values(array_filter($classOrTraits, fn ($c) => trait_exists($c))), +]; +})->values()->all(); +} + +protected function expectations(): array +{ +$reflection = new ReflectionProperty(Expectation::class, 'extends'); +$extends = $reflection->getValue(); + +return collect($extends)->map(function (Closure $closure, string $name) { +$parameters = collect((new ReflectionFunction($closure))->getParameters()) +->map(function (ReflectionParameter $param) { +$type = $param->hasType() ? $param->getType() . ' ' : ''; + +$default = $param->isOptional() && $param->isDefaultValueAvailable() +? ' = ' . var_export($param->getDefaultValue(), true) +: ''; + +return $type . '$' . $param->getName() . $default; +}) +->join(', '); + +return compact('name', 'parameters'); +})->values()->all(); +} +}; + +echo json_encode($pest->config()); diff --git a/tests/Feature/Migrations/AddOnticketImmersiveHeroConfigurationTest.php b/tests/Feature/Migrations/AddOnticketImmersiveHeroConfigurationTest.php new file mode 100644 index 0000000..e6231f0 --- /dev/null +++ b/tests/Feature/Migrations/AddOnticketImmersiveHeroConfigurationTest.php @@ -0,0 +1,96 @@ +id(); + $table->string('codigo')->unique(); + $table->string('nombre'); + $table->string('header_type'); + $table->timestamps(); + }); + Schema::create('storefront_website_type_extras', function (Blueprint $table): void { + $table->id(); + $table->string('storefront_website_type_code'); + $table->string('codigo'); + $table->string('nombre'); + $table->text('descripcion'); + $table->boolean('is_required'); + $table->json('config_schema'); + $table->timestamps(); + $table->unique(['storefront_website_type_code', 'codigo']); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->string('codigo')->unique(); + $table->string('storefront_website_type_code'); + $table->boolean('display_seach_bar'); + $table->timestamps(); + }); + Schema::create('websites_extras', function (Blueprint $table): void { + $table->id(); + $table->string('website_code'); + $table->foreignId('website_type_extra_id'); + $table->json('config'); + $table->boolean('is_enabled'); + $table->timestamps(); + $table->unique(['website_code', 'website_type_extra_id']); + }); + + DB::table('storefront_website_types')->insert([ + 'codigo' => 'onticket_multi_event', 'nombre' => 'OnTicket', 'header_type' => 'immersive', + ]); + DB::table('tenants')->insert([ + ['id' => 10, 'codigo' => 'onticket', 'storefront_website_type_code' => 'onticket', 'display_seach_bar' => false], + ['id' => 20, 'codigo' => 'sonder', 'storefront_website_type_code' => 'shopit', 'display_seach_bar' => false], + ]); + DB::table('storefront_website_type_extras')->insert([ + 'id' => 30, 'storefront_website_type_code' => 'onticket_multi_event', 'codigo' => 'heroCarousel', + 'nombre' => 'Carrusel', 'descripcion' => 'Existente', 'is_required' => false, 'config_schema' => '{}', + ]); + DB::table('websites_extras')->insert([ + 'id' => 40, 'website_code' => 'onticket', 'website_type_extra_id' => 30, + 'config' => '[1,2]', 'is_enabled' => true, + ]); + + $migration = require database_path('migrations/2026_09_18_000000_add_onticket_immersive_hero_configuration.php'); + $migration->up(); + + $this->assertSame(10, DB::table('tenants')->where('codigo', 'onticket')->value('id')); + $this->assertSame(20, DB::table('tenants')->where('codigo', 'sonder')->value('id')); + $this->assertSame(0, DB::table('tenants')->where('codigo', 'sonder')->value('display_seach_bar')); + $this->assertSame(1, DB::table('tenants')->where('codigo', 'onticket')->value('display_seach_bar')); + $this->assertSame('onticket_multi_event', DB::table('tenants')->where('codigo', 'onticket')->value('storefront_website_type_code')); + $this->assertSame('[1,2]', DB::table('websites_extras')->where('id', 40)->value('config')); + + $extraId = DB::table('storefront_website_type_extras') + ->where('storefront_website_type_code', 'onticket_multi_event') + ->where('codigo', 'immersiveHero')->value('id'); + $this->assertNotNull($extraId); + $websiteExtra = DB::table('websites_extras') + ->where('website_code', 'onticket')->where('website_type_extra_id', $extraId)->sole(); + $this->assertSame([ + 'eyebrow' => 'Encendé tu', 'title' => 'experiencia', + 'description' => 'Reservá tu entrada y formá parte', + ], json_decode($websiteExtra->config, true)); + + DB::table('websites_extras')->where('id', $websiteExtra->id)->update([ + 'config' => '{"eyebrow":"Personalizado"}', + ]); + $migration->up(); + + $this->assertSame('{"eyebrow":"Personalizado"}', DB::table('websites_extras') + ->where('id', $websiteExtra->id)->value('config')); + $this->assertSame(1, DB::table('websites_extras') + ->where('website_code', 'onticket')->where('website_type_extra_id', $extraId)->count()); + } +} diff --git a/tests/Feature/Seeders/TenantSeederTest.php b/tests/Feature/Seeders/TenantSeederTest.php index 1a11016..df4c542 100644 --- a/tests/Feature/Seeders/TenantSeederTest.php +++ b/tests/Feature/Seeders/TenantSeederTest.php @@ -32,6 +32,7 @@ class TenantSeederTest extends TestCase $this->assertSame('onticket', $tenant->client->code); $this->assertSame($websiteType->codigo, $tenant->admin_website_type_code); $this->assertSame('onticket_multi_event', $tenant->storefront_website_type_code); + $this->assertTrue($tenant->display_seach_bar); $this->assertSame($websiteType->nombre, $tenant->nombre); $this->assertSame($websiteType->dominio, $tenant->dominio); $this->assertSame($websiteType->site_title, $tenant->site_title); @@ -50,7 +51,7 @@ class TenantSeederTest extends TestCase } $carousel = $tenant->websiteExtras() - ->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'heroCarousel')) + ->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'immersiveHeroCarousel')) ->sole(); $imageIds = $carousel->config; $this->assertCount(8, $imageIds); @@ -71,6 +72,13 @@ class TenantSeederTest extends TestCase $this->seed(TenantSeeder::class); $this->assertSame($imageIds, $carousel->fresh()->config); + $this->assertSame([ + 'eyebrow' => 'Encendé tu', + 'title' => 'experiencia', + 'description' => 'Reservá tu entrada y formá parte', + ], $tenant->websiteExtras() + ->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'immersiveHero')) + ->sole()->config); } public function test_it_assigns_social_media_to_both_tenants_in_order(): void diff --git a/tests/Feature/Seeders/WebsiteTypeSeederTest.php b/tests/Feature/Seeders/WebsiteTypeSeederTest.php index 6f0cc3d..cff02dc 100644 --- a/tests/Feature/Seeders/WebsiteTypeSeederTest.php +++ b/tests/Feature/Seeders/WebsiteTypeSeederTest.php @@ -89,8 +89,8 @@ class WebsiteTypeSeederTest extends TestCase $this->assertSame('standard', $onTicketStorefront->header_type); $multiEventStorefront = StorefrontWebsiteType::query()->where('codigo', 'onticket_multi_event')->with('extras')->sole(); $this->assertSame('immersive', $multiEventStorefront->header_type); - $this->assertSame(['heroCarousel'], $multiEventStorefront->extras->pluck('codigo')->all()); - $this->assertSame($shopItStorefront->extras->sole()->config_schema, $multiEventStorefront->extras->sole()->config_schema); + $this->assertEqualsCanonicalizing(['immersiveHeroCarousel', 'immersiveHero'], $multiEventStorefront->extras->pluck('codigo')->all()); + $this->assertSame($shopItStorefront->extras->sole()->config_schema, $multiEventStorefront->extras->firstWhere('codigo', 'immersiveHeroCarousel')->config_schema); $this->assertEqualsCanonicalizing( ['heroConfig', 'eventConfig', 'additionalInfoConfig'], $onTicketStorefront->extras->pluck('codigo')->all(), diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 39b32f1..20bd8b4 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -345,12 +345,12 @@ class BootstrapTenantControllerTest extends TestCase ->where('codigo', 'onticket_multi_event') ->firstOrFail() ->extras() - ->where('codigo', 'heroCarousel') + ->where('codigo', 'immersiveHeroCarousel') ->firstOrFail(); $images = collect(['first.jpg', 'second.jpg'])->map(fn (string $filename): Attachment => Attachment::query()->create([ 'key' => (string) Str::uuid(), - 'path' => "tenants/acme/extras/heroCarousel/{$filename}", + 'path' => "tenants/acme/extras/immersiveHeroCarousel/{$filename}", 'filename' => $filename, 'type' => AttachmentType::Image, 'mime_type' => 'image/jpeg', @@ -365,10 +365,10 @@ class BootstrapTenantControllerTest extends TestCase $this->assertSame($images->pluck('id')->all(), $extra->fresh()->config); $response = $this->getJson('/api/tenants/bootstrap?dominio=acme.com&path=%2F')->assertOk(); - $response->assertJsonCount(2, 'data.extras.heroCarousel'); + $response->assertJsonCount(2, 'data.extras.immersiveHeroCarousel'); foreach ($images->values() as $index => $image) { - $response->assertJsonPath("data.extras.heroCarousel.{$index}", function (string $url) use ($image): bool { + $response->assertJsonPath("data.extras.immersiveHeroCarousel.{$index}", function (string $url) use ($image): bool { return str_contains($url, $image->path) && (str_contains($url, 'Expires=') || str_contains($url, 'X-Amz-Expires=')); });