diff --git a/.gitignore b/.gitignore index 54fb416b..778248bd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ yarn-error.log composer.lock package-lock.json yarn.lock -config/lunar public/build meilisearch/ .phpunit.cache/test-results diff --git a/README.md b/README.md index aa2d1c2b..ee406088 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,15 @@ docker-compose up The demo store will be available to `http://localhost` in your browser. +### Searching + +Out the box, the starter kit is configured to use Meilisearch. You will need to ensure you have this set up, you should also check the following config files to familiarise yourself how search is configured to work. + +- `config/lunar/search.php` +- `config/scout.php` + +To customise what is indexed, you should look at the `Search/ProductIndexer` class. + #### Log into Lunar panel Once the project is prepared, the Lunar panel will start and available to `http://localhost/lunar`. diff --git a/app/Livewire/CollectionPage.php b/app/Livewire/CollectionPage.php index 92111777..b1faf594 100644 --- a/app/Livewire/CollectionPage.php +++ b/app/Livewire/CollectionPage.php @@ -2,15 +2,20 @@ namespace App\Livewire; +use App\Livewire\Traits\WithSearch; use App\Traits\FetchesUrls; use Illuminate\Support\Collection; -use Illuminate\View\View; +use Livewire\Attributes\Computed; use Livewire\Component; +use Livewire\WithPagination; use Lunar\Models\Collection as CollectionModel; +use Lunar\Search\Contracts\SearchManagerContract; class CollectionPage extends Component { use FetchesUrls; + use WithPagination; + use WithSearch; public function mount(string $slug): void { @@ -24,21 +29,27 @@ public function mount(string $slug): void ] ); + + if (! $this->url) { abort(404); } + + $this->filters = [ + 'collection_ids' => [$this->collection->id] + ]; } - /** - * Computed property to return the collection. - */ - public function getCollectionProperty(): mixed + #[Computed] + public function collection(): mixed { return $this->url->element; } - public function render(): View + public function render(): \Illuminate\Contracts\View\View { - return view('livewire.collection-page'); + return view('livewire.search-page', [ + 'isCollection' => true, + ]); } } diff --git a/app/Livewire/SearchPage.php b/app/Livewire/SearchPage.php index 4ffc903d..c06daa34 100644 --- a/app/Livewire/SearchPage.php +++ b/app/Livewire/SearchPage.php @@ -2,34 +2,30 @@ namespace App\Livewire; +use App\Livewire\Traits\SearchesProducts; +use App\Livewire\Traits\WithSearch; +use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\View\View; +use Livewire\Attributes\Computed; +use Livewire\Attributes\Modelable; +use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; use Lunar\Models\Product; +use Lunar\Search\Contracts\SearchManagerContract; +use Lunar\Search\Data\SearchResults; +use Lunar\Search\Facades\Search; class SearchPage extends Component { use WithPagination; + use WithSearch; - /** - * {@inheritDoc} - */ - protected $queryString = [ - 'term', - ]; - - /** - * The search term. - */ - public ?string $term = null; - - /** - * Return the search results. - */ - public function getResultsProperty(): LengthAwarePaginator + public function mount(Request $request) { - return Product::search($this->term)->paginate(50); + $this->query = (string) $request->get('query', ''); } public function render(): View diff --git a/app/Livewire/Traits/WithSearch.php b/app/Livewire/Traits/WithSearch.php new file mode 100644 index 00000000..0fb5c731 --- /dev/null +++ b/app/Livewire/Traits/WithSearch.php @@ -0,0 +1,113 @@ +sort = (string) $request->get('sort', ''); + } + + #[Computed] + public function searchInstance(): SearchManagerContract + { + $search = Search::model(\Lunar\Models\Product::class); + + if ($this->facets && count($this->facets)) { + $facets = []; + + foreach ($this->facets as $facet) { + [$field, $facetValue] = explode(':', urldecode($facet)); + + if (empty($facets[$field])) { + $facets[$field] = []; + } + + $facets[$field][] = $facetValue; + } + + $search->setFacets($facets); + } + + $search->filter($this->filters); + + return $search; + } + + + + #[Computed] + public function results(): SearchResults + { + + $sorting = $this->sort ?: ''; + + return $this->searchInstance->perPage($this->perPage)->sort($sorting)->query($this->query)->get(); + } + + #[Computed] + public function displayFacets(): Collection + { + return collect($this->results->facets)->reject( + fn ($facet) => !count($facet->values) + )->values(); + } + + public function clearFacets(): void + { + $this->facets = []; + } + + public function updatedQuery(): void + { + $this->resetPage(); + } + + public function updatedFacets(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->resetPage(); + $this->instance->perPage($this->perPage); + } + + public function removeFacet(string $facet): void + { + $facets = $this->facets; + unset($facets[ + array_search($facet, $facets) + ]); + $this->facets = collect($facets)->values()->toArray(); + } +} diff --git a/app/Search/ProductIndexer.php b/app/Search/ProductIndexer.php new file mode 100644 index 00000000..6ca64fd3 --- /dev/null +++ b/app/Search/ProductIndexer.php @@ -0,0 +1,121 @@ +with([ + 'thumbnail', + 'variants', + 'productType', + 'brand', + 'prices', + ]); + } + + public function toSearchableArray(Model $model): array + { + $priceModels = $model->prices; + + $basePrice = $priceModels->first(function ($price) { + return $price->min_quantity == 1; + }); + + $minPrice = $priceModels->sortBy('price')->first(); + + $options = $model->productOptions()->with([ + 'values' => function ($query) use ($model) { + $query->whereHas('variants', function ($relation) use ($model) { + $relation->where('product_id', $model->id); + }); + }, + ])->get()->mapWithKeys(function ($option) { + return [ + $option->handle => $option->values->map(function ($value) { + return $value->translate('name'); + }) + ]; + })->toArray(); + + return [ + 'id' => (string) $model->id, + 'name' => $model->attr('name'), + 'description' => $model->attr('description'), + 'brand' => $model->brand?->name, + 'thumbnail' => $model->thumbnail?->getUrl('small'), + 'slug' => $model->defaultUrl?->slug, + 'price' => [ + 'inc_tax' => [ + 'value' => (int) $basePrice->priceIncTax()->value, + 'formatted' => $basePrice->priceIncTax()->formatted, + ], + 'ex_tax' => [ + 'value' => (int) $basePrice->priceExTax()->value, + 'formatted' => $basePrice->priceExTax()->formatted, + ], + ], + 'min_price' => $minPrice?->priceIncTax()?->value ?: 0, + ...$options, + ...$this->getCollections($model) + ]; + } + + private function getCollections(Model $product): array + { + $trail = []; + + $categories = []; + $categoryIds = []; + + foreach ($product->collections as $i => $collection) { + $levels = [$collection->translateAttribute('name')]; + $levelIds = [$collection->id]; + $node = $collection; + + while ($node->parent) { + array_unshift($levels, $node->parent->translateAttribute('name')); + array_unshift($levelIds, $node->parent->id); + $node = $node->parent; + } + + foreach ($levels as $level => $name) { + if (! isset($trail['lvl'.$level])) { + $trail['lvl'.$level] = []; + } + + $key = $level - 1; + + $breadcrumb = [$name]; + + while (isset($levels[$key])) { + array_unshift($breadcrumb, $levels[$key]); + $key--; + } + + $trail['lvl'.$level][] = implode(' > ', $breadcrumb); + } + + $categories = array_merge($levels, $categories); + $categoryIds = array_merge($levelIds, $categoryIds); + } + + foreach ($trail as $key => $values) { + $trail[$key] = collect($values) + ->unique() + ->values() + ->toArray(); + } + + return [ + 'hierarchical_collections' => $trail, + 'collection_ids' => collect($categoryIds)->unique()->values()->toArray(), + 'collections' => collect($categories)->unique()->values()->toArray(), + ]; + } +} diff --git a/composer.json b/composer.json index 37aac3ad..6cd78c63 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "league/flysystem-aws-s3-v3": "^3.0", "lunarphp/lunar": "^1.0@beta", "lunarphp/stripe": "^1.0@beta", + "lunarphp/search": "^0.1@alpha", "lunarphp/table-rate-shipping": "^1.0@beta", "meilisearch/meilisearch-php": "^1.6", "predis/predis": "^2.2" diff --git a/config/lunar/search.php b/config/lunar/search.php new file mode 100644 index 00000000..fa5ccf03 --- /dev/null +++ b/config/lunar/search.php @@ -0,0 +1,73 @@ + [ + /* + * These models are required by the system, do not change them. + */ + Lunar\Models\Brand::class, + Lunar\Models\Collection::class, + Lunar\Models\Customer::class, + Lunar\Models\Order::class, + Lunar\Models\Product::class, + Lunar\Models\ProductOption::class, + + /* + * Below you can add your own models for indexing... + */ + // App\Models\Example::class, + ], + + /* + |-------------------------------------------------------------------------- + | Search engine mapping + |-------------------------------------------------------------------------- + | + | You can define what search driver each searchable model should use. + | If the model isn't defined here, it will use the SCOUT_DRIVER env variable. + | + */ + 'engine_map' => [ + // Lunar\Models\Product::class => 'algolia', + // Lunar\Models\Order::class => 'meilisearch', + // Lunar\Models\Collection::class => 'meilisearch', + ], + + 'indexers' => [ + Lunar\Models\Brand::class => Lunar\Search\BrandIndexer::class, + Lunar\Models\Collection::class => Lunar\Search\CollectionIndexer::class, + Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class, + Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class, + Lunar\Models\Product::class => \App\Search\ProductIndexer::class, + Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class, + ], + + 'facets' => [ + \Lunar\Models\Product::class => [ + 'brand' => [ + 'label' => 'Brand', + ], + 'colour' => [ + 'label' => 'Colour', + ], + 'size' => [ + 'label' => 'Size', + ], + 'shoe-size' => [ + 'label' => 'Shoe Size', + ] + ] + ], + +]; diff --git a/config/scout.php b/config/scout.php index 955f878d..2fa2f0e6 100644 --- a/config/scout.php +++ b/config/scout.php @@ -116,4 +116,15 @@ 'secret' => env('ALGOLIA_SECRET', ''), ], + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + \App\Models\Product::class => [ + 'filterableAttributes' => ['brand', 'size', 'shoe-size', 'colour', 'collection_ids'], + 'sortableAttributes' => ['name', 'sku', 'min_price'] + ], + ], + ], + ]; diff --git a/package.json b/package.json index d6bc2c46..e5bee683 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "devDependencies": { "@ryangjchandler/alpine-clipboard": "^2.3.0", "@tailwindcss/forms": "^0.5.7", - "autoprefixer": "^10.4.17", + "autoprefixer": "^10.4.20", "axios": "^1.6.1", "laravel-vite-plugin": "^0.8.0", - "postcss": "^8.4.35", - "tailwindcss": "^3.4.1", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", "vite": "^4.0.0" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..49c0612d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/resources/views/components/header/search.blade.php b/resources/views/components/header/search.blade.php index eeb8479a..893753ae 100644 --- a/resources/views/components/header/search.blade.php +++ b/resources/views/components/header/search.blade.php @@ -1,6 +1,6 @@
merge(['class' => 'w-full relative']) }} action="{{ route('search.view') }}"> - + +
+ +
+ diff --git a/resources/views/components/product-card.blade.php b/resources/views/components/product-card.blade.php index 7afe73cb..8514e9a6 100644 --- a/resources/views/components/product-card.blade.php +++ b/resources/views/components/product-card.blade.php @@ -1,26 +1,42 @@ -@props(['product']) +@props([ + 'name', + 'slug' => null, + 'thumbnail' => null, + 'price' +]) - -
- @if ($product->thumbnail) - {{ $product->translateAttribute('name') }} - @endif +
+ - - {{ $product->translateAttribute('name') }} - + @if($slug) + + {{ $name }} + + @else + {{ $name }} + @endif

Price - -

- +
+ {{ $price }} +
+
diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php index ad81472f..3e6d4c9a 100644 --- a/resources/views/layouts/storefront.blade.php +++ b/resources/views/layouts/storefront.blade.php @@ -12,10 +12,7 @@ name="description" content="Example of an ecommerce storefront built with Lunar." > - + @vite(['resources/css/app.css', 'resources/js/app.js']) -
-

- {{ $this->collection->translateAttribute('name') }} -

- -
- @forelse($this->collection->products as $product) - - @empty - @endforelse -
-
- diff --git a/resources/views/livewire/home.blade.php b/resources/views/livewire/home.blade.php index c99b2a77..30e13d73 100644 --- a/resources/views/livewire/home.blade.php +++ b/resources/views/livewire/home.blade.php @@ -14,7 +14,12 @@
@foreach ($this->randomCollection->products as $product) - + @endforeach
diff --git a/resources/views/livewire/search-page.blade.php b/resources/views/livewire/search-page.blade.php index f8e7c233..b10e57c4 100644 --- a/resources/views/livewire/search-page.blade.php +++ b/resources/views/livewire/search-page.blade.php @@ -1,16 +1,69 @@
-
-

- Search Results - @if (isset($term)) - for {{ $term }} - @endif -

+
+
+

+ @if(!empty($isCollection)) + {{ $this->collection->attr('name') }} + @elseif($this->query) + Search Results for "{{ $this->query }}" + @else + All Products + @endif +

+
-
- @foreach ($this->results as $result) - - @endforeach + @if(!$this->results->count) +
+ Sorry, looks like we don't have any products available. +
+ @endif +
+
+
+ Search Filters + +
+ @include('partials.search.search-filters') +
+
+ @if($this->results->count) +
+
+ {{ $this->results->links }} +
+
+
+ +
+
+ @include('partials.search.sorting') +
+
+
+ @endif +
+ @foreach ($this->results->hits as $result) +
+ +
+ @endforeach +
+
diff --git a/resources/views/partials/search/search-filters.blade.php b/resources/views/partials/search/search-filters.blade.php new file mode 100644 index 00000000..f96b697a --- /dev/null +++ b/resources/views/partials/search/search-filters.blade.php @@ -0,0 +1,50 @@ +
+ @foreach($this->displayFacets as $displayFacet) +
+

+ {{ $displayFacet->label }} +

+
+ @foreach($displayFacet->values as $facetValue) +
+
+ !$facetValue->active, + 'bg-blue-500 text-white' => $facetValue->active, + ]) + > + @if($facetValue->active) + + @endif + + +
+ +
+ @endforeach +
+
+ @endforeach +
diff --git a/resources/views/partials/search/sorting.blade.php b/resources/views/partials/search/sorting.blade.php new file mode 100644 index 00000000..fbe28df7 --- /dev/null +++ b/resources/views/partials/search/sorting.blade.php @@ -0,0 +1,13 @@ +
+
+ +
+ +
+
+ +
diff --git a/tailwind.config.js b/tailwind.config.js index 06dfc796..45f36f86 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,8 +1,11 @@ -module.exports = { +/** @type {import('tailwindcss').Config} */ + +export default { content: [ - './resources/**/*.blade.php', - './resources/**/*.js', - './vendor/lunarphp/stripe-payments/resources/views/**/*.blade.php', + "./resources/**/*.blade.php", + "./resources/**/*.js", + "./resources/**/*.vue", + './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/**/*.blade.php' ], theme: { extend: {},