diff --git a/app/Http/Controllers/Bo/ProductController.php b/app/Http/Controllers/Bo/ProductController.php new file mode 100644 index 0000000..426c892 --- /dev/null +++ b/app/Http/Controllers/Bo/ProductController.php @@ -0,0 +1,153 @@ +search) { + $this->searchQuery( + $products, + $request->search, + // * Search in tags + function ($q) use ($request) { + $q->orWhereHas('tags', function ($q) use ($request) { + $q->whereRaw( + 'UPPER(CONCAT(`tags`.`name`)) LIKE UPPER(?)', + ['%' . filter_var($request->search, FILTER_SANITIZE_STRING) . '%'] + ); + }); + }, + 'title', + 'description' + ); + } + + $this->sortQuery($products); + $searchFields = trans('Titre, Description'); + + $products->orderBy('updated_at', 'DESC'); + $products = $products->paginate(); + + return view('back.products.index', compact('products', 'searchFields')); + } + + /** + * Show the form for creating a new resource. + * + * @return \Illuminate\Http\Response + */ + public function create() + { + $ranges = Range::all(); + return view('back.products.create', compact('ranges')); + } + + /** + * Store a newly created resource in storage. + * + * @param \App\Http\Requests\Bo\Products\StoreProductRequest $request + * @return \Illuminate\Http\Response + */ + public function store(StoreProductRequest $request) + { + return DB::transaction(function () use ($request) { + $product = new Product(); + $product->fill($request->validated()); + + if ($product->saveOrFail()) { + return redirect() + ->route('bo.products.edit', $product) + ->with('success', trans( + ':model a été créé', + ['model' => Str::singular(trans('models.classes.products'))] + )); + } + return back(); + }); + } + + /** + * Show the form for editing the specified resource. + * + * @param \App\Models\Product $product + * @return \Illuminate\Http\Response + */ + public function edit(Product $product) + { + $ranges = Range::all(); + return view('back.products.edit', [ + 'productModel' => $product, + 'ranges' => $ranges, + ]); + } + + /** + * Update the specified resource in storage. + * + * @param \App\Http\Requests\Bo\Products\UpdateProductRequest $request + * @param \App\Models\Product $product + * @return \Illuminate\Http\Response + */ + public function update(UpdateProductRequest $request, Product $product) + { + return DB::transaction(function () use ($request, $product) { + $product->fill($request->validated()); + /** @var \App\Models\IncoNutritionalValue */ + $nut = $product->incoNutritionalValues()->firstOrNew(); + $nut->fill($request->validated()); + $nut->saveOrFail(); + + if ($product->saveOrFail()) { + return redirect() + ->route('bo.products.edit', $product) + ->with('success', trans( + ':model a été mis à jour', + ['model' => Str::singular(trans('models.classes.products'))] + )); + } + return back(); + }); + } + + /** + * Remove the specified resource from storage. + * + * @param \App\Models\Product $product + * @return \Illuminate\Http\Response + */ + public function destroy(Product $product) + { + if ($product->deleteOrFail()) { + return redirect() + ->route('bo.products.index') + ->with('warning', trans( + ':model a bien été supprimé', + ['model' => Str::singular(trans('models.classes.products'))] + )); + } + return back(); + } +} diff --git a/app/Http/Requests/Bo/Products/StoreProductRequest.php b/app/Http/Requests/Bo/Products/StoreProductRequest.php new file mode 100644 index 0000000..ecb85fc --- /dev/null +++ b/app/Http/Requests/Bo/Products/StoreProductRequest.php @@ -0,0 +1,141 @@ +check(); + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation() + { + $this->merge([ + 'slug' => Str::slug($this->title), + 'status' => $this->status ? true : false, + 'show_ingredients' => $this->show_ingredients ? true : false, + 'show_preparation_advices' => $this->show_preparation_advices ? true : false, + 'show_sales_pitch' => $this->show_sales_pitch ? true : false, + 'show_allergen_list' => $this->show_allergen_list ? true : false, + 'show_nutrionnal_values' => $this->show_nutrionnal_values ? true : false, + 'show_portions' => $this->show_portions ? true : false, + 'labels' => $this->labels ?? [] + ]); + $this->mergePicture(); + $this->mergePicture('picture_thumb'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return \array_merge( + [ + 'title' => 'required|min:6|max:255', + 'slug' => 'required|max:255', + 'seo_title' => 'required|min:45|max:70', + 'seo_description' => 'required|min:50|max:160', + 'description' => 'required|string|min:15', + 'picture_alt' => 'required_with:picture|nullable|string|min:6|max:255', + 'picture_title' => 'required_with:picture|nullable|string|min:6|max:255', + 'picture_thumb_alt' => 'required_with:picture_thumb|nullable|string|min:6|max:255', + 'picture_thumb_title' => 'required_with:picture_thumb|nullable|string|min:6|max:255', + 'publish_at' => 'nullable|date_format:Y-m-d\TH:i', + 'unpublish_at' => 'nullable|date_format:Y-m-d\TH:i', + 'status' => 'required|boolean', + 'range_id' => 'required|exists:ranges,id', + + 'ingredients' => 'required|string|min:15', + 'preparation_advices' => 'required|string|min:15', + 'sales_pitch' => 'required|string|min:15', + 'allergen_list' => 'required|string|min:15', + 'number_portion' => 'required|numeric|min:0', + + 'show_ingredients' => 'required|boolean', + 'show_preparation_advices' => 'required|boolean', + 'show_sales_pitch' => 'required|boolean', + 'show_allergen_list' => 'required|boolean', + 'show_nutrionnal_values' => 'required|boolean', + 'show_portions' => 'required|boolean', + + 'labels' => 'sometimes|array', + 'labels.*' => 'sometimes|array', + 'labels.*.value' => ['required', new EnumIsValidRule(ProductLabelEnum::class)], + + 'weight' => 'required|numeric', + 'energy' => 'required|numeric', + 'energy_kj' => 'required|numeric', + 'protein' => 'required|numeric', + 'carbohydrates' => 'required|numeric', + 'sugar' => 'required|numeric', + 'fat' => 'required|numeric', + 'saturated_fatty_acids' => 'required|numeric', + 'dietary_fiber' => 'required|numeric', + 'salt' => 'required|numeric', + ], + $this->pictureRules('picture', 700, 700, 700, 700), + $this->pictureRules('picture_thumb', 300, 300, 300, 300) + ); + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'title' => trans('title'), + 'slug' => trans('title'), + 'seo_title' => trans('title') . ' seo', + 'seo_description' => trans('description') . ' seo', + 'description' => trans('description'), + 'picture' => trans('illustration'), + 'picture_alt' => trans('Texte alternatif de l\'illustration'), + 'picture_title' => trans('Titre de l\'illustration'), + 'picture_thumb' => trans('vignette'), + 'picture_thumb_alt' => trans('Texte alternatif de la vignette'), + 'picture_thumb_title' => trans('Titre de la vignette'), + 'status' => trans('publié ?'), + 'publish_at' => trans('date de publication'), + 'unpublish_at' => trans('date de dépublication'), + + 'nutritional' => trans('valeurs nutritionelles'), + 'nutritional.energy' => trans('énergie'), + 'nutritional.energy_kj' => trans('énergie kj'), + 'nutritional.protein' => trans('protéines'), + 'nutritional.carbohydrates' => trans('glucides'), + 'nutritional.sugar' => trans('sucres'), + 'nutritional.fat' => trans('matière grasse'), + 'nutritional.saturated_fatty_acids' => trans('acides gras saturés'), + 'nutritional.dietary_fiber' => trans('fibres alimentaires'), + 'nutritional.salt' => trans('sel'), + ]; + } +} diff --git a/app/Http/Requests/Bo/Products/UpdateProductRequest.php b/app/Http/Requests/Bo/Products/UpdateProductRequest.php new file mode 100644 index 0000000..1d4ce71 --- /dev/null +++ b/app/Http/Requests/Bo/Products/UpdateProductRequest.php @@ -0,0 +1,10 @@ + 'weight', 'val_energ_kcal_r' => 'energy', - 'val_energ_kcal_p' => 'energy_percent', 'val_energ_kj_r' => 'energy_kj', - 'pourcent_apport_ref_kjo_r' => 'energy_kj_percent', 'proteine_r' => 'protein', - 'pourcent_apport_ref_pro_r' => 'protein_percent', 'sucre_r' => 'sugar', - 'pourcent_apport_ref_sugar_r' => 'sugar_percent', 'mat_grass_r' => 'fat', - 'pourcent_apport_ref_fat_r' => 'fat_percent', 'fib_alim_r' => 'dietary_fiber', - 'pourcent_apport_ref_fibtg_r' => 'dietary_fiber_percent', 'glucides_r' => 'carbohydrates', - 'glucides_p' => 'carbohydrates_percent', 'ags_r' => 'saturated_fatty_acids', - 'pourcent_ags' => 'saturated_fatty_acids_percent', - 'sel_r' => 'salt', - 'pourcent_apport_ref_salteq_r' => 'salt_percent' + 'sel_r' => 'salt' ]; $o = []; diff --git a/app/Models/Product.php b/app/Models/Product.php index 98a8ea9..74d3031 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -39,6 +39,13 @@ class Product extends Model 'status', 'publish_at', 'unpublish_at', + 'range_id', + 'show_ingredients', + 'show_preparation_advices', + 'show_sales_pitch', + 'show_allergen_list', + 'show_nutrionnal_values', + 'show_portions' ]; /** @@ -49,6 +56,11 @@ class Product extends Model protected $casts = [ 'labels' => 'array', 'status' => 'bool', + 'show_ingredients' => 'bool', + 'show_preparation_advices' => 'bool', + 'show_sales_pitch' => 'bool', + 'show_allergen_list' => 'bool', + 'show_nutrionnal_values' => 'bool', 'publish_at' => 'datetime', 'unpublish_at' => 'datetime' ]; @@ -137,7 +149,17 @@ class Product extends Model */ public function incoNutritionalValues() { - return $this->hasOne(IncoNutritionalValues::class); + return $this->hasOne(IncoNutritionalValue::class); + } + + /** + * BelongsTo range relation + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function range() + { + return $this->belongsTo(Range::class); } // * METHODS diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 9e40d02..0da5b87 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Lib\ToolboxHelper; use App\Models\News; +use App\Models\Product; use App\Models\Range; use App\Models\Slider; use Illuminate\Cache\RateLimiting\Limit; @@ -44,6 +45,7 @@ class RouteServiceProvider extends ServiceProvider ->group(base_path('routes/web-fo.php')); }); + Route::model('product', Product::class); Route::model('range', Range::class); Route::model('slider', Slider::class); Route::model('news', News::class, function ($value) { diff --git a/app/Traits/Controllers/UpdateModelStatus.php b/app/Traits/Controllers/UpdateModelStatus.php index a3a9842..657e8b4 100644 --- a/app/Traits/Controllers/UpdateModelStatus.php +++ b/app/Traits/Controllers/UpdateModelStatus.php @@ -14,7 +14,7 @@ trait UpdateModelStatus /** * Change model published * - * @param Model $model + * @param \Illuminate\Database\Eloquent\Model $model * @return \Illuminate\Http\RedirectResponse */ public function changePublished(Model $model) diff --git a/database/factories/IncoNutritionalValueFactory.php b/database/factories/IncoNutritionalValueFactory.php index 9a23170..522878b 100644 --- a/database/factories/IncoNutritionalValueFactory.php +++ b/database/factories/IncoNutritionalValueFactory.php @@ -25,6 +25,8 @@ class IncoNutritionalValueFactory extends Factory public function definition() { return [ + "weight" => $this->faker->boolean(10) ? + null : $this->faker->numberBetween(231 - 150, 231 + 150), "energy" => $this->faker->boolean(10) ? null : $this->faker->numberBetween(231 - 150, 231 + 150), "energy_kj" => $this->faker->boolean(10) ? @@ -42,25 +44,7 @@ class IncoNutritionalValueFactory extends Factory "dietary_fiber" => $this->faker->boolean(10) ? null : $this->faker->numberBetween(2.9 - 2, 2.9 + 6), "salt" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(1.1 - 1, 1.1 + 1.1), - "energy_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(231 - 150, 231 + 160), - "energy_kj_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(12 - 6, 12 + 25), - "protein_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(18 - 6, 18 + 25), - "carbohydrates_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(20 - 6, 20 + 25), - "sugar_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(2 - 1, 2 + 6), - "fat_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(17 - 1, 17 + 6), - "saturated_fatty_acids_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(28 - 1, 28 + 6), - "dietary_fiber_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(28 - 1, 28 + 6), - "salt_percent" => $this->faker->boolean(10) ? - null : $this->faker->numberBetween(18 - 1, 18 + 6) + null : $this->faker->numberBetween(1.1 - 1, 1.1 + 1.1) ]; } } diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 367b29b..a1033cb 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -32,7 +32,7 @@ class ProductFactory extends Factory 'picture' => '/assets/images/products/product_fake_700_700.jpg', 'picture_title' => $this->faker->words(8, true), 'picture_alt' => $this->faker->words(8, true), - 'picture_thumb' => '/assets/products/product_fake_thumb_300_300.jpg', + 'picture_thumb' => '/assets/images/products/product_fake_thumb_300_300.jpg', 'picture_thumb_title' => $this->faker->words(8, true), 'picture_thumb_alt' => $this->faker->words(8, true), 'labels' => collect(ProductLabelEnum::toArray())->random(rand(0, 4))->all(), @@ -41,7 +41,13 @@ class ProductFactory extends Factory 'sales_pitch' => $this->faker->text(\mt_rand(15, 150)), 'allergen_list' => $this->faker->words(8, true), 'number_portion' => $this->faker->numberBetween(0, 8), - 'status' => $this->faker->boolean(75) + 'status' => $this->faker->boolean(75), + 'show_ingredients' => $this->faker->boolean(75), + 'show_preparation_advices' => $this->faker->boolean(75), + 'show_sales_pitch' => $this->faker->boolean(75), + 'show_allergen_list' => $this->faker->boolean(75), + 'show_nutrionnal_values' => $this->faker->boolean(75), + 'show_portions' => $this->faker->boolean(75), ]; } } diff --git a/database/migrations/2022_07_12_164019_create_ranges_table.php b/database/migrations/2022_07_12_164017_create_ranges_table.php similarity index 100% rename from database/migrations/2022_07_12_164019_create_ranges_table.php rename to database/migrations/2022_07_12_164017_create_ranges_table.php diff --git a/database/migrations/2022_07_12_164018_create_products_table.php b/database/migrations/2022_07_12_164018_create_products_table.php index 882bf59..2ab24e3 100644 --- a/database/migrations/2022_07_12_164018_create_products_table.php +++ b/database/migrations/2022_07_12_164018_create_products_table.php @@ -1,5 +1,6 @@ text('ingredients')->comment('Ingredients'); $table->text('preparation_advices')->comment('Preparation advices'); - $table->text('sales_pitch')->comment('Preparation advices'); - $table->text('allergen_list')->comment('Preparation advices'); + $table->text('sales_pitch')->comment('Arguments de vente'); + $table->text('allergen_list')->comment('Liste allergene issues de inco'); $table->unsignedTinyInteger('number_portion')->comment('Portion number'); + $table->boolean('show_ingredients')->comment('Afficher en front ou non.'); + $table->boolean('show_preparation_advices')->comment('Afficher en front ou non.'); + $table->boolean('show_sales_pitch')->comment('Afficher en front ou non.'); + $table->boolean('show_allergen_list')->comment('Afficher en front ou non.'); + $table->boolean('show_nutrionnal_values')->comment('Afficher en front ou non.'); + $table->boolean('show_portions')->comment('Afficher en front ou non.'); + $table->boolean('status')->comment('Le produit est visible ou non'); $table->timestamp('publish_at')->nullable() @@ -49,6 +57,8 @@ class CreateProductsTable extends Migration $table->timestamp('unpublish_at')->nullable() ->comment('The date where the product status state should be set off.'); + $table->foreignIdFor(Range::class)->constrained(); + $table->timestamps(); }); } diff --git a/database/migrations/2022_12_07_135414_create_inco_nutritional_values_table.php b/database/migrations/2022_12_07_135414_create_inco_nutritional_values_table.php index 60eca27..fad6627 100644 --- a/database/migrations/2022_12_07_135414_create_inco_nutritional_values_table.php +++ b/database/migrations/2022_12_07_135414_create_inco_nutritional_values_table.php @@ -17,6 +17,7 @@ class CreateIncoNutritionalValuesTable extends Migration Schema::create('inco_nutritional_values', function (Blueprint $table) { $table->id(); + $table->double('weight')->nullable(); $table->double('energy')->nullable(); $table->double('energy_kj')->nullable(); $table->double('protein')->nullable(); @@ -27,16 +28,6 @@ class CreateIncoNutritionalValuesTable extends Migration $table->double('dietary_fiber')->nullable(); $table->double('salt')->nullable(); - $table->float('energy_percent')->nullable(); - $table->float('energy_kj_percent')->nullable(); - $table->float('protein_percent')->nullable(); - $table->float('carbohydrates_percent')->nullable(); - $table->float('sugar_percent')->nullable(); - $table->float('fat_percent')->nullable(); - $table->float('saturated_fatty_acids_percent')->nullable(); - $table->float('dietary_fiber_percent')->nullable(); - $table->float('salt_percent')->nullable(); - $table->foreignId('product_id'); $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 1f718d1..16cb45b 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use App\Models\IncoNutritionalValue; use App\Models\Product; +use App\Models\Range; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; @@ -20,9 +21,11 @@ class ProductSeeder extends Seeder Product::all()->map(function (Product $product) { $product->deleteOrFail(); }); - Product::factory(150)->create()->each(function (Product $product) { + $ranges = Range::all(); + Product::factory(150)->make()->each(function (Product $product) use ($ranges) { \mt_srand(); if (\mt_rand(0, 2)) { + $product->range()->associate($ranges->random(1)->first()); $product->saveOrFail(); $product->fresh(); /** @var \App\Models\IncoNutritionalValue $incValue */ diff --git a/database/seeders/RangeSeeder.php b/database/seeders/RangeSeeder.php index a4a4fa8..9f6317a 100644 --- a/database/seeders/RangeSeeder.php +++ b/database/seeders/RangeSeeder.php @@ -4,7 +4,6 @@ namespace Database\Seeders; use App\Models\Range; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; class RangeSeeder extends Seeder { @@ -15,7 +14,9 @@ class RangeSeeder extends Seeder */ public function run() { - DB::table('ranges')->truncate(); + Range::all()->map(function (Range $range) { + $range->deleteOrFail(); + }); Range::factory(8)->create(); } } diff --git a/resources/ts/components/bo/EnumTagDropdownComponent.vue b/resources/ts/components/bo/EnumTagDropdownComponent.vue new file mode 100644 index 0000000..290d8dc --- /dev/null +++ b/resources/ts/components/bo/EnumTagDropdownComponent.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/resources/ts/confirm.ts b/resources/ts/confirm.ts index 3b70474..24e84c2 100644 --- a/resources/ts/confirm.ts +++ b/resources/ts/confirm.ts @@ -53,7 +53,7 @@ window.addEventListener("DOMContentLoaded", () => { const el = e.target; if (!el || !(el instanceof HTMLFormElement)) { throw new Error( - "confirmDoneJS can only be executed on an exising form" + "confirmSubmitJS can only be executed on an exising form" ); } confirm.methods.confirm( @@ -70,24 +70,42 @@ window.addEventListener("DOMContentLoaded", () => { } elements = document.getElementsByClassName("confirmJS"); for (let element of elements) { - element.addEventListener("submit", function (e) { - e.preventDefault(); - const el = e.target; - if (!el || !(el instanceof HTMLFormElement)) { - throw new Error( - "confirmDoneJS can only be executed on an exising form" - ); - } - confirm.methods.confirm( - "Confirmez", - "Êtes vous sure ?", - el, - function (response: SweetAlertResult) { - (el as HTMLFormElement).submit(); - }, - { icon: "warning" } - ); - return false; - }); + element.addEventListener( + "click", + function (e) { + if (!element.getAttribute("confirmJS")) { + e.preventDefault(); + const el = e.target; + if (!el || !(el instanceof HTMLElement)) { + throw new Error( + "confirmJS can only be executed on a html element" + ); + } + (async () => { + const promise = new Promise( + (resolve: (value: boolean) => void, reject) => { + console.log(el); + confirm.methods.confirm( + "Confirmez", + "Êtes vous sure ?", + el, + function (response: SweetAlertResult) { + resolve(response.isConfirmed); + }, + { icon: "warning" } + ); + return false; + } + ); + if (await promise) { + element.setAttribute("confirmJS", "true"); + el.click(); + element.removeAttribute("confirmJS"); + } + })(); + } + }, + false + ); } }); diff --git a/resources/ts/modules/confirm.ts b/resources/ts/modules/confirm.ts index f963f33..5c6c6ab 100644 --- a/resources/ts/modules/confirm.ts +++ b/resources/ts/modules/confirm.ts @@ -9,7 +9,7 @@ export default { confirm( title: string, text: string = "", - self: HTMLElement, + self: Object, after: (response: SweetAlertResult) => void = ( response: SweetAlertResult ) => {}, diff --git a/resources/views/back/products/create.blade.php b/resources/views/back/products/create.blade.php new file mode 100644 index 0000000..d181e2d --- /dev/null +++ b/resources/views/back/products/create.blade.php @@ -0,0 +1,30 @@ +@extends('layouts.back') + +@section('title', sprintf('%s - %s', __('models.classes.products'), Str::ucfirst(__('création')))) + +@section('breadcrumb', request()->route()->getName()) + +@section('content') +
+
+
+
+ @include('back.breadcrumb.breadcrumb-body') +
+
+
+

+ +  {{ __('models.classes.products') }} {{ __('création') }} +

+
+
+
+
+ @csrf + @include('back.products.form') + +
+
+
+@endsection diff --git a/resources/views/back/products/edit.blade.php b/resources/views/back/products/edit.blade.php new file mode 100644 index 0000000..c980c6d --- /dev/null +++ b/resources/views/back/products/edit.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.back') + +@section('title', sprintf('%s - %s %s', __('models.classes.products'), Str::ucfirst(__('édition')), $productModel->title)) + +@section('breadcrumb', request()->route()->getName()) + +@section('content') +
+
+
+
+ @include('back.breadcrumb.breadcrumb-body', [ + 'brParam' => $productModel + ]) +
+
+
+
+

+ +  {{ __('models.classes.products') }} {{ __('édition') }} +

+
+
+
+ @method('DELETE') + @csrf +
+ {{-- Public View Page --}} + @if($productModel->status) + + @endif + +
+
+
+
+
+
+
+ @csrf + @method('PUT') + @include('back.products.form', compact('productModel')) + +
+
+
+@endsection diff --git a/resources/views/back/products/form.blade.php b/resources/views/back/products/form.blade.php new file mode 100644 index 0000000..88672ac --- /dev/null +++ b/resources/views/back/products/form.blade.php @@ -0,0 +1,415 @@ +
+
+
+ {{ __('Informations générales') }} +
+
+ +
'productModelTitle'])'>
+ + @include('back.modules.input-error', [ + 'inputName' => 'title', 'helperId' => 'productModelTitleHelp' , 'helper' => __('Doit décrire précisément le produit') + ]) + @include('back.modules.input-error', [ + 'inputName' => 'slug' + ]) +
+
+ + + @include('back.modules.input-error', [ + 'inputName' => 'range_id', 'helperId' => 'productModelTypeHelp' , 'helper' => __('La gamme du produit') + ]) +
+
+
+
+
+ +
+
+
+ {{ __('Seo') }} +
+
+ +
'productModelSeoTitle'])'>
+ + @include('back.modules.input-error', [ + 'inputName' => 'seo_title', 'helperId' => 'productModelSeoTitleHelp' , 'helper' => __('Doit décrire précisément le produit, recommandation pour un positionnement optimal dans les moteurs de recherche : entre 53 et 60 caractères, balise ') + ]) + </div> + <div class="col-12 col-md-6 form-group"> + <label for="productModelSeoDescription" class="col-form-label"><b>{{ __('Description seo') }}</b></label> + @php + $data = [ + 'id' => 'productModelSeoDescription', + 'class' => 'form-control', + 'name' => 'seo_description', + 'placeholder' => __('Une description du produit pour les moteurs de recherche'), + 'ariaDescribedby' => 'productModelSeoDescriptionHelp', + 'required' => true, + 'value' => old('seo_description', $productModel->seo_description ?? '') + ]; + @endphp + <div id="input-text-auto" data-json='@json($data)'></div> + @include('back.modules.input-error', [ + 'inputName' => 'seo_description', 'helperId' => 'productModelSeoDescriptionHelp' , 'helper' => __(' Doit décrire précisément le produit, requiert entre 50 et 160 caractères, balise <meta> description') + ]) + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Labels') }}</legend> + <div class="row mb-3"> + <div class="col-12 form-group"> + @php + $data = [ + 'name' => 'labels', + 'value' => old('labels', $productModel->labels ?? []), + 'tags' => App\Enums\Products\ProductLabelEnum::toArray(), + ]; + @endphp + <div id="enum-tag-dropdown" data-json='@json($data)'></div> + @include('back.modules.input-error', [ + 'inputName' => 'labels', 'helperId' => 'newsModelTagsHelp' , 'helper' => __('Les labels associés au produit') + ]) + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Publication') }}</legend> + <div class="row mb-3"> + <div class="col"> + <div class="form-group"> + <label for="productPublishedAt"><b>{{ __('Date de publication (Europe/Paris)') }}</b></label> + <input id="productPublishedAt" name="publish_at" class="form-control publishTime" type="datetime-local" data-form-type="date" aria-describedby="productPublishAtHelp" value="{{ old('publish_at', ((isset($productModel) and $productModel->publish_at) ? $productModel->publish_at->format('Y-m-d\TH:i') : '')) }}"> + @include('back.modules.input-error', [ + 'inputName' => 'publish_at', 'helperId' => 'productPublishAtHelp' , 'helper' => (isset($productModel) and $productModel->publish_at) ? __('Laisser vide pour un affichage immédiat du produit.') : __('Vider pour un affichage immédiat du produit') + ]) + </div> + </div> + <div class="col"> + <div class="form-group"> + <label for="productUnPublishedAt"><b>{{ __('Date de fin de publication (Europe/Paris)') }}</b></label> + <input id="productUnPublishedAt" name="unpublish_at" class="form-control publishTime" type="datetime-local" data-form-type="date" aria-describedby="productUnPublishAtHelp" value="{{ old('unpublish_at', ((isset($productModel) and $productModel->unpublish_at) ? $productModel->unpublish_at->format('Y-m-d\TH:i') : '')) }}"> + @include('back.modules.input-error', [ + 'inputName' => 'unpublish_at', 'helperId' => 'productUnPublishAtHelp' , 'helper' => (isset($productModel) and $productModel->unpublish_at) ? __('Laisser vide pour un affichage permanent du produit.') : __('Vider pour un affichage permanent du produit') + ]) + </div> + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Illustration') }}</legend> + <div class="row mb-3"> + <div class="col-12 col-md-6 form-group"> + <label for="productPictureAlt" class="col-form-label"><b>{{ __('Texte alternatif de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'productPictureAlt'])'></div> + <input name="picture_alt" type="text" value="{{ old('picture_alt', $productModel->picture_alt ?? '') }}" class="form-control" + id="productPictureAlt" aria-describedby="productPictureAltHelp" placeholder="{{ __('Une image de petits chatons') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_alt', 'helperId' => 'productPictureAltHelp' , 'helper' => __('Doit décrire votre image pour les moteurs de recherches et les non voyants (5 à 6 mots)') + ]) + </div> + <div class="col-12 col-md-6 form-group"> + <label for="productPictureTitle" class="col-form-label"><b>{{ __('Titre de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'productPictureTitle'])'></div> + <input name="picture_title" type="text" value="{{ old('picture_title', $productModel->picture_title ?? '') }}" class="form-control" + id="productPictureTitle" aria-describedby="productPictureTitleHelp" placeholder="{{ __('Une description du produit pour les moteurs de recherche') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_title', 'helperId' => 'productPictureTitleHelp' , 'helper' => __('Utile pour les utilisateurs, s\'affiche en passant la souris sur l\'image.') + ]) + </div> + </div> + <div class="row"> + <div class="col-12"> + @php + $data = [ + 'id' => 'productModelPicture', + 'name' => 'picture', + 'helper' => __('L\'image qui va servir de vignette à le produit, doit être de 700px (hauteur) par 700px (largeur)'), + 'width' => 700, + 'height' => 700, + 'value' => $productModel->picture ?? '', + ]; + @endphp + @include('back.modules.input-error', [ 'inputName' => 'picture']) + <div class="image-input" data-json='@json($data)'></div> + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Vignette') }}</legend> + <div class="row mb-3"> + <div class="col-12 col-md-6 form-group"> + <label for="productPictureThumbAlt" class="col-form-label"><b>{{ __('Texte alternatif de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'productPictureThumbAlt'])'></div> + <input name="picture_thumb_alt" type="text" value="{{ old('picture_thumb_alt', $productModel->picture_thumb_alt ?? '') }}" class="form-control" + id="productPictureThumbAlt" aria-describedby="productPictureThumbAltHelp" placeholder="{{ __('Une image de petits chatons') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_thumb_alt', 'helperId' => 'productPictureThumbAltHelp' , 'helper' => __('Doit décrire votre image pour les moteurs de recherches et les non voyants (5 à 6 mots)') + ]) + </div> + <div class="col-12 col-md-6 form-group"> + <label for="productPictureThumbTitle" class="col-form-label"><b>{{ __('Titre de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'productPictureThumbTitle'])'></div> + <input name="picture_thumb_title" type="text" value="{{ old('picture_thumb_title', $productModel->picture_thumb_title ?? '') }}" class="form-control" + id="productPictureThumbTitle" aria-describedby="productPictureThumbTitleHelp" placeholder="{{ __('Une description du produit pour les moteurs de recherche') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_thumb_title', 'helperId' => 'productPictureThumbTitleHelp' , 'helper' => __('Utile pour les utilisateurs, s\'affiche en passant la souris sur l\'image.') + ]) + </div> + </div> + <div class="row"> + <div class="col-12"> + @php + $data = [ + 'id' => 'productModelThumbPicture', + 'name' => 'picture_thumb', + 'helper' => __('L\'image qui va servir de vignette à le produit, doit être de 300px (hauteur) par 300px (largeur)'), + 'width' => 300, + 'height' => 300, + 'value' => $productModel->picture_thumb ?? '', + ]; + @endphp + @include('back.modules.input-error', [ 'inputName' => 'picture_thumb']) + <div class="image-input" data-json='@json($data)'></div> + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Description du produit') }}</legend> + <div class="row mb-3"> + <div class="col-12 form-group"> + @include('back.modules.input-error', [ + 'inputName' => 'description', 'helperId' => 'productModelDescriptionHelp' , 'helper' => __('Relisez bien votre description avant de la publier.') + ]) + @php + $data = [ + 'name' => 'description', + 'text' => old('description', $productModel->description ?? ''), + ]; + @endphp + <div class="ck-editor-bo" data-json='@json($data)'></div> + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Informations Inco') }}</legend> + @php $attributes = [ + 'ingredients' => __('Ingrédients'), + 'preparation_advices' => __('Conseils de préparation'), + 'sales_pitch' => __('Arguments de vente'), + 'allergen_list' => __('Liste d\'allergènes') + ]; @endphp + @foreach($attributes as $attribute => $helperText) + <div class="row mb-3"> + <div class="col-12 form-group"> + <span class="h6 fw-bold d-inline-block mb-2">{{ $helperText }}</span> + <div class="form-check form-switch d-inline-block ms-5"> + <input class="form-check-input" name="{{ "show_{$attribute}" }}" type="checkbox" role="switch" id="{{ "{$attribute}Switch" }}" value="1" @if(old("show_{$attribute}", $productModel->{"show_{$attribute}"} ?? null)) checked @endif> + <label class="form-check-label" for="{{ "{$attribute}Switch" }}"><span class="fw-bold fst-italic">{{ __('Afficher ? ') }}</span></label> + </div> + @include('back.modules.input-error', [ + 'inputName' => $attribute + ]) + @php + $data = [ + 'name' => $attribute, + 'text' => old($attribute, $productModel->{$attribute} ?? ''), + ]; + @endphp + <div class="ck-editor-bo" data-json='@json($data)'></div> + </div> + </div> + @endforeach + <div class="row mb-3"> + <div class="col-12 col-md-6 form-group"> + <label for="productModelPortions" class="col-form-label d-inline-block"><b>{{ __('Nombre de portions') }}</b></label> + <div class="form-check form-switch d-inline-block ms-5"> + <input class="form-check-input" name="show_portions" type="checkbox" role="switch" id="portionsSwitch" value="1" @if(old('show_portions', $productModel->show_portions ?? null)) checked @endif> + <label class="form-check-label" for="portionsSwitch"><span class="fw-bold fst-italic">{{ __('Afficher ? ') }}</span></label> + </div> + <input name="number_portion" type="number" value="{{ old('number_portion', $productModel->number_portion ?? 0) }}" class="form-control" + id="productModelPortions" aria-describedby="productModelPortionsHelp" min="0" step="1" required> + @include('back.modules.input-error', [ + 'inputName' => 'number_portion', 'helperId' => 'productModelPortionsHelp' , 'helper' => __('Nombre de portions du produit') + ]) + </div> + </div> + <div class="form-group"> + <div class="row"> + <label class="h6 fw-bold d-inline-block">{{ __('Valeurs nutritionelles') }}</label> + <div class="form-check form-switch d-inline-block ms-5"> + <input class="form-check-input" name="show_nutrionnal_values" type="checkbox" role="switch" id="nutrionnaSwitch" value="1" @if(old('show_nutrionnal_values', $productModel->show_nutrionnal_values ?? null)) checked @endif> + <label class="form-check-label" for="nutrionnaSwitch"><span class="fw-bold fst-italic">{{ __('Afficher ? ') }}</span></label> + </div> + @php + $incoNut = (isset($productModel) and $productModel->xmlInco) ? json_decode($productModel->xmlInco->nutritional_values) : null; + @endphp + <div class="table-responsive"> + <table class="table table-light text-center table-sm table-bordered table-striped"> + <thead class="table-group-divider"> + <tr> + <th scope="col">Nutriment</th> + <th scope="col">Valeur</th> + @if(isset($incoNut)) + <th scope="col">Val. Source Inco</th> + @endif + </tr> + </thead> + <tbody class="table-group-divider"> + @php $incoValues = [ + 'Poids de référence (en g)' => ['product' => 'weight', 'inco' => ''], + 'Énergie (en kCal)' => ['product' => 'energy', 'inco' => 'val_energ_kcal_r'], + 'Énergie (en kJ)' => ['product' => 'energy_kj', 'inco' => 'val_energ_kj_r'], + 'Protéines (en g)' => ['product' => 'protein', 'inco' => 'proteine_r'], + 'Glucides (en g)' => ['product' => 'carbohydrates', 'inco' => 'glucides_r'], + 'Sucres (en g)' => ['product' => 'sugar', 'inco' => 'sucre_r'], + 'Matière grasse (en g)' => ['product' => 'fat', 'inco' => 'mat_grass_r'], + 'Acides Gras Saturés (en g)' => ['product' => 'saturated_fatty_acids', 'inco' => 'ags_r'], + 'Fibres alimentaires (en g)' => ['product' => 'dietary_fiber', 'inco' => 'fib_alim_r'], + 'Sel (en g)' => ['product' => 'salt', 'inco' => 'sel_r'] + ]; @endphp + @foreach($incoValues as $title => $incoValue) + <tr> + <td class="w-50">{{ $title }}</td> + <td class="w-25"><input class="form-control form-control-sm w-100 h-100" type="number" step="1" name="{{ $incoValue['product'] }}" value="{{ old($incoValue['product'], isset($productModel->incoNutritionalValues->{$incoValue['product']}) ? str_replace(',', '.', (string)$productModel->incoNutritionalValues->{$incoValue['product']}) : 0) }}"></td> + @if(isset($incoNut)) + <td class="w-25"><span class="text-danger">{{ isset($incoNut->{$incoValue['inco']}) ? str_replace(',', '.', $incoNut->{$incoValue['inco']}) : 0 }}</span></td> + @endif + </tr> + @endforeach + </tbody> + </table> + </div> + </div> + @if(isset($productModel) and isset($productModel->xmlInco)) + <div class="row justify-content-center"> + <div class="col-12 col-md-5 text-center"> + <a href="#" class="btn btn-danger confirmJS">{{ __('Copier les Valeurs nutritionelles') }}</a> + </div> + </div> + @endif + </div> + </fieldset> + </div> +</div> + +<div class="row mb-3"> + <div class="col-12 col-md-6"> + <div class="form-check"> + <input name="status" class="form-check-input mt-2" type="checkbox" id="productStatus" aria-describedby="productStatusHelp" value="1" @if(old('status', $productModel->status ?? ''))checked @endif> + <label class="form-check-label user-select-none pt-1" for="productStatus"><b>{{ __('Publié') }}</b></label><br> + @include('back.modules.input-error', [ + 'inputName' => 'status', 'helperId' => 'productStatusHelp' , 'helper' => __('Le produit est visible ou non') + ]) + </div> + </div> +</div> + + +@push('scripts') +<script nonce="{{ $nonce }}"> +document.addEventListener("DOMContentLoaded", () => { + + // Publish times + let publishTimes = document.getElementsByClassName('publishTime'); + for (let input of publishTimes) { + let parent = input.parentNode, + dates = { + start: 0, + end: 0 + }; + input.originalValue = (new Date(input.value)).valueOf(); + if(input.originalValue === NaN) { + input.originalValue = 0 + } + if(input.id === 'productPublishedAt') { + dates.start = input.originalValue; + } else { + dates.end = input.originalValue; + } + let getEuropeParisTime = () => { + return new Date((new Date()).getTime() + (60000*((new Date()).getTimezoneOffset()+60))); + }; + let setDateHelpers = (input, parent) => { + let small = parent.children.productPublishAtHelp ? parent.children.productPublishAtHelp : parent.children.productUnPublishAtHelp; + let isPlanned = false; + if(input.id === 'productPublishedAt') { + isPlanned = dates.start > dates.end && dates.end != 0; + } else { + isPlanned = dates.start < dates.end && dates.start != 0; + } + if (isPlanned || getEuropeParisTime().valueOf() < (new Date(input.value)).valueOf()) { + let date = new Date(input.value); + // small.textContent = "{{ __('Planifié pour le ') }}" + date.toLocaleString(); + small.textContent = input.id === 'productPublishedAt' ? + "{{ __('Vider pour un affichage immédiat du produit.') }}" : + "{{ __('Vider pour un affichage permanent du produit.') }}"; + } else { + // small.textContent = input.id === 'productPublishedAt' ? + // "{{ __('Uniquement pour planifier la publication') }}" : + // "{{ __('Uniquement pour planifier la fin de la publication') }}"; + small.textContent = input.id === 'productPublishedAt' ? + "{{ __('Laisser vide pour un affichage immédiat du produit.') }}" : + "{{ __('Laisser vide pour un affichage permanent du produit.') }}"; + } + }; + setDateHelpers(input, parent); + input.addEventListener('change', (event) => { + let parent = event.target.parentNode, + input = event.target, value; + value = (new Date(input.value)).valueOf(); + if(input.originalValue === NaN) { + value = 0 + } + if(input.id === 'productPublishedAt') { + dates.start = value; + } else { + dates.end = value; + } + setDateHelpers(input, parent); + }); + } +}); +</script> +@endpush diff --git a/resources/views/back/products/index.blade.php b/resources/views/back/products/index.blade.php new file mode 100644 index 0000000..c957e08 --- /dev/null +++ b/resources/views/back/products/index.blade.php @@ -0,0 +1,103 @@ +@extends('layouts.back') + +@section('title', sprintf('%s - %s', __('models.classes.products'), Str::ucfirst(__('liste')))) + +@section('breadcrumb', request()->route()->getName()) + +@section('content') + <div class="card"> + <div class="card-header"> + <div class="row"> + <div class="col-12"> + @include('back.breadcrumb.breadcrumb-body') + </div> + </div> + <div class="row"> + <div class="col"> + <h1 class="h2">{{ __('models.classes.products') }} {{ Str::ucfirst(__('liste')) }}</h1> + </div> + <div class="col-md-3"> + <div class="input-group justify-content-end"> + <a href="{{ route('bo.products.create') }}" class="btn btn-primary" title="{{ __('Ajouter un :model', [ + 'model' => Str::singular(__('models.classes.products')) + ]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-plus"></i></a> + </div> + </div> + </div> + </div> + <div class="card-body"> + @include('back.modules.searchBar') + <div class="row justify-content-center"> + <div class="col-12 col-md-6 p-0 p-md-3"> + {{ $products->withQueryString()->onEachSide(1)->links() }} + </div> + </div> + <div class="row"> + <div class="col"> + <div class="table-responsive"> + <table class="table fixAction"> + <thead> + @php + $cols = ['title' => __('Titre'), 'description' => __('Description'), 'status' => __('Publication'), 'updated_at' => __('Mis à jour le')]; + @endphp + @include('back.modules.tableColSorter', [ 'cols' => $cols, 'mobileHide' => ['status']]) + </thead> + <tbody> + @if($products->count()) + @foreach ($products as $productModel) + <tr> + <td> + <span>{{ $productModel->title }}</span> + </td> + <td> + <span>{{ Str::limit(strip_tags($productModel->description)) }}</span> + </td> + <td class="d-none d-md-table-cell"> + <form action="{{ route('bo.products.change-published', $productModel->id) }}" method="POST"> + @csrf + <button type="submit" class="btn btn-sm" title="{{ __(':action :model', [ + 'action' => $productModel->status ? __('Déplublier') : __('Publier'), + 'model' => Str::singular(__('models.classes.products')) + ]) }}" data-bs-toggle="tooltip"> + <i class="fas @if($productModel->status)fa-check check-green @else fa-times cross-red @endif"></i> + </button> + </form> + </td> + <td> + <span>{{ $productModel->updated_at->formatLocalized('%d/%m/%Y à %I:%M') }}</span> + </td> + <td> + <div class="btn-group"> + {{-- Public View Page --}} + @if($productModel->status) + <a class="btn btn-sm btn-warning" href="" target="_blank" title="{{ __('Visualiser :model', [ + 'model' => Str::singular(__('models.classes.products')) + ]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-eye"></i></a> + @endif + <a class="btn btn-sm btn-primary" href="{{ route('bo.products.edit', $productModel->id) }}" title="{{ __('Modifier :model', [ + 'model' => Str::singular(__('models.classes.products')) + ]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-pencil"></i></a> + </div> + </td> + </tr> + @endforeach + @else + <tr> + <td colspan="5" class="text-center"><span class="h3">{{ + __('Aucun résultat pour les :models', ['models' => __('models.classes.products')]) + }}</span></td> + </tr> + @endif + </tbody> + </table> + </div> + </div> + </div> + <div class="row justify-content-center"> + <div class="col-12 col-md-6 p-0 p-md-3"> + {{ $products->withQueryString()->onEachSide(1)->links() }} + </div> + </div> + </div> + </div> +@endsection diff --git a/routes/web-bo.php b/routes/web-bo.php index 97137e0..d83dd0c 100644 --- a/routes/web-bo.php +++ b/routes/web-bo.php @@ -40,7 +40,12 @@ Route::prefix('bo')->name('bo.')->group(function () { Route::get('/ranges/model-title', [RangeController::class, 'modelTitle']) ->name('ranges.model-title'); - Route::resource('sliders', SliderController::class)->except(['show']); + // * PRODUCTS + Route::resource('products', ProductController::class)->except(['show']) + ->withoutMiddleware('htmlsanitize') + ->middleware('htmlsanitize:description,ingredients,preparation_advices,sales_pitch,allergen_list'); + Route::post('/products/{product}/change-published', [ProductController::class, 'changePublished']) + ->name('products.change-published'); // * TAGS Route::resource('tags', TagController::class)->except(['show']);