refactor: fron and back recipe - products association
This commit is contained in:
parent
b6d08c8bd1
commit
28f447c8ec
20 changed files with 577 additions and 60 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
app/Dtos/ProductSummary.php
Normal file
15
app/Dtos/ProductSummary.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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é',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
78
resources/ts/front/Components/Theme/Icons/EggArrowIcon.tsx
Normal file
78
resources/ts/front/Components/Theme/Icons/EggArrowIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue