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

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

## [3.15.0] - 2026-01-27

### Added

- **Published Status Tracking** - Track which recipes are currently published on HelloFresh
- New `published` field in API responses (replaces `variant` field)
- Published statistics in portal dashboard showing published vs unpublished recipe counts
- Filter "Hide Archived" to show only currently published recipes

- **Unpublished Recipe Notice** - Warning callout on recipe detail pages when recipe is no longer available on HelloFresh

### Changed

- **Variant Display Logic** - Improved variant relationships on recipe detail pages
- Variant recipes now show all sibling variants plus the original recipe
- Previously only showed direct children for canonical recipes

- **HelloFresh Links** - Links to HelloFresh website now only shown for published recipes (previously hidden for variants)

### Removed

- **Variant Filter** - Removed "Hide Variants" filter as it's replaced by published status tracking
- **Variant API Field** - Removed `variant` field from API responses (use `canonical_id` to identify variants)
- **Canonical Recipe Notice** - Removed "Based on Canonical Recipe" callout from recipe detail pages

## [3.14.1] - 2026-01-26

### Changed
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Resources/Api/RecipeCollectionResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function toArray(Request $request): array
return [
'id' => $this->id,
'canonical_id' => $this->canonical_id,
'variant' => $this->variant,
'published' => $this->published,
'url' => config('app.url') . '/' . $locale . '-' . resolve('current.country')->code .
'/recipes/' . slugify($this->getTranslationWithAnyFallback('name', $locale)) . '-' . $this->id,
'name' => $this->getTranslationWithAnyFallback('name', $locale),
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Resources/Api/RecipeResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function toArray(Request $request): array
return [
'id' => $this->id,
'canonical_id' => $this->canonical_id,
'variant' => $this->variant,
'published' => $this->published,
'url' => config('app.url') . '/' . $locale . '-' . resolve('current.country')->code .
'/recipes/' . slugify($this->getTranslationWithAnyFallback('name', $locale)) . '-' . $this->id,
'name' => $this->getTranslationWithAnyFallback('name', $locale),
Expand Down
2 changes: 1 addition & 1 deletion app/Jobs/Recipe/ImportRecipeJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ protected function importRecipe(): ?Recipe
'nutrition_' . $suffix => $this->recipe['nutrition'],
'yields_' . $suffix => $this->recipe['yields'],
'variant' => $this->recipe['canonical'] !== '' && $this->recipe['canonical'] !== $this->recipe['id'],
'is_published' => $this->recipe['isPublished'],
'published' => $this->recipe['isPublished'],
'hellofresh_created_at' => $this->recipe['createdAt'],
'hellofresh_updated_at' => $this->recipe['updatedAt'],
];
Expand Down
2 changes: 1 addition & 1 deletion app/Livewire/Portal/Docs/RecipesIndexDoc.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,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' => 'variant', 'type' => 'boolean', 'description' => 'Whether this recipe is a variant of another recipe'],
['name' => 'published', 'type' => 'boolean', 'description' => 'Whether this recipe is currently published on HelloFresh'],
['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
2 changes: 1 addition & 1 deletion app/Livewire/Portal/Docs/RecipesShowDoc.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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' => 'variant', 'type' => 'boolean', 'description' => 'Whether this recipe is a variant of another recipe'],
['name' => 'published', 'type' => 'boolean', 'description' => 'Whether this recipe is currently published on HelloFresh'],
['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 @@ -178,6 +178,17 @@ public function variantStats(): array
return $this->statistics()->variantStats();
}

/**
* Get published recipe statistics.
*
* @return array{published: int, unpublished: int, unpublished_percentage: float}
*/
#[Computed]
public function publishedStats(): array
{
return $this->statistics()->publishedStats();
}

public function render(): View
{
/** @var View $view */
Expand Down
6 changes: 3 additions & 3 deletions app/Livewire/Web/Concerns/WithFilterShareTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* @property string $search
* @property string $sortBy
* @property bool $filterHasPdf
* @property bool $filterHideVariants
* @property bool $filterOnlyPublished
* @property array<int> $excludedAllergenIds
* @property array<int> $ingredientIds
* @property string $ingredientMatchMode
Expand Down Expand Up @@ -167,8 +167,8 @@ protected function collectBooleanFilters(array &$filters): void
$filters['has_pdf'] = true;
}

if ($this->filterHideVariants) {
$filters['hide_variants'] = true;
if ($this->filterOnlyPublished) {
$filters['only_published'] = true;
}

$filters['ingredient_match'] = $this->ingredientMatchMode;
Expand Down
14 changes: 7 additions & 7 deletions app/Livewire/Web/Recipes/RecipeIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class RecipeIndex extends AbstractComponent

public bool $filterHasPdf = false;

public bool $filterHideVariants = false;
public bool $filterOnlyPublished = false;

/** @var array<int> */
public array $excludedAllergenIds = [];
Expand Down Expand Up @@ -92,7 +92,7 @@ public function mount(?Menu $menu = null): void
$this->selectedMenuWeek = $this->menu?->year_week;
$this->viewMode = session('view_mode', ViewModeEnum::Grid->value);
$this->filterHasPdf = session($this->filterSessionKey('has_pdf'), false);
$this->filterHideVariants = session($this->filterSessionKey('hide_variants'), false);
$this->filterOnlyPublished = session($this->filterSessionKey('only_published'), 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 @@ -281,7 +281,7 @@ protected function getSessionMapping(): array
{
return [
'filterHasPdf' => 'has_pdf',
'filterHideVariants' => 'hide_variants',
'filterOnlyPublished' => 'only_published',
'excludedAllergenIds' => 'excluded_allergens',
'ingredientIds' => 'ingredients',
'ingredientMatchMode' => 'ingredient_match',
Expand All @@ -308,7 +308,7 @@ public function activeFilterCount(): int
$count++;
}

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

Expand Down Expand Up @@ -387,7 +387,7 @@ public function isTagActive(int $tagId): bool
public function clearFilters(): void
{
$this->filterHasPdf = false;
$this->filterHideVariants = false;
$this->filterOnlyPublished = false;
$this->excludedAllergenIds = [];
$this->ingredientIds = [];
$this->ingredientMatchMode = IngredientMatchModeEnum::Any->value;
Expand All @@ -402,7 +402,7 @@ public function clearFilters(): void

session()->forget([
$this->filterSessionKey('has_pdf'),
$this->filterSessionKey('hide_variants'),
$this->filterSessionKey('only_published'),
$this->filterSessionKey('excluded_allergens'),
$this->filterSessionKey('ingredients'),
$this->filterSessionKey('ingredient_match'),
Expand Down Expand Up @@ -432,7 +432,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->filterHideVariants, fn (Builder $query) => $query->where('variant', false))
->when($this->filterOnlyPublished, fn (Builder $query) => $query->where('published', true))
->when($this->excludedAllergenIds !== [], fn (Builder $query) => $query->whereDoesntHave(
'allergens',
fn (Builder $allergenQuery) => $allergenQuery->whereIn('allergens.id', $this->excludedAllergenIds)
Expand Down
2 changes: 1 addition & 1 deletion app/Livewire/Web/Recipes/RecipeRandom.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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))
->when($this->filterHideVariants, fn (Builder $query) => $query->where('variant', false))
->when($this->filterOnlyPublished, fn (Builder $query) => $query->where('published', true))
->when($this->excludedAllergenIds !== [], fn (Builder $query) => $query->whereDoesntHave(
'allergens',
fn (Builder $allergenQuery) => $allergenQuery->whereIn('allergens.id', $this->excludedAllergenIds)
Expand Down
32 changes: 29 additions & 3 deletions app/Livewire/Web/Recipes/RecipeShow.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ public function mount(Recipe $recipe): void
'utensils',
'ingredients',
'canonical.country',
'variants.country',
'variants.label',
'variants.tags',
]);

// Set default yield based on available yields
Expand Down Expand Up @@ -203,6 +200,34 @@ public function nutrition(): array
return $nutrition !== null ? array_values($nutrition) : [];
}

/**
* Get all related variants (siblings if this is a variant, or children if this is a canonical recipe).
*
* @return Collection<int, Recipe>
*/
#[Computed]
public function relatedVariants(): Collection
{
// If this recipe is a variant, show canonical + sibling variants
if ($this->recipe->canonical_id !== null) {
$variants = Recipe::where('canonical_id', $this->recipe->canonical_id)
->where('published', true)
->whereNot('id', $this->recipe->id)
->with(['country', 'label', 'tags'])
->get();

// Add the canonical recipe to the collection
if ($this->recipe->canonical?->published) {
$variants->prepend($this->recipe->canonical);
}

return $variants;
}

// If this recipe is canonical, show its variants
return $this->recipe->variants()->with(['country', 'label', 'tags'])->get();
}

/**
* Get similar recipes based on shared tags.
*
Expand All @@ -222,6 +247,7 @@ public function similarRecipes(): Collection
$excludedAllergenIds = session($sessionKey, []);

return Recipe::where('country_id', $this->recipe->country_id)
->where('published', true)
->whereNot('id', $this->recipe->id)
->whereNotNull('name->' . app()->getLocale())
->whereHas('tags', fn (Builder $query): Builder => $query->whereIn('tags.id', $tagIds))
Expand Down
10 changes: 5 additions & 5 deletions app/Models/Recipe.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class Recipe extends Model
'yields_primary',
'yields_secondary',
'variant',
'is_published',
'published',
'hellofresh_created_at',
'hellofresh_updated_at',
];
Expand Down Expand Up @@ -114,7 +114,7 @@ protected function casts(): array
'yields_secondary' => 'array',
'has_pdf' => 'bool',
'variant' => 'bool',
'is_published' => 'bool',
'published' => 'bool',
'hellofresh_created_at' => 'datetime',
'hellofresh_updated_at' => 'datetime',
];
Expand Down Expand Up @@ -147,7 +147,7 @@ public function canonical(): BelongsTo
*/
public function variants(): HasMany
{
return $this->hasMany(self::class, 'canonical_id');
return $this->hasMany(self::class, 'canonical_id')->where('published', true);
}

/**
Expand Down Expand Up @@ -255,8 +255,8 @@ protected function hellofreshUrl(): Attribute
*/
protected function buildHellofreshUrl(): ?string
{
// Variant recipes often lead to 404 pages on HelloFresh
if ($this->variant) {
// Unpublished recipes lead to 404 pages on HelloFresh
if (! $this->published) {
return null;
}

Expand Down
22 changes: 22 additions & 0 deletions app/Services/Portal/StatisticsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class StatisticsService
'portal_avg_prep_times',
'portal_data_health',
'portal_variant_stats',
'portal_published_stats',
];

/**
Expand Down Expand Up @@ -337,4 +338,25 @@ public function variantStats(): array
];
});
}

/**
* Get published recipe statistics.
*
* @return array{published: int, unpublished: int, unpublished_percentage: float}
*/
public function publishedStats(): array
{
/** @var array{published: int, unpublished: int, unpublished_percentage: float} */
return Cache::remember('portal_published_stats', $this->cacheTtl, static function (): array {
$total = Recipe::count();
$published = Recipe::where('published', true)->count();
$unpublished = Recipe::where('published', false)->count();

return [
'published' => $published,
'unpublished' => $unpublished,
'unpublished_percentage' => $total > 0 ? round(($unpublished / $total) * 100, 1) : 0.0,
];
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/** @noinspection DuplicatedCode */

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('recipes', static function (Blueprint $table): void {
$table->renameColumn('is_published', 'published');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recipes', function (Blueprint $table): void {
$table->renameColumn('published', 'is_published');
});
}
};
Loading
Loading