diff --git a/CHANGELOG.md b/CHANGELOG.md index db376716..875c4683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/Http/Resources/Api/RecipeCollectionResource.php b/app/Http/Resources/Api/RecipeCollectionResource.php index d808c2de..b36f4325 100644 --- a/app/Http/Resources/Api/RecipeCollectionResource.php +++ b/app/Http/Resources/Api/RecipeCollectionResource.php @@ -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), diff --git a/app/Http/Resources/Api/RecipeResource.php b/app/Http/Resources/Api/RecipeResource.php index 6afc6c20..c008a014 100644 --- a/app/Http/Resources/Api/RecipeResource.php +++ b/app/Http/Resources/Api/RecipeResource.php @@ -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), diff --git a/app/Livewire/Portal/Docs/RecipesIndexDoc.php b/app/Livewire/Portal/Docs/RecipesIndexDoc.php index e741567f..81d6a42b 100644 --- a/app/Livewire/Portal/Docs/RecipesIndexDoc.php +++ b/app/Livewire/Portal/Docs/RecipesIndexDoc.php @@ -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)'], diff --git a/app/Livewire/Portal/Docs/RecipesShowDoc.php b/app/Livewire/Portal/Docs/RecipesShowDoc.php index e2bb9647..aeebd18d 100644 --- a/app/Livewire/Portal/Docs/RecipesShowDoc.php +++ b/app/Livewire/Portal/Docs/RecipesShowDoc.php @@ -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)'], diff --git a/app/Livewire/Portal/Stats/RecipeStats.php b/app/Livewire/Portal/Stats/RecipeStats.php index 168a1ffd..d9202710 100644 --- a/app/Livewire/Portal/Stats/RecipeStats.php +++ b/app/Livewire/Portal/Stats/RecipeStats.php @@ -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 */ diff --git a/app/Livewire/Web/Recipes/RecipeIndex.php b/app/Livewire/Web/Recipes/RecipeIndex.php index c88cf32f..4a9de263 100644 --- a/app/Livewire/Web/Recipes/RecipeIndex.php +++ b/app/Livewire/Web/Recipes/RecipeIndex.php @@ -41,6 +41,8 @@ class RecipeIndex extends AbstractComponent public bool $filterHasPdf = false; + public bool $filterShowCanonical = false; + /** @var array */ public array $excludedAllergenIds = []; @@ -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); @@ -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', @@ -275,6 +279,10 @@ public function activeFilterCount(): int $count++; } + if ($this->filterShowCanonical) { + $count++; + } + if ($this->excludedAllergenIds !== []) { $count++; } @@ -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; @@ -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'), @@ -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) diff --git a/app/Livewire/Web/Recipes/RecipeRandom.php b/app/Livewire/Web/Recipes/RecipeRandom.php index 8dbf3f52..057e4075 100644 --- a/app/Livewire/Web/Recipes/RecipeRandom.php +++ b/app/Livewire/Web/Recipes/RecipeRandom.php @@ -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) diff --git a/app/Livewire/Web/Recipes/RecipeShow.php b/app/Livewire/Web/Recipes/RecipeShow.php index 13e61e4d..6ffcb38b 100644 --- a/app/Livewire/Web/Recipes/RecipeShow.php +++ b/app/Livewire/Web/Recipes/RecipeShow.php @@ -37,6 +37,8 @@ public function mount(Recipe $recipe): void 'cuisines', 'utensils', 'ingredients', + 'canonical.country', + 'variants.country', ]); // Set default yield based on available yields diff --git a/app/Models/Recipe.php b/app/Models/Recipe.php index 6c2faac6..2071d1cc 100644 --- a/app/Models/Recipe.php +++ b/app/Models/Recipe.php @@ -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; diff --git a/app/Services/Portal/StatisticsService.php b/app/Services/Portal/StatisticsService.php index 98ef4fe2..89f9469b 100644 --- a/app/Services/Portal/StatisticsService.php +++ b/app/Services/Portal/StatisticsService.php @@ -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; @@ -40,6 +41,7 @@ class StatisticsService 'portal_recipes_per_month', 'portal_avg_prep_times', 'portal_data_health', + 'portal_canonical_stats', ]; /** @@ -70,6 +72,7 @@ public function warmCache(): void $this->recipesPerMonth(); $this->avgPrepTimesByCountry(); $this->dataHealth(); + $this->canonicalStats(); } /** @@ -101,6 +104,7 @@ public function countryStats(): Collection /** @var Collection */ 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()); } @@ -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, + ]; + }); + } } diff --git a/config/api.php b/config/api.php index 468203c3..28f191c8 100644 --- a/config/api.php +++ b/config/api.php @@ -82,7 +82,7 @@ | */ - 'version' => env('API_VERSION', 'unknown'), + 'version' => (string) env('API_VERSION', '') !== '' ? env('API_VERSION') : 'unknown', /* |-------------------------------------------------------------------------- diff --git a/lang/da.json b/lang/da.json index 0cf2270f..dc5836e2 100644 --- a/lang/da.json +++ b/lang/da.json @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/lang/de.json b/lang/de.json index d0b0717e..354b551a 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/lang/en.json b/lang/en.json index 639eb712..1bbdd638 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/lang/es.json b/lang/es.json index d1facb4f..768308a2 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/lang/fr.json b/lang/fr.json index bcb6de75..96c41f73 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -22,6 +22,7 @@ "Avatar": "Avatar", "Avatar removed successfully.": "Avatar supprimé avec succès.", "Avatar updated successfully.": "Avatar mis à jour avec succès.", + "Based on Canonical Recipe": "Variante de recette", "Back to Lists": "Retour aux listes", "Back to Login": "Retour à la connexion", "Browse Recipes": "Parcourir les recettes", @@ -143,6 +144,7 @@ "Not Found": "Non trouvé", "Oldest first": "Plus anciennes d'abord", "Only the owner can share this list.": "Seul le propriétaire peut partager cette liste.", + "Show Canonical Recipes": "Inclure les variantes de recettes", "Only with PDF": "Uniquement avec PDF", "Optional description for your list": "Description optionnelle pour votre liste", "Page Expired": "Page expirée", @@ -229,6 +231,7 @@ "System": "Système", "Terms of Use": "Conditions d'utilisation", "This list is already shared with this user.": "Cette liste est déjà partagée avec cet utilisateur.", + "This recipe is based on": "Cette recette est basée sur", "This list is empty": "Cette liste est vide", "This password reset link will expire in :count minutes.": "Ce lien de réinitialisation du mot de passe expirera dans :count minutes.", "Too Many Requests": "Trop de requêtes", @@ -244,6 +247,7 @@ "Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Téléchargez une photo de profil. L'image doit être carrée et mesurer entre 200x200 et 1000x1000 pixels.", "Upload new avatar": "Télécharger un nouvel avatar", "User removed from shared list.": "Utilisateur supprimé de la liste partagée.", + "Variants of this Recipe": "Variantes de cette recette", "Verify Email Address": "Vérifier l'adresse e-mail", "View": "Voir", "View PDF": "Voir le PDF", diff --git a/lang/it.json b/lang/it.json index c9b214b3..cab0faff 100644 --- a/lang/it.json +++ b/lang/it.json @@ -22,6 +22,7 @@ "Avatar": "Avatar", "Avatar removed successfully.": "Avatar rimosso con successo.", "Avatar updated successfully.": "Avatar aggiornato con successo.", + "Based on Canonical Recipe": "Variante della ricetta", "Back to Lists": "Torna alle liste", "Back to Login": "Torna al login", "Browse Recipes": "Sfoglia ricette", @@ -143,6 +144,7 @@ "Not Found": "Non trovato", "Oldest first": "Più vecchie prima", "Only the owner can share this list.": "Solo il proprietario può condividere questa lista.", + "Show Canonical Recipes": "Includi varianti delle ricette", "Only with PDF": "Solo con PDF", "Optional description for your list": "Descrizione opzionale per la tua lista", "Page Expired": "Pagina scaduta", @@ -229,6 +231,7 @@ "System": "Sistema", "Terms of Use": "Condizioni d'uso", "This list is already shared with this user.": "Questa lista è già condivisa con questo utente.", + "This recipe is based on": "Questa ricetta è basata su", "This list is empty": "Questa lista è vuota", "This password reset link will expire in :count minutes.": "Questo link di reset della password scadrà tra :count minuti.", "Too Many Requests": "Troppe richieste", @@ -244,6 +247,7 @@ "Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Carica un'immagine del profilo. L'immagine deve essere quadrata e tra 200x200 e 1000x1000 pixel.", "Upload new avatar": "Carica nuovo avatar", "User removed from shared list.": "Utente rimosso dalla lista condivisa.", + "Variants of this Recipe": "Varianti di questa ricetta", "Verify Email Address": "Verifica indirizzo email", "View": "Visualizza", "View PDF": "Visualizza PDF", diff --git a/lang/nb.json b/lang/nb.json index e6aa00c4..edc7a8f2 100644 --- a/lang/nb.json +++ b/lang/nb.json @@ -22,6 +22,7 @@ "Avatar": "Avatar", "Avatar removed successfully.": "Avatar fjernet.", "Avatar updated successfully.": "Avatar oppdatert.", + "Based on Canonical Recipe": "Oppskriftsvariant", "Back to Lists": "Tilbake til lister", "Back to Login": "Tilbake til innlogging", "Browse Recipes": "Bla gjennom oppskrifter", @@ -143,6 +144,7 @@ "Not Found": "Ikke funnet", "Oldest first": "Eldste først", "Only the owner can share this list.": "Bare eieren kan dele denne listen.", + "Show Canonical Recipes": "Vis også oppskriftsvarianter", "Only with PDF": "Kun med PDF", "Optional description for your list": "Valgfri beskrivelse for listen din", "Page Expired": "Siden har utløpt", @@ -229,6 +231,7 @@ "System": "System", "Terms of Use": "Bruksvilkår", "This list is already shared with this user.": "Denne listen er allerede delt med denne brukeren.", + "This recipe is based on": "Denne oppskriften er basert på", "This list is empty": "Denne listen er tom", "This password reset link will expire in :count minutes.": "Lenken for å tilbakestille passordet utløper om :count minutter.", "Too Many Requests": "For mange forespørsler", @@ -244,6 +247,7 @@ "Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Last opp et profilbilde. Bildet må være kvadratisk og mellom 200x200 og 1000x1000 piksler.", "Upload new avatar": "Last opp ny avatar", "User removed from shared list.": "Bruker fjernet fra delt liste.", + "Variants of this Recipe": "Varianter av denne oppskriften", "Verify Email Address": "Bekreft e-postadresse", "View": "Vis", "View PDF": "Se PDF", diff --git a/lang/nl.json b/lang/nl.json index 949e3c7c..bfcf60d7 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -22,6 +22,7 @@ "Avatar": "Avatar", "Avatar removed successfully.": "Avatar succesvol verwijderd.", "Avatar updated successfully.": "Avatar succesvol bijgewerkt.", + "Based on Canonical Recipe": "Receptvariant", "Back to Lists": "Terug naar lijsten", "Back to Login": "Terug naar inloggen", "Browse Recipes": "Recepten bekijken", @@ -143,6 +144,7 @@ "Not Found": "Niet gevonden", "Oldest first": "Oudste eerst", "Only the owner can share this list.": "Alleen de eigenaar kan deze lijst delen.", + "Show Canonical Recipes": "Ook receptvarianten tonen", "Only with PDF": "Alleen met PDF", "Optional description for your list": "Optionele beschrijving voor je lijst", "Page Expired": "Pagina niet meer geldig", @@ -229,6 +231,7 @@ "System": "Systeem", "Terms of Use": "Gebruiksvoorwaarden", "This list is already shared with this user.": "Deze lijst is al gedeeld met deze gebruiker.", + "This recipe is based on": "Dit recept is gebaseerd op", "This list is empty": "Deze lijst is leeg", "This password reset link will expire in :count minutes.": "Deze link om je wachtwoord te herstellen verloopt over :count minuten.", "Too Many Requests": "Te veel serververzoeken", @@ -244,6 +247,7 @@ "Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Upload een profielfoto. De afbeelding moet vierkant zijn en tussen 200x200 en 1000x1000 pixels.", "Upload new avatar": "Nieuwe avatar uploaden", "User removed from shared list.": "Gebruiker verwijderd uit gedeelde lijst.", + "Variants of this Recipe": "Varianten van dit recept", "Verify Email Address": "Verifieer e-mailadres", "View": "Bekijken", "View PDF": "PDF bekijken", diff --git a/lang/sv.json b/lang/sv.json index bcf05bec..60eead6f 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -22,6 +22,7 @@ "Avatar": "Avatar", "Avatar removed successfully.": "Avatar borttagen.", "Avatar updated successfully.": "Avatar uppdaterad.", + "Based on Canonical Recipe": "Receptvariant", "Back to Lists": "Tillbaka till listor", "Back to Login": "Tillbaka till inloggning", "Browse Recipes": "Bläddra bland recept", @@ -143,6 +144,7 @@ "Not Found": "Hittades ej", "Oldest first": "Äldsta först", "Only the owner can share this list.": "Endast ägaren kan dela denna lista.", + "Show Canonical Recipes": "Visa även receptvarianter", "Only with PDF": "Endast med PDF", "Optional description for your list": "Valfri beskrivning för din lista", "Page Expired": "Sidan är utgången", @@ -229,6 +231,7 @@ "System": "System", "Terms of Use": "Användarvillkor", "This list is already shared with this user.": "Denna lista är redan delad med denna användare.", + "This recipe is based on": "Detta recept är baserat på", "This list is empty": "Denna lista är tom", "This password reset link will expire in :count minutes.": "Denna återställningslänk kommer att gå ut om :count minuter.", "Too Many Requests": "För många anrop", @@ -244,6 +247,7 @@ "Upload a profile picture. Image must be square and between 200x200 and 1000x1000 pixels.": "Ladda upp en profilbild. Bilden måste vara kvadratisk och mellan 200x200 och 1000x1000 pixlar.", "Upload new avatar": "Ladda upp ny avatar", "User removed from shared list.": "Användare borttagen från delad lista.", + "Variants of this Recipe": "Varianter av detta recept", "Verify Email Address": "Bekräfta e-postadress", "View": "Visa", "View PDF": "Visa PDF", diff --git a/resources/views/flux/icon/git-branch.blade.php b/resources/views/flux/icon/git-branch.blade.php new file mode 100644 index 00000000..63573766 --- /dev/null +++ b/resources/views/flux/icon/git-branch.blade.php @@ -0,0 +1,46 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/portal/livewire/stats/recipe-stats.blade.php b/resources/views/portal/livewire/stats/recipe-stats.blade.php index 9afb5a65..e6ecbe99 100644 --- a/resources/views/portal/livewire/stats/recipe-stats.blade.php +++ b/resources/views/portal/livewire/stats/recipe-stats.blade.php @@ -100,6 +100,23 @@ + {{-- Canonical Stats --}} + +
+
+ +
+
+ Canonical Recipes + {{ Number::format($this->canonicalStats['total_canonical']) }} +
+
+ {{ $this->canonicalStats['canonical_percentage'] }}% of all recipes + {{ Number::format($this->canonicalStats['unique_canonical_parents']) }} unique parents +
+
+
+ {{-- Recipe Quality & Data Health --}}
@@ -161,6 +178,7 @@ Name Locales Recipes + Variants with PDF Ingredients Menus @@ -182,6 +200,9 @@ {{ Number::format($country->recipes_count ?? 0) }} + + {{ Number::format($country->variants_count ?? 0) }} + {{ Number::format($country->recipes_with_pdf_count ?? 0) }} diff --git a/resources/views/web/livewire/recipes/recipe-show.blade.php b/resources/views/web/livewire/recipes/recipe-show.blade.php index b3c213dc..b1f26101 100644 --- a/resources/views/web/livewire/recipes/recipe-show.blade.php +++ b/resources/views/web/livewire/recipes/recipe-show.blade.php @@ -89,6 +89,37 @@ class="rounded-full p-2 transition-colors bg-zinc-100 text-zinc-700 hover:bg-zin
+ {{-- Canonical Recipe Info --}} + @if ($recipe->canonical) + + {{ __('Based on Canonical Recipe') }} + + {{ __('This recipe is based on') }} + + {{ $recipe->canonical->name ?: $recipe->canonical->getFirstTranslation('name') }} + + ({{ $recipe->canonical->country->code }}). + + + @endif + + {{-- Variant Recipes --}} + @if ($recipe->variants->isNotEmpty()) +
+ {{ __('Variants of this Recipe') }} +
+ @foreach ($recipe->variants as $variant) + + + + {{ $variant->name ?: $variant->getFirstTranslation('name') }} + + + @endforeach +
+
+ @endif + {{-- Description --}} @if ($recipe->description)
diff --git a/resources/views/web/partials/recipes/filter-modal.blade.php b/resources/views/web/partials/recipes/filter-modal.blade.php index bda6405c..ba7f8ace 100644 --- a/resources/views/web/partials/recipes/filter-modal.blade.php +++ b/resources/views/web/partials/recipes/filter-modal.blade.php @@ -5,6 +5,8 @@ + + {{ __('Difficulty') }}