diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index 21af6ac..2684d1b 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -8,16 +8,19 @@ use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedItem; use App\Domains\Catalog\Requests\CatalogItemDetailRequest; use App\Domains\Catalog\Requests\FeaturedGroupPageRequest; +use App\Domains\Catalog\Requests\SearchCatalogItemsRequest; use App\Domains\Catalog\Requests\StoreCatalogItemRequest; use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource; use App\Domains\Catalog\Resources\CatalogFeaturedItemResource; use App\Domains\Catalog\Resources\CatalogItemDetailResource; use App\Domains\Catalog\Resources\CatalogItemResource; +use App\Domains\Catalog\Resources\CatalogSearchItemResource; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Pagination\LengthAwarePaginator; class CatalogController extends Controller @@ -39,6 +42,21 @@ class CatalogController extends Controller )); } + public function search( + SearchCatalogItemsRequest $request, + Tenant $tenant, + CatalogService $catalogService, + ): AnonymousResourceCollection { + return CatalogSearchItemResource::collection( + $catalogService->search( + $tenant, + $request->validated('q'), + $tenant->search_items_per_page, + (int) $request->validated('page', 1), + ) + ); + } + public function featuredGroupItems( FeaturedGroupPageRequest $request, Tenant $tenant, diff --git a/app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php b/app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php new file mode 100644 index 0000000..be2cca0 --- /dev/null +++ b/app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php @@ -0,0 +1,29 @@ +> */ + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'min:2', 'max:100'], + 'page' => ['sometimes', 'integer', 'min:1'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('q') && is_string($this->input('q'))) { + $this->merge(['q' => trim($this->string('q')->toString())]); + } + } +} diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php new file mode 100644 index 0000000..6cbca53 --- /dev/null +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -0,0 +1,45 @@ + */ + public function toArray(Request $request): array + { + $attachment = $this->attachments->first() + ?? $this->variants + ->flatMap(fn (Variant $variant) => $variant->attachments) + ->first(); + + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'nombre' => $this->nombre, + 'descripcion' => $this->descripcion, + 'precio' => $this->precio, + 'image' => $attachment?->getTemporaryUrl(1440), + 'stock_tecnico' => $this->availableStock(), + 'variants' => $this->variants + ->map(fn (Variant $variant): array => [ + 'id' => $variant->id, + 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(), + 'values' => $variant->definitions + ->mapWithKeys(fn ($definition) => [ + $definition->itemAttribute?->attribute?->codigo => $definition->value, + ]) + ->filter(fn ($value, $key): bool => $key !== null), + ]) + ->values(), + ]; + } +} diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index cc98f73..36570f9 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -10,6 +10,9 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; +use App\Domains\Tenant\Models\Tenant; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -158,6 +161,55 @@ class CatalogService return $catalogItem; } + /** @return LengthAwarePaginator */ + public function search( + Tenant $tenant, + string $term, + int $perPage, + int $page, + ): LengthAwarePaginator { + $normalizedTerm = mb_strtolower($term); + $containsPattern = "%{$normalizedTerm}%"; + $startsWithPattern = "{$normalizedTerm}%"; + + $paginator = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where(function (Builder $query) use ($containsPattern): void { + $query + ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) + ->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern]) + ->orWhereHas( + 'brand', + fn (Builder $brandQuery) => $brandQuery + ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) + ) + ->orWhereHas( + 'category', + fn (Builder $categoryQuery) => $categoryQuery + ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) + ); + }) + ->with([ + 'attachments', + 'inventory', + 'variants.inventory', + 'variants.attachments', + 'variants.definitions.itemAttribute.attribute', + 'bundleComponents.catalogItem', + 'bundleComponents.variant.catalogItem', + ]) + ->orderByRaw( + 'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END', + [$normalizedTerm, $startsWithPattern], + ) + ->orderBy('nombre') + ->paginate(perPage: $perPage, pageName: 'page', page: $page); + + return $paginator->withPath(route('catalog-items.index', [ + 'tenant' => $tenant->codigo, + ])); + } + public function delete(CatalogItem $catalogItem): void { DB::transaction(function () use ($catalogItem): void { diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index f39fed6..8c21316 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -7,6 +7,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void { Route::get('catalog', [CatalogController::class, 'index']); Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems']) ->name('catalog.featured-groups.items.index'); + Route::get('catalog-items', [CatalogController::class, 'search']) + ->name('catalog-items.index'); Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']); Route::post('catalog-items', [CatalogController::class, 'store']); }); diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 1df48df..f1e5f07 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -3,6 +3,8 @@ namespace App\Domains\Tenant\Models; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Catalog\Enums\GroupLayout; +use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\TenantMenu; @@ -27,11 +29,20 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'footer_logo_id', 'hero_config', 'event_config', + 'search_product_layout', + 'search_group_layout', + 'search_items_per_page', ])] class Tenant extends Model { use HasFactory; + protected $attributes = [ + 'search_product_layout' => ProductLayout::ColumnWithImage->value, + 'search_group_layout' => GroupLayout::Paginated->value, + 'search_items_per_page' => 12, + ]; + public function getRouteKeyName(): string { return 'codigo'; @@ -47,6 +58,9 @@ class Tenant extends Model return [ 'hero_config' => 'array', 'event_config' => 'array', + 'search_product_layout' => ProductLayout::class, + 'search_group_layout' => GroupLayout::class, + 'search_items_per_page' => 'integer', ]; } diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 9092d03..115f889 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -2,6 +2,8 @@ namespace App\Domains\Tenant\Requests; +use App\Domains\Catalog\Enums\GroupLayout; +use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; @@ -82,6 +84,9 @@ class StoreTenantRequest extends FormRequest 'event_config.location' => ['nullable', 'string'], 'event_config.dates' => ['nullable', 'array'], 'event_config.dates.*' => ['required', 'string'], + 'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)], + 'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)], + 'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'], ]; } } diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 7594531..bb6f2d9 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -2,6 +2,8 @@ namespace App\Domains\Tenant\Requests; +use App\Domains\Catalog\Enums\GroupLayout; +use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Support\TenantDomainNormalizer; @@ -93,6 +95,9 @@ class UpdateTenantRequest extends FormRequest 'event_config.location' => ['nullable', 'string'], 'event_config.dates' => ['nullable', 'array'], 'event_config.dates.*' => ['required', 'string'], + 'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)], + 'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)], + 'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'], ]; } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index c972511..7592b37 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -40,6 +40,9 @@ class TenantResource extends JsonResource 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), 'hero_config' => $heroConfig, 'event_config' => $this->event_config, + 'search_product_layout' => $this->search_product_layout->value, + 'search_group_layout' => $this->search_group_layout->value, + 'search_items_per_page' => $this->search_items_per_page, 'main_carousel_images' => $this->whenLoaded( 'mainCarouselImages', fn () => $this->mainCarouselImages diff --git a/database/migrations/2026_07_24_100000_add_catalog_search_layouts_to_tenants_table.php b/database/migrations/2026_07_24_100000_add_catalog_search_layouts_to_tenants_table.php new file mode 100644 index 0000000..282171b --- /dev/null +++ b/database/migrations/2026_07_24_100000_add_catalog_search_layouts_to_tenants_table.php @@ -0,0 +1,32 @@ +enum('search_product_layout', ProductLayout::values()) + ->default(ProductLayout::ColumnWithImage->value); + $table->enum('search_group_layout', GroupLayout::values()) + ->default(GroupLayout::Paginated->value); + $table->unsignedTinyInteger('search_items_per_page')->default(12); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn([ + 'search_product_layout', + 'search_group_layout', + 'search_items_per_page', + ]); + }); + } +}; diff --git a/tests/Feature/Catalog/CatalogSearchTest.php b/tests/Feature/Catalog/CatalogSearchTest.php new file mode 100644 index 0000000..52aa2b6 --- /dev/null +++ b/tests/Feature/Catalog/CatalogSearchTest.php @@ -0,0 +1,132 @@ +createTenant('search-default'); + + $this->assertDatabaseHas('tenants', [ + 'codigo' => $tenant->codigo, + 'search_product_layout' => ProductLayout::ColumnWithImage->value, + 'search_group_layout' => GroupLayout::Paginated->value, + 'search_items_per_page' => 12, + ]); + } + + public function test_search_layouts_can_be_updated_on_the_tenant_and_are_returned_by_bootstrap(): void + { + $tenant = $this->createTenant('search-config'); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'search_product_layout' => ProductLayout::Row->value, + 'search_group_layout' => GroupLayout::SimpleVertical->value, + 'search_items_per_page' => 24, + ]) + ->assertOk() + ->assertJsonPath('data.search_product_layout', ProductLayout::Row->value) + ->assertJsonPath('data.search_group_layout', GroupLayout::SimpleVertical->value) + ->assertJsonPath('data.search_items_per_page', 24); + + $this->getJson("/api/tenants/bootstrap/{$tenant->dominio}") + ->assertOk() + ->assertJsonPath('data.search_product_layout', ProductLayout::Row->value) + ->assertJsonPath('data.search_group_layout', GroupLayout::SimpleVertical->value) + ->assertJsonPath('data.search_items_per_page', 24); + } + + public function test_search_is_tenant_scoped_relevant_and_uses_configured_page_size(): void + { + $tenant = $this->createTenant('search-items'); + $otherTenant = $this->createTenant('other-search-items'); + $tenant->update(['search_items_per_page' => 4]); + + foreach (range(1, 5) as $number) { + $this->createCatalogItem($tenant, "Running {$number}"); + } + $exactMatch = $this->createCatalogItem($tenant, 'Running'); + $this->createCatalogItem($tenant, 'Unrelated'); + $this->createCatalogItem($otherTenant, 'Running foreign'); + + $response = $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items?q=running" + ); + + $response + ->assertOk() + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.per_page', 4) + ->assertJsonPath('meta.total', 6) + ->assertJsonCount(4, 'data') + ->assertJsonPath('data.0.id', $exactMatch->id) + ->assertJsonMissing(['nombre' => 'Running foreign']) + ->assertJsonMissing(['nombre' => 'Unrelated']); + } + + public function test_search_validates_the_query(): void + { + $tenant = $this->createTenant('search-validation'); + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items?q=a") + ->assertUnprocessable() + ->assertJsonValidationErrors('q'); + } + + private function createCatalogItem(Tenant $tenant, string $name): CatalogItem + { + $inventory = Inventory::query()->create(['real_stock' => 10]); + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => str($name)->slug()->toString(), + 'nombre' => $name, + 'descripcion' => "{$name} description", + 'precio' => 100, + ]); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +}