refactor: fron and back recipe - products association

This commit is contained in:
Jérémy Munsch 2026-07-09 15:41:15 +02:00
parent b6d08c8bd1
commit 28f447c8ec
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
20 changed files with 577 additions and 60 deletions

View file

@ -8,6 +8,7 @@ use App\Contracts\Directory;
use App\Dtos\CategoryProducts;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Illuminate\Pagination\LengthAwarePaginator;
@ -19,6 +20,11 @@ interface ProductDirectory extends Directory
public function findBySlug(string $slug): ?Product;
/**
* @return \App\Dtos\ProductSummary[]
*/
public function getAllProductsSummary(): array;
/**
* @return LengthAwarePaginator<array-key,Product>
*/
@ -41,4 +47,7 @@ interface ProductDirectory extends Directory
string $search = '',
int $limitPerCategory = 6
): ?CategoryProducts;
/** @return \Domain\Models\Entities\Product[] */
public function getRelatedProducts(RecipeId $recipeId, int $limit = 4): array;
}

View file

@ -6,6 +6,7 @@ namespace App\Contracts\Directories;
use App\Contracts\Directory;
use Domain\Models\Entities\Recipe;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Illuminate\Pagination\LengthAwarePaginator;
@ -32,4 +33,7 @@ interface RecipeDirectory extends Directory
array $seasons = [],
string $search = ''
): LengthAwarePaginator;
/** @return \Domain\Models\Entities\Recipe[] */
public function getRelatedRecipes(ProductId $productId, int $limit = 4): array;
}

View file

@ -6,10 +6,12 @@ namespace App\Directories;
use App\Contracts\Directories\ProductDirectory;
use App\Dtos\CategoryProducts;
use App\Dtos\ProductSummary;
use App\Models\Product;
use App\Models\Recipe;
use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Illuminate\Database\Eloquent\Builder;
@ -41,6 +43,16 @@ class EloquentProductDirectory implements ProductDirectory
->first();
}
public function getAllProductsSummary(): array
{
return Product::query()
->select(['id', 'name', 'picture'])
->orderBy('name', 'asc')
->get()
->map(fn (Product $product) => $this->mapToSummary($product))
->all();
}
public function getPaginatedProducts(
Pagination $pagination,
string $search = '',
@ -152,6 +164,15 @@ class EloquentProductDirectory implements ProductDirectory
);
}
private function mapToSummary(Product $product): ProductSummary
{
return new ProductSummary(
id: (string) $product->id,
name: $product->name,
picture: $product->picture,
);
}
private function mapToEntity(Product $product): DomainProduct
{
return new DomainProduct(
@ -179,4 +200,22 @@ class EloquentProductDirectory implements ProductDirectory
)->all()
);
}
public function getRelatedProducts(RecipeId $recipeId, int $limit = 4): array
{
$recipe = Recipe::query()
->where('id', $recipeId->value)
->first();
if (! $recipe) {
return [];
}
return $recipe->products()
->with(['recipes'])
->limit($limit)
->get()
->map(fn (Product $product) => $this->mapToEntity($product))
->all();
}
}

View file

@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Directories;
use App\Contracts\Directories\RecipeDirectory;
use App\Models\Product;
use App\Models\Recipe;
use Domain\Models\Entities\Recipe as DomainRecipe;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Illuminate\Database\Eloquent\Builder;
@ -118,4 +120,22 @@ class EloquentRecipeDirectory implements RecipeDirectory
associatedProducts: []
);
}
public function getRelatedRecipes(ProductId $productId, int $limit = 4): array
{
$product = Product::query()
->where('id', (string) $productId->value)
->first();
if (! $product) {
return [];
}
return $product->recipes()
->with(['products'])
->limit($limit)
->get()
->map(fn (Recipe $recipe) => $this->mapToEntity($recipe))
->all();
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Dtos;
final readonly class ProductSummary
{
public function __construct(
public string $id,
public string $name,
public ?string $picture,
) {
}
}

View file

@ -100,7 +100,9 @@ class ProductController extends BackendController
return DB::transaction(function () use ($request) {
$product = new Product();
$product->inertiaFill($request->validated());
$product->fill($request->only(
$product->getFillable()
));
if ($product->saveOrFail()) {
return redirect()->route('back.products.edit', $product)
@ -137,7 +139,9 @@ class ProductController extends BackendController
$this->authorize('update', $product);
return DB::transaction(function () use ($request, $product) {
$product->inertiaFill($request->validated());
$product->fill($request->only(
$product->getFillable()
));
if ($product->saveOrFail()) {
return redirect()->route('back.products.edit', $product)

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Back;
use App\Contracts\Directories\ProductDirectory;
use App\Http\Requests\Back\Recipes\IndexRecipeRequest;
use App\Http\Requests\Back\Recipes\StoreRecipeRequest;
use App\Http\Requests\Back\Recipes\UpdateRecipeRequest;
@ -25,6 +26,7 @@ class RecipeController extends BackendController
public function __construct(
Request $request,
Session $session,
private readonly ProductDirectory $productDirectory,
) {
parent::__construct(
$request,
@ -91,6 +93,7 @@ class RecipeController extends BackendController
'enumConsumptionTime' => ConsumptionTime::toArray(),
'enumInspiration' => Inspiration::toArray(),
'enumSeason' => Season::toArray(),
'allProducts' => $this->productDirectory->getAllProductsSummary(),
]);
}
@ -100,9 +103,14 @@ class RecipeController extends BackendController
return DB::transaction(function () use ($request) {
$recipe = new Recipe();
$recipe->inertiaFill($request->validated());
$recipe->fill($request->only(
$recipe->getFillable()
));
if ($recipe->saveOrFail()) {
$recipe->products()
->sync($request->array('products'));
return redirect()->route('back.recipes.edit', $recipe)
->with('success', trans('uwil-backend::crud.messages.has_been_created', [
'model' => Str::ucfirst(trans_choice('models.classes.recipes', 1)),
@ -120,12 +128,15 @@ class RecipeController extends BackendController
{
$this->authorize('update', $recipe);
$recipe->load('products');
return $this->inertiaRender('back/Pages/Recipes/Edit', [
'data' => CommonResource::make($recipe),
'enumProductCategory' => ProductCategory::toArray(),
'enumConsumptionTime' => ConsumptionTime::toArray(),
'enumInspiration' => Inspiration::toArray(),
'enumSeason' => Season::toArray(),
'allProducts' => $this->productDirectory->getAllProductsSummary(),
]);
}
@ -136,9 +147,14 @@ class RecipeController extends BackendController
$this->authorize('update', $recipe);
return DB::transaction(function () use ($request, $recipe) {
$recipe->inertiaFill($request->validated());
$recipe->fill($request->only(
$recipe->getFillable()
));
if ($recipe->saveOrFail()) {
$recipe->products()
->sync($request->array('products'));
return redirect()->route('back.recipes.edit', $recipe)
->with('success', trans('uwil-backend::crud.messages.has_been_updated', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Front;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Directories\RecipeDirectory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Front\Products\IndexProductRequest;
use Domain\Models\Entities\Product;
@ -16,6 +17,7 @@ class ProductController extends Controller
{
public function __construct(
private readonly ProductDirectory $productDirectory,
private readonly RecipeDirectory $recipeDirectory,
) {
}
@ -62,8 +64,12 @@ class ProductController extends Controller
public function show(Product $product): Response
{
$relatedRecipes = $this->recipeDirectory
->getRelatedRecipes($product->id(), 10);
return Inertia::render('front/Pages/Products/Show', [
'product' => $product,
'product' => $product,
'relatedRecipes' => $relatedRecipes,
]);
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Front;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Directories\RecipeDirectory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Front\Recipes\IndexRecipeRequest;
@ -17,6 +18,7 @@ class RecipeController extends Controller
{
public function __construct(
private readonly RecipeDirectory $recipeDirectory,
private readonly ProductDirectory $productDirectory,
) {
}
@ -66,8 +68,12 @@ class RecipeController extends Controller
public function show(Recipe $recipe): Response
{
$relatedProducts = $this->productDirectory
->getRelatedProducts($recipe->id(), 10);
return Inertia::render('front/Pages/Recipes/Show', [
'recipe' => $recipe,
'recipe' => $recipe,
'relatedProducts' => $relatedProducts,
]);
}
}

View file

@ -35,21 +35,23 @@ class StoreRecipeRequest extends FormRequest
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'seo_title' => ['required', 'string', 'between:45,70'],
'seo_description' => ['required', 'string', 'between:50,160'],
'product_category' => ['required', new Enum(ProductCategory::class)],
'consomption_time' => ['required', new Enum(ConsumptionTime::class)],
'inspiration' => ['required', new Enum(Inspiration::class)],
'season' => ['required', new Enum(Season::class)],
'description' => ['nullable', 'string'],
'details' => ['nullable', 'string'],
'ingredients_list' => ['required', 'string'],
'preparation_steps' => ['required', 'string'],
'tips_and_advices' => ['nullable', 'string'],
'picture' => ['required', 'image', 'max:50000', 'dimensions:width=800,height=800'],
'picture_alt' => ['required', 'string', 'min:4', 'max:75'],
'picture_title' => ['required', 'string', 'min:4', 'max:75'],
'name' => ['required', 'string', 'max:255'],
'seo_title' => ['required', 'string', 'between:45,70'],
'seo_description' => ['required', 'string', 'between:50,160'],
'product_category' => ['required', new Enum(ProductCategory::class)],
'consomption_time' => ['required', new Enum(ConsumptionTime::class)],
'inspiration' => ['required', new Enum(Inspiration::class)],
'season' => ['required', new Enum(Season::class)],
'description' => ['nullable', 'string'],
'details' => ['nullable', 'string'],
'ingredients_list' => ['required', 'string'],
'preparation_steps' => ['required', 'string'],
'tips_and_advices' => ['nullable', 'string'],
'picture' => ['required', 'image', 'max:50000', 'dimensions:width=800,height=800'],
'picture_alt' => ['required', 'string', 'min:4', 'max:75'],
'picture_title' => ['required', 'string', 'min:4', 'max:75'],
'products' => ['nullable', 'array'],
'products.*' => ['string', 'uuid', 'exists:products,id'],
];
}
@ -61,21 +63,22 @@ class StoreRecipeRequest extends FormRequest
public function attributes(): array
{
return [
'name' => trans('validation.attributes.name'),
'seo_title' => trans('validation.attributes.seo_title'),
'seo_description' => trans('validation.attributes.seo_description'),
'product_category' => trans('validation.attributes.product_category'),
'consomption_time' => trans('validation.attributes.consomption_time'),
'inspiration' => trans('validation.attributes.inspiration'),
'season' => trans('validation.attributes.season'),
'description' => trans('validation.attributes.description'),
'details' => trans('validation.attributes.details'),
'ingredients_list' => trans('validation.attributes.ingredients_list'),
'preparation_steps' => trans('validation.attributes.preparation_steps'),
'tips_and_advices' => trans('validation.attributes.tips_and_advices'),
'picture' => trans('validation.attributes.picture'),
'picture_alt' => trans('validation.attributes.picture_alt'),
'picture_title' => trans('validation.attributes.picture_title'),
'name' => trans('validation.attributes.name'),
'seo_title' => trans('validation.attributes.seo_title'),
'seo_description' => trans('validation.attributes.seo_description'),
'product_category' => trans('validation.attributes.product_category'),
'consomption_time' => trans('validation.attributes.consomption_time'),
'inspiration' => trans('validation.attributes.inspiration'),
'season' => trans('validation.attributes.season'),
'description' => trans('validation.attributes.description'),
'details' => trans('validation.attributes.details'),
'ingredients_list' => trans('validation.attributes.ingredients_list'),
'preparation_steps' => trans('validation.attributes.preparation_steps'),
'tips_and_advices' => trans('validation.attributes.tips_and_advices'),
'picture' => trans('validation.attributes.picture'),
'picture_alt' => trans('validation.attributes.picture_alt'),
'picture_title' => trans('validation.attributes.picture_title'),
'products' => trans('validation.attributes.associated_products'),
];
}
}

View file

@ -254,9 +254,11 @@ return [
'preview' => 'aperçu',
'price' => 'prix',
'product_category' => 'gamme',
'associated_products' => 'produits associés',
'product_id' => 'identifiant du produit',
'product_uid' => 'UID du produit',
'product_uuid' => 'UUID du produit',
'products' => 'produits',
'promo_code' => 'code promo',
'province' => 'région',
'quantity' => 'quantité',

View file

@ -1,6 +1,6 @@
// * Packages
import { useForm, usePage } from "@inertiajs/react";
import { BookTextIcon, CogIcon, ImageIcon } from "lucide-react";
import { BookTextIcon, CheckIcon, CogIcon, ImageIcon } from "lucide-react";
import { route } from "ziggy-js";
// * Components
import FieldsetWrapper from "@/back/Components/FieldsetWrapper";
@ -13,6 +13,8 @@ import useCrudSubmit from "@/back/hooks/use-crud-submit";
// * Types & Global
import CompleteInputFile from "@/back/Components/Inputs/CompleteInputFile";
import { RawRecipeModel } from "@/back/types/recipes";
import { Button } from "@/components/ui/button";
import ProductsSelectionModal from "./Partials/ProductsSelectionModal";
import { ModelId } from "@/domain";
import str from "@/hooks/use-string";
import useTrans from "@/hooks/use-translations";
@ -35,6 +37,7 @@ interface FormData {
picture: string | File | null;
picture_alt: string;
picture_title: string;
products: string[];
}
export default function Form({
@ -57,6 +60,11 @@ export default function Form({
const enumInspiration = (usePage().props.enumInspiration ??
{}) as LaravelEnumMap;
const enumSeason = (usePage().props.enumSeason ?? {}) as LaravelEnumMap;
const allProducts = (usePage().props.allProducts ?? []) as {
id: string;
name: string;
picture: string | null;
}[];
const precognitionMethod = action === "store" ? "post" : "put";
const precognitionUrl = route(`back.${modelRoute}.${action}`, model?.id);
@ -78,10 +86,20 @@ export default function Form({
picture: model?.picture ?? null,
picture_alt: model?.picture_alt ?? "",
picture_title: model?.picture_title ?? "",
products: model?.products?.map((p) => p.id) ?? [],
}).withPrecognition(precognitionMethod, precognitionUrl);
const { post, setData, data, reset, errors: formErrors, validate } = form;
const toggleProduct = (productId: string) => {
setData(
"products",
data.products.includes(productId)
? data.products.filter((id) => id !== productId)
: [...data.products, productId],
);
};
const modelNameTrans = useTrans(`models.classes.${modelName}`, {
choice: 1,
});
@ -260,6 +278,65 @@ export default function Form({
/>
</FieldsetWrapper>
{/* Associated Products */}
<FieldsetWrapper
title="Produits associés"
icon={<CheckIcon className="mr-1" />}
description="Lier des produits à cette recette"
className="flex flex-col gap-4"
>
<div className="flex justify-between items-center">
<p className="text-sm text-gray-500">
{data.products.length} produit(s) sélectionné(s)
</p>
<ProductsSelectionModal
allProducts={allProducts}
selectedProducts={data.products}
onToggleProduct={toggleProduct}
/>
</div>
{data.products.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-4 mt-4 border-t pt-4">
{data.products.map((productId) => {
const product = allProducts.find((p) => p.id === productId);
if (!product) return null;
return (
<div
key={product.id}
className="flex flex-col items-center justify-between h-full bg-gray-50 p-2 rounded-lg border"
>
<img
src={
product.picture
? `/${product.picture}`
: "/assets/img/placeholder.jpg"
}
alt={product.name}
className="w-12 h-12 object-cover rounded-md mb-2"
/>
<span
className="text-xs text-center font-medium line-clamp-2 mb-2 flex-grow"
title={product.name}
>
{product.name}
</span>
<Button
type="button"
variant="destructive"
size="sm"
className="w-full mt-auto"
onClick={() => toggleProduct(product.id)}
>
Retirer
</Button>
</div>
);
})}
</div>
)}
</FieldsetWrapper>
<div className="text-center mt-6">
<SubmitButton modelTrans={modelNameTrans} />
</div>

View file

@ -0,0 +1,88 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { CheckIcon } from "lucide-react";
interface Product {
id: string;
name: string;
picture: string | null;
}
interface ProductsSelectionModalProps {
allProducts: Product[];
selectedProducts: string[];
onToggleProduct: (productId: string) => void;
}
export default function ProductsSelectionModal({
allProducts,
selectedProducts,
onToggleProduct,
}: Readonly<ProductsSelectionModalProps>) {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline">
Sélectionner des produits
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl h-[80vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="sticky top-0 bg-white z-10 p-6 pb-4 border-b">
<DialogTitle>Sélectionner des produits associés</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{allProducts.map((product) => {
const isSelected = selectedProducts.includes(product.id);
return (
<button
type="button"
key={product.id}
onClick={() => onToggleProduct(product.id)}
className={`relative flex flex-col items-center p-2 rounded-lg cursor-pointer border-2 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-gray-200 hover:border-gray-300"
}`}
>
<img
src={
product.picture
? `/${product.picture}`
: "/assets/img/placeholder.jpg"
}
alt={product.name}
className="w-16 h-16 object-cover rounded-md mb-2"
/>
<span className="text-xs text-center font-medium line-clamp-2">
{product.name}
</span>
{isSelected && (
<div className="absolute top-1 right-1 bg-primary text-primary-foreground rounded-full p-0.5">
<CheckIcon className="w-3 h-3" />
</div>
)}
</button>
);
})}
</div>
</div>
<DialogFooter className="sticky bottom-0 bg-white z-10 p-4 border-t flex justify-end">
<DialogClose asChild>
<Button type="button">Valider</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -28,6 +28,7 @@ export interface RawRecipeModel extends ModelWithPolicies {
ingredients_list: string;
preparation_steps: string;
tips_and_advices: string | null;
products?: { id: ModelId }[];
}
export interface RecipeModel extends RawRecipeModel {
@ -56,10 +57,13 @@ export const rawRecipeModelSchema = zod.object({
ingredients_list: zod.string(),
preparation_steps: zod.string(),
tips_and_advices: zod.string().nullable(),
products: zod
.array(zod.object({ id: zod.uuid().transform((v) => v as ModelId) }))
.optional(),
});
export const recipeModelSchema = rawRecipeModelSchema.extend({
id: zod.union([zod.uuid(), zod.number()]).transform((v) => v as ModelId),
id: zod.uuid().transform((v) => v as ModelId),
created_at: zod.coerce.date(),
updated_at: zod.coerce.date(),
deleted_at: zod.coerce.date().nullable(),

View file

@ -9,6 +9,7 @@ import { Pagination } from "swiper/modules";
import { Swiper, SwiperClass, SwiperSlide } from "swiper/react";
import { PaginationOptions } from "swiper/types";
/** THEME */
import EggArrowIcon from "$/uwil/Icons/EggArrowIcon";
import ItemCard from "$/uwil/ItemCard";
import { bgColors, imageShapes } from "@/front/lib/constants";
import { cn } from "@/lib/utils";
@ -24,9 +25,9 @@ function NextButton({ swiperRef, disabled, icon }: SwiperButtonProps) {
type="button"
onClick={() => swiperRef.current?.slideNext()}
disabled={disabled}
className="hidden md:block disabled:opacity-0"
className="hidden md:block disabled:opacity-0 z-10 hover:scale-105 transition-transform"
>
{icon}
{icon ?? <EggArrowIcon direction="right" />}
</button>
);
}
@ -37,9 +38,9 @@ function PrevButton({ swiperRef, disabled, icon }: SwiperButtonProps) {
type="button"
onClick={() => swiperRef.current?.slidePrev()}
disabled={disabled}
className="hidden md:block disabled:opacity-0"
className="hidden md:block disabled:opacity-0 z-10 hover:scale-105 transition-transform"
>
{icon}
{icon ?? <EggArrowIcon direction="left" />}
</button>
);
}

View file

@ -0,0 +1,78 @@
import { cn } from "@/lib/utils";
interface EggArrowIconProps {
direction: "left" | "right";
className?: string;
}
export default function EggArrowIcon({
direction,
className,
}: Readonly<EggArrowIconProps>) {
const leftArrowPath =
"M25.507 21.7106L16.9809 30.6754C16.8256 30.846 16.7039 31.047 16.6228 31.2671C16.4522 31.7036 16.4522 32.1933 16.6228 32.6298C16.7039 32.8499 16.8256 33.0509 16.9809 33.2215L25.507 42.1863C25.666 42.3535 25.8547 42.4861 26.0624 42.5766C26.2702 42.6671 26.4928 42.7136 26.7177 42.7136C27.1718 42.7136 27.6073 42.5239 27.9284 42.1863C28.2495 41.8487 28.4299 41.3908 28.4299 40.9133C28.4299 40.4358 28.2495 39.9779 27.9284 39.6403L22.3012 33.7414L35.2437 33.7414C35.696 33.7414 36.1297 33.5525 36.4495 33.2163C36.7693 32.88 36.949 32.424 36.949 31.9484C36.949 31.4729 36.7693 31.0169 36.4495 30.6806C36.1297 30.3444 35.696 30.1555 35.2437 30.1555L22.3012 30.1555L27.9284 24.2566C28.0882 24.0899 28.2151 23.8916 28.3016 23.6731C28.3882 23.4546 28.4328 23.2203 28.4328 22.9836C28.4328 22.7469 28.3882 22.5125 28.3016 22.2941C28.2151 22.0756 28.0882 21.8773 27.9284 21.7106C27.7698 21.5425 27.5812 21.4091 27.3735 21.3181C27.1657 21.2271 26.9428 21.1802 26.7177 21.1802C26.4926 21.1802 26.2697 21.2271 26.0619 21.3181C25.8541 21.4091 25.6655 21.5425 25.507 21.7106Z";
const rightArrowPath =
"M28.4461 42.1832L37.1536 33.2184C37.3121 33.0478 37.4364 32.8468 37.5193 32.6267C37.6934 32.1902 37.6934 31.7005 37.5193 31.264C37.4364 31.0439 37.3121 30.8429 37.1536 30.6723L28.4461 21.7075C28.2837 21.5403 28.0909 21.4077 27.8788 21.3172C27.6666 21.2267 27.4392 21.1802 27.2096 21.1802C26.7458 21.1802 26.3011 21.3699 25.9731 21.7075C25.6452 22.0451 25.461 22.503 25.461 22.9805C25.461 23.458 25.6452 23.9159 25.9731 24.2535L31.7201 30.1524L18.5021 30.1524C18.0402 30.1524 17.5973 30.3413 17.2707 30.6775C16.9441 31.0138 16.7606 31.4698 16.7606 31.9453C16.7606 32.4209 16.9441 32.8769 17.2707 33.2132C17.5973 33.5494 18.0402 33.7383 18.5021 33.7383L31.7201 33.7383L25.9731 39.6372C25.8099 39.8039 25.6804 40.0022 25.5919 40.2207C25.5035 40.4392 25.458 40.6735 25.458 40.9102C25.458 41.1469 25.5035 41.3813 25.5919 41.5997C25.6804 41.8182 25.8099 42.0165 25.9731 42.1832C26.135 42.3513 26.3276 42.4847 26.5399 42.5757C26.7521 42.6667 26.9797 42.7136 27.2096 42.7136C27.4395 42.7136 27.6671 42.6667 27.8793 42.5757C28.0916 42.4847 28.2842 42.3513 28.4461 42.1832Z";
return (
<svg
width="56"
height="67"
viewBox="0 0 56 67"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(className)}
>
<g filter="url(#filter0_d_12806_2754)">
<path
d="M28 1.5C33.8446 1.5 39.4518 6.03781 43.7051 12.7285C47.9196 19.3584 50.5 27.6561 50.5 34.291C50.4998 47.4178 40.3963 57.5 28 57.5C15.6037 57.5 5.50021 47.4178 5.5 34.291C5.5 27.6561 8.08039 19.3584 12.2949 12.7285C16.5482 6.03781 22.1554 1.5 28 1.5Z"
stroke="#111F51"
strokeWidth="3"
className="stroke-primary-foreground transition-colors"
/>
<path
d={direction === "left" ? leftArrowPath : rightArrowPath}
fill="#111F51"
className="fill-primary-foreground transition-colors"
/>
</g>
<defs>
<filter
id="filter0_d_12806_2754"
x="0"
y="0"
width="56"
height="67"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="4" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_12806_2754"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_12806_2754"
result="shape"
/>
</filter>
</defs>
</svg>
);
}

View file

@ -58,6 +58,7 @@ export default function Image({
size="xs"
font={fonts.DELIUS_SWASH}
isBold={false}
tabIndex={-1}
link={{
label: action.label,
href: action.href,

View file

@ -1,9 +1,13 @@
/** THEME */
import { CarouselProps, TextProps } from "@/types/front";
import Section, { ButtonSectionProps } from "$/uwil/Section";
import { BgColorName, ImageShapeName, imageShapes } from "@/front/lib/constants";
import Carousel from "$/base/Carousel/Carousel";
import Section, { ButtonSectionProps } from "$/uwil/Section";
import {
BgColorName,
ImageShapeName,
imageShapes,
} from "@/front/lib/constants";
import { cn } from "@/lib/utils";
import { CarouselProps, TextProps } from "@/types/front";
type CarouselSectionProps = CarouselProps & {
title?: TextProps;
@ -62,22 +66,22 @@ export default function CarouselSection(props: CarouselSectionProps) {
items,
carousel,
sectionClassName,
shape = imageShapes.ROUNDED
shape = imageShapes.ROUNDED,
} = props;
const buttonProps = button
? {
button: {
href: button.href,
label: button.label,
...(button.icon
? {
icon: {
icon: button.icon.icon,
},
}
: {}),
},
}
button: {
href: button.href,
label: button.label,
...(button.icon
? {
icon: {
icon: button.icon.icon,
},
}
: {}),
},
}
: {};
return (
<Section

View file

@ -28,11 +28,20 @@ import {
} from "@/domain";
import { Product } from "@/domain/entities/product";
import Button from "$/uwil/Button";
import { Recipe } from "@/domain/entities/recipe";
import CarouselContent from "@/front/Components/Base/Carousel/CarouselContent";
import { ThemeItemCardProps } from "@/types/front";
interface ShowProps {
product: Product;
relatedRecipes?: Recipe[];
}
export default function Show({ product }: Readonly<ShowProps>) {
export default function Show({
product,
relatedRecipes = [],
}: Readonly<ShowProps>) {
const [showAllFormats, setShowAllFormats] = useState(false);
const LaravelEnumMapSchema = <T extends string | number>(
enumObj: Record<string, T>,
@ -384,6 +393,67 @@ export default function Show({ product }: Readonly<ShowProps>) {
</a>
</Section>
)}
{/* Related Recipes Section */}
{relatedRecipes.length > 0 && (
<Section className="py-12 md:py-16">
<div className="flex flex-col items-center max-w-[1248px] mx-auto">
<h2 className="text-2xl md:text-3xl font-bold text-primary-foreground uppercase text-center mb-10">
Recettes Ovoteam
</h2>
<CarouselContent
items={relatedRecipes.map(
(r) =>
({
image: {
src: r.picture
? `/${r.picture}`
: "/assets/img/placeholder.jpg",
alt: r.name,
},
title: { text: r.name, font: "font-delius-swash" },
button: {
label: "Voir la recette",
href: route("front.recipes.show", { recipe: r.slug }),
},
}) as ThemeItemCardProps,
)}
variant="bg-primary"
carousel={{
slidesPerView: 1,
spaceBetween: 30,
breakpoints: {
768: { slidesPerView: 2 },
1024: { slidesPerView: 3 },
1248: { slidesPerView: 4 },
},
pagination: true,
navigation: true,
}}
/>
<div className="mt-10 mb-8">
<Button
link={{
href: route("front.recipes.index"),
label: "Voir toutes les recettes",
}}
icon={{ icon: "chevron-right", placement: "right" }}
variant="default"
/>
</div>
<Button
link={{
href: "/",
label: "Retour à la page d'accueil",
}}
icon={{ icon: "chevron-left", placement: "left" }}
variant="inverted"
/>
</div>
</Section>
)}
</AppLayout>
);
}

View file

@ -19,8 +19,14 @@ import {
import { Recipe } from "@/domain/entities/recipe";
import { DownloadIcon, Lightbulb } from "lucide-react";
import Button from "$/uwil/Button";
import { Product } from "@/domain/entities/product";
import CarouselContent from "@/front/Components/Base/Carousel/CarouselContent";
import { ThemeItemCardProps } from "@/types/front";
interface ShowProps {
recipe: Recipe;
relatedProducts?: Product[];
}
type EnumOption = {
@ -31,7 +37,10 @@ type EnumOption = {
type EnumMap = Record<string, EnumOption>;
export default function Show({ recipe }: Readonly<ShowProps>) {
export default function Show({
recipe,
relatedProducts = [],
}: Readonly<ShowProps>) {
const LaravelEnumMapSchema = <T extends string | number>(
enumObj: Record<string, T>,
) =>
@ -284,6 +293,67 @@ export default function Show({ recipe }: Readonly<ShowProps>) {
Télécharger le pdf
</button>
</Section>
{/* Related Products Section */}
{relatedProducts.length > 0 && (
<Section className="py-12 md:py-16">
<div className="flex flex-col items-center max-w-[1248px] mx-auto">
<h2 className="text-2xl md:text-3xl font-bold text-primary-foreground uppercase text-center mb-10">
Produits Ovoteam
</h2>
<CarouselContent
items={relatedProducts.map(
(p) =>
({
image: {
src: p.picture
? `/${p.picture}`
: "/assets/img/placeholder.jpg",
alt: p.name,
},
title: { text: p.name },
button: {
label: "Voir le produit",
href: route("front.products.show", { product: p.slug }),
},
}) as ThemeItemCardProps,
)}
variant="bg-primary"
carousel={{
slidesPerView: 1,
spaceBetween: 30,
breakpoints: {
768: { slidesPerView: 2 },
1024: { slidesPerView: 3 },
1248: { slidesPerView: 4 },
},
pagination: true,
navigation: true,
}}
/>
<div className="mt-10 mb-8">
<Button
link={{
href: route("front.products.index"),
label: "Voir tous les produits",
}}
icon={{ icon: "chevron-right", placement: "right" }}
variant="default"
/>
</div>
<Button
link={{
href: "/",
label: "Retour à la page d'accueil",
}}
icon={{ icon: "chevron-left", placement: "left" }}
variant="inverted"
/>
</div>
</Section>
)}
</AppLayout>
);
}