Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file.

## [3.14.0] - 2026-01-25

### Added

- **Filter Share Links** - Share current filter settings via URL
- Share button on Recipes and Random Recipes pages
- Shareable links preserve all active filters (ingredients, tags, allergens, difficulty, time ranges, etc.)

## [3.13.0] - 2026-01-25

### Added
Expand Down
22 changes: 22 additions & 0 deletions app/Enums/FilterSharePageEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Enums;

enum FilterSharePageEnum: string
{
case Recipes = 'recipes';
case Random = 'random';
case Menus = 'menus';

/**
* Get the route name for this page.
*/
public function routeName(): string
{
return match ($this) {
self::Recipes => 'localized.recipes.index',
self::Random => 'localized.recipes.random',
self::Menus => 'localized.menus.index',
};
}
}
87 changes: 87 additions & 0 deletions app/Http/Controllers/FilterShareController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Enums\FilterSharePageEnum;
use App\Models\Country;
use App\Models\FilterShare;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class FilterShareController extends Controller
{
/**
* Apply filters to session and redirect to the target page.
*/
public function __invoke(Request $request, string $id): RedirectResponse
{
/** @var Country $country */
$country = resolve('current.country');

$filterShare = FilterShare::find($id);

abort_if($filterShare === null || $filterShare->country_id !== $country->id, 404);

$page = FilterSharePageEnum::tryFrom($filterShare->page);

abort_if($page === null, 404);

$this->applyFiltersToSession($filterShare, $country);

// Pass through URL params (search, page, sort)
$queryParams = [];

if ($request->filled('search')) {
$queryParams['search'] = $request->query('search');
}

if ($request->filled('page') && (int) $request->query('page') > 1) {
$queryParams['page'] = (int) $request->query('page');
}

if ($request->filled('sort')) {
$queryParams['sort'] = $request->query('sort');
}

$redirectUrl = localized_route($page->routeName());

if ($queryParams !== []) {
$redirectUrl .= '?' . http_build_query($queryParams);
}

return redirect($redirectUrl);
}

/**
* Apply the filter share's filters to the session.
*/
protected function applyFiltersToSession(FilterShare $filterShare, Country $country): void
{
$filters = $filterShare->filters;
$prefix = sprintf('recipe_filter_%d_', $country->id);

$mapping = [
'has_pdf' => 'has_pdf',
'show_canonical' => 'show_canonical',
'excluded_allergens' => 'excluded_allergens',
'ingredients' => 'ingredients',
'ingredient_match' => 'ingredient_match',
'excluded_ingredients' => 'excluded_ingredients',
'tags' => 'tags',
'excluded_tags' => 'excluded_tags',
'labels' => 'labels',
'excluded_labels' => 'excluded_labels',
'difficulty' => 'difficulty',
'prep_time' => 'prep_time',
'total_time' => 'total_time',
];

foreach ($mapping as $filterKey => $sessionKey) {
if (array_key_exists($filterKey, $filters)) {
session()->put($prefix . $sessionKey, $filters[$filterKey]);
}
}
}
}
247 changes: 247 additions & 0 deletions app/Livewire/Web/Concerns/WithFilterShareTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<?php

declare(strict_types=1);

namespace App\Livewire\Web\Concerns;

use App\Enums\FilterSharePageEnum;
use App\Models\Country;
use App\Models\FilterShare;

/**
* @property int $countryId
* @property Country $country
* @property int $activeFilterCount
* @property string $search
* @property string $sortBy
* @property bool $filterHasPdf
* @property bool $filterShowCanonical
* @property array<int> $excludedAllergenIds
* @property array<int> $ingredientIds
* @property string $ingredientMatchMode
* @property array<int> $excludedIngredientIds
* @property array<int> $tagIds
* @property array<int> $excludedTagIds
* @property array<int> $labelIds
* @property array<int> $excludedLabelIds
* @property array<int> $difficultyLevels
* @property array{0: int, 1: int}|null $prepTimeRange
* @property array{0: int, 1: int}|null $totalTimeRange
*/
trait WithFilterShareTrait
{
public string $shareUrl = '';

/**
* Prepare the share URL (called when share button is clicked).
*/
public function prepareShareUrl(): void
{
$this->shareUrl = $this->activeFilterCount > 0
? $this->createFilterShare()
: $this->getDirectShareUrl();
}

/**
* Get the current page URL (no filters, no DB).
*/
public function getDirectShareUrl(): string
{
$params = $this->collectUrlParamsForShare();
$url = localized_route($this->getFilterSharePage()->routeName());

return $params !== [] ? $url . '?' . http_build_query($params) : $url;
}

/**
* Generate filter share URL (only called when filters are active).
*/
public function generateFilterShareUrl(): void
{
$this->shareUrl = $this->createFilterShare();
}

/**
* Create a filter share and return the URL.
*/
protected function createFilterShare(): string
{
$filters = $this->collectFiltersForShare();
ksort($filters);
$page = $this->getFilterSharePage()->value;

// Check for existing identical filter share (JSONB = comparison)
$filterShare = FilterShare::where('country_id', $this->countryId)
->where('page', $page)
->whereRaw('filters = ?::jsonb', [json_encode($filters, JSON_THROW_ON_ERROR)])
->first();

if ($filterShare === null) {
$filterShare = new FilterShare([
'page' => $page,
'filters' => $filters,
]);
$filterShare->country()->associate($this->country);
$filterShare->save();
}

$url = localized_route('localized.filter-share', ['id' => $filterShare->id]);

$urlParams = $this->collectUrlParamsForShare();
if ($urlParams !== []) {
$url .= '?' . http_build_query($urlParams);
}

return $url;
}

/**
* Collect URL parameters for sharing (search, page, sort).
*
* @return array<string, mixed>
*/
protected function collectUrlParamsForShare(): array
{
$params = [];

if ($this->search !== '') {
$params['search'] = $this->search;
}

if ($this->getSharePage() > 1) {
$params['page'] = $this->getSharePage();
}

if ($this->sortBy !== '' && $this->sortBy !== $this->getDefaultSort()) {
$params['sort'] = $this->sortBy;
}

return $params;
}

/**
* Get the default sort value.
*/
protected function getDefaultSort(): string
{
return '';
}

/**
* Get the current page number for sharing (override in paginated components).
*/
protected function getSharePage(): int
{
return 1;
}

/**
* Get the filter share page enum for this component.
*/
abstract protected function getFilterSharePage(): FilterSharePageEnum;

/**
* Collect the current filters for sharing.
*
* @return array<string, mixed>
*/
protected function collectFiltersForShare(): array
{
$filters = [];

$this->collectBooleanFilters($filters);
$this->collectArrayFilters($filters);
$this->collectTimeRangeFilters($filters);

return $filters;
}

/**
* Collect boolean filters.
*
* @param array<string, mixed> $filters
*/
protected function collectBooleanFilters(array &$filters): void
{
if ($this->filterHasPdf) {
$filters['has_pdf'] = true;
}

if ($this->filterShowCanonical) {
$filters['show_canonical'] = true;
}

$filters['ingredient_match'] = $this->ingredientMatchMode;
}

/**
* Collect array filters.
*
* @param array<string, mixed> $filters
*/
protected function collectArrayFilters(array &$filters): void
{
$arrayMappings = [
'excludedAllergenIds' => 'excluded_allergens',
'ingredientIds' => 'ingredients',
'excludedIngredientIds' => 'excluded_ingredients',
'tagIds' => 'tags',
'excludedTagIds' => 'excluded_tags',
'labelIds' => 'labels',
'excludedLabelIds' => 'excluded_labels',
'difficultyLevels' => 'difficulty',
];

foreach ($arrayMappings as $property => $filterKey) {
if ($this->{$property} !== []) {
$values = $this->{$property};
sort($values);
$filters[$filterKey] = $values;
}
}
}

/**
* Collect time range filters.
*
* @param array<string, mixed> $filters
*/
protected function collectTimeRangeFilters(array &$filters): void
{
if ($this->isPrepTimeFilterActive()) {
$filters['prep_time'] = $this->prepTimeRange;
}

if ($this->isTotalTimeFilterActive()) {
$filters['total_time'] = $this->totalTimeRange;
}
}

/**
* Check if prep time filter is active (not default values).
*/
protected function isPrepTimeFilterActive(): bool
{
if ($this->prepTimeRange === null) {
return false;
}

$default = $this->getDefaultPrepTimeRange();

return $default === null || $this->prepTimeRange !== $default;
}

/**
* Check if total time filter is active (not default values).
*/
protected function isTotalTimeFilterActive(): bool
{
if ($this->totalTimeRange === null) {
return false;
}

$default = $this->getDefaultTotalTimeRange();

return $default === null || $this->totalTimeRange !== $default;
}
}
Loading
Loading