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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

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

## [3.13.0] - 2026-01-25

### Added

- **Recipe Variants** - Support for recipe variants
- Recipe variants are hidden by default, with a filter option to show them
- Recipe pages show relationships: variants link to the original, originals list all variants
- Portal statistics now include variant metrics
- API now includes the canonical ID for each recipe

### Changed

- **HelloFresh Links** - Links to HelloFresh are hidden for recipe variants as they often lead to 404 pages

## [3.12.0] - 2026-01-10

### Added
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/Api/RecipeCollectionResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function toArray(Request $request): array

return [
'id' => $this->id,
'canonical_id' => $this->canonical_id,
'url' => config('app.url') . '/' . $locale . '-' . resolve('current.country')->code .
'/recipes/' . slugify($this->getTranslationWithAnyFallback('name', $locale)) . '-' . $this->id,
'name' => $this->getTranslationWithAnyFallback('name', $locale),
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/Api/RecipeResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function toArray(Request $request): array

return [
'id' => $this->id,
'canonical_id' => $this->canonical_id,
'url' => config('app.url') . '/' . $locale . '-' . resolve('current.country')->code .
'/recipes/' . slugify($this->getTranslationWithAnyFallback('name', $locale)) . '-' . $this->id,
'name' => $this->getTranslationWithAnyFallback('name', $locale),
Expand Down
1 change: 1 addition & 0 deletions app/Livewire/Portal/Docs/RecipesIndexDoc.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ protected function responseFields(): array
{
return [
['name' => 'id', 'type' => 'integer', 'description' => 'Recipe ID'],
['name' => 'canonical_id', 'type' => 'integer|null', 'description' => 'ID of the original recipe if this is a variant'],
['name' => 'url', 'type' => 'string', 'description' => 'URL to recipe on website'],
['name' => 'name', 'type' => 'string', 'description' => 'Recipe name (localized)'],
['name' => 'headline', 'type' => 'string', 'description' => 'Short description (localized)'],
Expand Down
1 change: 1 addition & 0 deletions app/Livewire/Portal/Docs/RecipesShowDoc.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ protected function responseFields(): array
{
return [
['name' => 'id', 'type' => 'integer', 'description' => 'Recipe ID'],
['name' => 'canonical_id', 'type' => 'integer|null', 'description' => 'ID of the original recipe if this is a variant'],
['name' => 'url', 'type' => 'string', 'description' => 'URL to recipe on website'],
['name' => 'name', 'type' => 'string', 'description' => 'Recipe name (localized)'],
['name' => 'headline', 'type' => 'string', 'description' => 'Short description (localized)'],
Expand Down
11 changes: 11 additions & 0 deletions app/Livewire/Portal/Stats/RecipeStats.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ public function dataHealth(): array
return $this->statistics()->dataHealth();
}

/**
* Get canonical recipe statistics.
*
* @return array{total_canonical: int, recipes_with_canonical: int, unique_canonical_parents: int, canonical_percentage: float}
*/
#[Computed]
public function canonicalStats(): array
{
return $this->statistics()->canonicalStats();
}

public function render(): View
{
/** @var View $view */
Expand Down
11 changes: 11 additions & 0 deletions app/Livewire/Web/Recipes/RecipeIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class RecipeIndex extends AbstractComponent

public bool $filterHasPdf = false;

public bool $filterShowCanonical = false;

/** @var array<int> */
public array $excludedAllergenIds = [];

Expand Down Expand Up @@ -87,6 +89,7 @@ public function mount(?Menu $menu = null): void
$this->viewMode = session('view_mode', ViewModeEnum::Grid->value);
$this->sortBy = session($this->filterSessionKey('sort'), RecipeSortEnum::NewestFirst->value);
$this->filterHasPdf = session($this->filterSessionKey('has_pdf'), false);
$this->filterShowCanonical = session($this->filterSessionKey('show_canonical'), false);
$this->excludedAllergenIds = session($this->filterSessionKey('excluded_allergens'), []);
$this->ingredientIds = session($this->filterSessionKey('ingredients'), []);
$this->ingredientMatchMode = session($this->filterSessionKey('ingredient_match'), IngredientMatchModeEnum::Any->value);
Expand Down Expand Up @@ -249,6 +252,7 @@ protected function getSessionMapping(): array
return [
'sortBy' => 'sort',
'filterHasPdf' => 'has_pdf',
'filterShowCanonical' => 'show_canonical',
'excludedAllergenIds' => 'excluded_allergens',
'ingredientIds' => 'ingredients',
'ingredientMatchMode' => 'ingredient_match',
Expand All @@ -275,6 +279,10 @@ public function activeFilterCount(): int
$count++;
}

if ($this->filterShowCanonical) {
$count++;
}

if ($this->excludedAllergenIds !== []) {
$count++;
}
Expand Down Expand Up @@ -350,6 +358,7 @@ public function isTagActive(int $tagId): bool
public function clearFilters(): void
{
$this->filterHasPdf = false;
$this->filterShowCanonical = false;
$this->excludedAllergenIds = [];
$this->ingredientIds = [];
$this->ingredientMatchMode = IngredientMatchModeEnum::Any->value;
Expand All @@ -364,6 +373,7 @@ public function clearFilters(): void

session()->forget([
$this->filterSessionKey('has_pdf'),
$this->filterSessionKey('show_canonical'),
$this->filterSessionKey('excluded_allergens'),
$this->filterSessionKey('ingredients'),
$this->filterSessionKey('ingredient_match'),
Expand Down Expand Up @@ -393,6 +403,7 @@ public function recipes(): LengthAwarePaginator
->when($menuRecipeIds !== [], fn (Builder $query) => $query->whereIn('id', $menuRecipeIds))
->when($this->search !== '', fn (Builder $query): Builder => $this->applySearchFilter($query))
->when($this->filterHasPdf, fn (Builder $query) => $query->where('has_pdf', true))
->when(! $this->filterShowCanonical, fn (Builder $query) => $query->whereNull('canonical_id'))
->when($this->excludedAllergenIds !== [], fn (Builder $query) => $query->whereDoesntHave(
'allergens',
fn (Builder $allergenQuery) => $allergenQuery->whereIn('allergens.id', $this->excludedAllergenIds)
Expand Down
1 change: 1 addition & 0 deletions app/Livewire/Web/Recipes/RecipeRandom.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public function randomRecipes(): Collection
return Recipe::where('country_id', $this->countryId)
->when($this->search !== '', fn (Builder $query): Builder => $this->applySearchFilter($query))
->when($this->filterHasPdf, fn (Builder $query) => $query->where('has_pdf', true))
->unless($this->filterShowCanonical, fn (Builder $query) => $query->whereNull('canonical_id'))
->when($this->excludedAllergenIds !== [], fn (Builder $query) => $query->whereDoesntHave(
'allergens',
fn (Builder $allergenQuery) => $allergenQuery->whereIn('allergens.id', $this->excludedAllergenIds)
Expand Down
2 changes: 2 additions & 0 deletions app/Livewire/Web/Recipes/RecipeShow.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public function mount(Recipe $recipe): void
'cuisines',
'utensils',
'ingredients',
'canonical.country',
'variants.country',
]);

// Set default yield based on available yields
Expand Down
13 changes: 13 additions & 0 deletions app/Models/Recipe.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,24 @@ protected function hellofreshUrl(): Attribute
return Attribute::get(fn (): ?string => $this->buildHellofreshUrl());
}

/**
* Check if this recipe is a canonical recipe (has a parent).
*/
public function isCanonical(): bool
{
return $this->canonical_id !== null;
}

/**
* Build the HelloFresh URL.
*/
protected function buildHellofreshUrl(): ?string
{
// Canonical recipes often lead to 404 pages on HelloFresh
if ($this->isCanonical()) {
return null;
}

/** @var string|null $hellofreshId */
$hellofreshId = $this->hellofresh_id;

Expand Down
28 changes: 28 additions & 0 deletions app/Services/Portal/StatisticsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Models\RecipeList;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -40,6 +41,7 @@ class StatisticsService
'portal_recipes_per_month',
'portal_avg_prep_times',
'portal_data_health',
'portal_canonical_stats',
];

/**
Expand Down Expand Up @@ -70,6 +72,7 @@ public function warmCache(): void
$this->recipesPerMonth();
$this->avgPrepTimesByCountry();
$this->dataHealth();
$this->canonicalStats();
}

/**
Expand Down Expand Up @@ -101,6 +104,7 @@ public function countryStats(): Collection
/** @var Collection<int, Country> */
return Cache::remember('portal_country_stats', $this->cacheTtl, static fn (): Collection => Country::where('active', true)
->withCount('menus')
->withCount(['recipes as variants_count' => fn (Builder $query) => $query->whereNotNull('canonical_id')])
->get());
}

Expand Down Expand Up @@ -310,4 +314,28 @@ public function dataHealth(): array
];
});
}

/**
* Get canonical recipe statistics.
*
* @return array{total_canonical: int, recipes_with_canonical: int, unique_canonical_parents: int, canonical_percentage: float}
*/
public function canonicalStats(): array
{
/** @var array{total_canonical: int, recipes_with_canonical: int, unique_canonical_parents: int, canonical_percentage: float} */
return Cache::remember('portal_canonical_stats', $this->cacheTtl, static function (): array {
$total = Recipe::count();
$recipesWithCanonical = Recipe::whereNotNull('canonical_id')->count();
$uniqueCanonicalParents = Recipe::whereNotNull('canonical_id')
->distinct('canonical_id')
->count('canonical_id');

return [
'total_canonical' => $recipesWithCanonical,
'recipes_with_canonical' => $recipesWithCanonical,
'unique_canonical_parents' => $uniqueCanonicalParents,
'canonical_percentage' => $total > 0 ? round(($recipesWithCanonical / $total) * 100, 1) : 0.0,
];
});
}
}
2 changes: 1 addition & 1 deletion config/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
|
*/

'version' => env('API_VERSION', 'unknown'),
'version' => (string) env('API_VERSION', '') !== '' ? env('API_VERSION') : 'unknown',

/*
|--------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions lang/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"Avatar": "Avatar",
"Avatar removed successfully.": "Avatar fjernet.",
"Avatar updated successfully.": "Avatar opdateret.",
"Based on Canonical Recipe": "Opskriftsvariant",
"Back to Lists": "Tilbage til lister",
"Back to Login": "Tilbage til login",
"Browse Recipes": "Gennemse opskrifter",
Expand Down Expand Up @@ -143,6 +144,7 @@
"Not Found": "Ikke fundet",
"Oldest first": "Ældste først",
"Only the owner can share this list.": "Kun ejeren kan dele denne liste.",
"Show Canonical Recipes": "Vis også opskriftsvarianter",
"Only with PDF": "Kun med PDF",
"Optional description for your list": "Valgfri beskrivelse til din liste",
"Page Expired": "Siden er udløbet",
Expand Down Expand Up @@ -229,6 +231,7 @@
"System": "System",
"Terms of Use": "Brugsbetingelser",
"This list is already shared with this user.": "Denne liste er allerede delt med denne bruger.",
"This recipe is based on": "Denne opskrift er baseret på",
"This list is empty": "Denne liste er tom",
"This password reset link will expire in :count minutes.": "Password-link vil udløbe om :count minutter.",
"Too Many Requests": "For mange forespørgsler",
Expand All @@ -244,6 +247,7 @@
"Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Upload et profilbillede. Billedet skal være kvadratisk og mellem 200x200 og 1000x1000 pixels.",
"Upload new avatar": "Upload ny avatar",
"User removed from shared list.": "Bruger fjernet fra delt liste.",
"Variants of this Recipe": "Varianter af denne opskrift",
"Verify Email Address": "Bekræft email-adresse",
"View": "Vis",
"View PDF": "Se PDF",
Expand Down
4 changes: 4 additions & 0 deletions lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"Avatar": "Avatar",
"Avatar removed successfully.": "Avatar erfolgreich entfernt.",
"Avatar updated successfully.": "Avatar erfolgreich aktualisiert.",
"Based on Canonical Recipe": "Rezeptvariante",
"Back to Lists": "Zurück zu den Listen",
"Back to Login": "Zurück zur Anmeldung",
"Back to recipes": "Zurück zu den Rezepten",
Expand Down Expand Up @@ -143,6 +144,7 @@
"Nutrition": "Nährwerte",
"Oldest first": "Älteste zuerst",
"Only the owner can share this list.": "Nur der Eigentümer kann diese Liste teilen.",
"Show Canonical Recipes": "Auch Rezeptvarianten anzeigen",
"Only with PDF": "Nur mit PDF",
"Optional description for your list": "Optionale Beschreibung für deine Liste",
"Page Expired": "Seite abgelaufen",
Expand Down Expand Up @@ -230,6 +232,7 @@
"System": "System",
"Terms of Use": "Nutzungsbedingungen",
"This list is already shared with this user.": "Diese Liste ist bereits mit diesem Benutzer geteilt.",
"This recipe is based on": "Dieses Rezept basiert auf",
"This list is empty": "Diese Liste ist leer",
"This password reset link will expire in :count minutes.": "Dieser Link zum Zurücksetzen des Passworts läuft in :count Minuten ab.",
"Too Many Requests": "Zu viele Anfragen",
Expand All @@ -247,6 +250,7 @@
"User removed from shared list.": "Benutzer aus geteilter Liste entfernt.",
"Utensils": "Küchenutensilien",
"Verify Email Address": "E-Mail-Adresse bestätigen",
"Variants of this Recipe": "Varianten dieses Rezepts",
"View": "Anzeigen",
"View PDF": "PDF anzeigen",
"View Shopping List": "Einkaufsliste ansehen",
Expand Down
4 changes: 4 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"Avatar": "Avatar",
"Avatar removed successfully.": "Avatar removed successfully.",
"Avatar updated successfully.": "Avatar updated successfully.",
"Based on Canonical Recipe": "Recipe Variant",
"Back to Lists": "Back to Lists",
"Back to Login": "Back to Login",
"Back to recipes": "Back to recipes",
Expand Down Expand Up @@ -142,6 +143,7 @@
"Nutrition": "Nutrition",
"Oldest first": "Oldest first",
"Only the owner can share this list.": "Only the owner can share this list.",
"Show Canonical Recipes": "Include Recipe Variants",
"Only with PDF": "Only with PDF",
"Optional description for your list": "Optional description for your list",
"Page Expired": "Page Expired",
Expand Down Expand Up @@ -229,6 +231,7 @@
"Tags": "Tags",
"Terms of Use": "Terms of Use",
"This list is already shared with this user.": "This list is already shared with this user.",
"This recipe is based on": "This recipe is based on",
"This list is empty": "This list is empty",
"This password reset link will expire in :count minutes.": "This password reset link will expire in :count minutes.",
"Too Many Requests": "Too Many Requests",
Expand All @@ -246,6 +249,7 @@
"User removed from shared list.": "User removed from shared list.",
"Utensils": "Utensils",
"Verify Email Address": "Verify Email Address",
"Variants of this Recipe": "Variants of this Recipe",
"View": "View",
"View PDF": "View PDF",
"View Shopping List": "View Shopping List",
Expand Down
4 changes: 4 additions & 0 deletions lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"Avatar": "Avatar",
"Avatar removed successfully.": "Avatar eliminado correctamente.",
"Avatar updated successfully.": "Avatar actualizado correctamente.",
"Based on Canonical Recipe": "Variante de receta",
"Back to Lists": "Volver a las listas",
"Back to Login": "Volver al inicio de sesión",
"Browse Recipes": "Explorar recetas",
Expand Down Expand Up @@ -143,6 +144,7 @@
"Not Found": "No encontrado",
"Oldest first": "Más antiguas primero",
"Only the owner can share this list.": "Solo el propietario puede compartir esta lista.",
"Show Canonical Recipes": "Incluir variantes de recetas",
"Only with PDF": "Solo con PDF",
"Optional description for your list": "Descripción opcional para tu lista",
"Page Expired": "Página expirada",
Expand Down Expand Up @@ -229,6 +231,7 @@
"System": "Sistema",
"Terms of Use": "Condiciones de uso",
"This list is already shared with this user.": "Esta lista ya está compartida con este usuario.",
"This recipe is based on": "Esta receta está basada en",
"This list is empty": "Esta lista está vacía",
"This password reset link will expire in :count minutes.": "Este enlace de restablecimiento de contraseña expirará en :count minutos.",
"Too Many Requests": "Demasiadas peticiones",
Expand All @@ -244,6 +247,7 @@
"Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Sube una foto de perfil. La imagen debe ser cuadrada y tener entre 200x200 y 1000x1000 píxeles.",
"Upload new avatar": "Subir nuevo avatar",
"User removed from shared list.": "Usuario eliminado de la lista compartida.",
"Variants of this Recipe": "Variantes de esta receta",
"Verify Email Address": "Confirme su correo electrónico",
"View": "Ver",
"View PDF": "Ver PDF",
Expand Down
Loading
Loading