diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2d6f9b9..1bd5031 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,24 +1,40 @@ -image: jakzal/phpqa:php7.3 +image: $CI_REGISTRY/service-web-lab/ldc-web-images-docker/ldcweb-qa:php7.3-1.0 stages: - Security - CodingStandards -security: +composer-security: stage: Security script: - - local-php-security-checker --format=json --path=./composer.lock > security-checker-report.json + - local-php-security-checker --format=json --path=./composer.lock > composer-security-checker-report.json artifacts: when: on_failure - expose_as: 'security-checker' + expose_as: 'composer-security-checker' paths: - - security-checker-report.json + - composer-security-checker-report.json + allow_failure: true + +npm-security: + stage: Security + script: + - audit-ci -p npm -o json > npm-security-checker-report.json + artifacts: + when: on_failure + expose_as: 'npm-security-checker' + paths: + - npm-security-checker-report.json allow_failure: true phpcs: stage: CodingStandards + before_script: + - composer install + cache: + paths: + - vendor/ script: - - phpcs --report=junit --report-file=phpcs-report.xml + - vendor/bin/phpcs --report=junit --report-file=phpcs-report.xml artifacts: when: on_failure expose_as: 'phpcs' diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..11298b4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#595926", + "activityBar.activeBorder": "#439c9c", + "activityBar.background": "#595926", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#439c9c", + "activityBarBadge.foreground": "#15202b", + "sash.hoverBorder": "#595926", + "statusBar.background": "#353517", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#595926", + "statusBarItem.remoteBackground": "#353517", + "statusBarItem.remoteForeground": "#e7e7e7", + "titleBar.activeBackground": "#353517", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#35351799", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.remoteColor": "#353517", + "vue3snippets.enable-compile-vue-file-on-did-save-code": true +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index f8ff2fe..b0c689c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,8 @@ namespace App\Console; +use App\Jobs\RangesStatusUpdate; +use App\Jobs\ProductStatusUpdate; use App\Jobs\SlidersStatusUpdate; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -17,6 +19,8 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule) { $schedule->job(SlidersStatusUpdate::class)->everyMinute(); + $schedule->job(RangesStatusUpdate::class)->everyMinute(); + $schedule->job(ProductStatusUpdate::class)->everyMinute(); } /** diff --git a/app/Enums/Products/ProductLabelEnum.php b/app/Enums/Products/ProductLabelEnum.php new file mode 100644 index 0000000..a685d81 --- /dev/null +++ b/app/Enums/Products/ProductLabelEnum.php @@ -0,0 +1,47 @@ + 0, + 'lr' => 1, + 'bea' => 2, + 'nutriscore' => 3, + 'planetscore' => 4 + ]; + } + + /** + * Enum labels + * + * @return array + */ + protected static function labels(): array + { + return [ + 'bio' => trans('Bio'), + 'lr' => trans('Label Rouge'), + 'bea' => trans('Bea'), + 'nutriscore' => trans('Nutriscore'), + 'planetscore' => trans('PlanetScore'), + ]; + } +} diff --git a/app/Enums/Ranges/RangeCategoryEnum.php b/app/Enums/Ranges/RangeCategoryEnum.php new file mode 100644 index 0000000..9a0ab95 --- /dev/null +++ b/app/Enums/Ranges/RangeCategoryEnum.php @@ -0,0 +1,41 @@ + 0, + 'egg' => 1, + 'partners' => 2 + ]; + } + + /** + * Enum labels + * + * @return array + */ + protected static function labels(): array + { + return [ + 'poultry' => trans('🐔 Vollaille'), + 'egg' => trans('🥚 Oeuf'), + 'partners' => trans('🤝 Produits partenaires'), + ]; + } +} 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/Controllers/Bo/RangeController.php b/app/Http/Controllers/Bo/RangeController.php new file mode 100644 index 0000000..039f905 --- /dev/null +++ b/app/Http/Controllers/Bo/RangeController.php @@ -0,0 +1,197 @@ +search) { + $this->searchQuery( + $ranges, + $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($ranges); + $searchFields = trans('Titre, Description'); + + $ranges->orderBy('updated_at', 'DESC'); + $ranges = $ranges->paginate(); + + return view('back.ranges.index', compact('ranges', 'searchFields')); + } + + /** + * Show the form for creating a new resource. + * + * @return \Illuminate\Http\Response + */ + public function create() + { + return view('back.ranges.create'); + } + + /** + * Store a newly created resource in storage. + * + * @param \App\Http\Requests\Bo\Ranges\StoreRangeRequest $request + * @return \Illuminate\Http\Response + */ + public function store(StoreRangeRequest $request) + { + return DB::transaction(function () use ($request) { + $range = new Range(); + $range->fill($request->validated()); + + if ($range->saveOrFail()) { + return redirect() + ->route('bo.ranges.edit', $range) + ->with('success', trans( + ':model a été créé', + ['model' => Str::singular(trans('models.classes.ranges'))] + )); + } + return back(); + }); + } + + /** + * Show the form for editing the specified resource. + * + * @param \App\Models\Range $range + * @return \Illuminate\Http\Response + */ + public function edit(Range $range) + { + return view('back.ranges.edit', [ + 'rangeModel' => $range + ]); + } + + /** + * Update the specified resource in storage. + * + * @param \App\Http\Requests\Bo\Ranges\UpdateRangeRequest $request + * @param \App\Models\Range $range + * @return \Illuminate\Http\Response + */ + public function update(UpdateRangeRequest $request, Range $range) + { + return DB::transaction(function () use ($request, $range) { + $range->fill($request->validated()); + + if ($range->saveOrFail()) { + return redirect() + ->route('bo.ranges.edit', $range) + ->with('success', trans( + ':model a été mis à jour', + ['model' => Str::singular(trans('models.classes.ranges'))] + )); + } + return back(); + }); + } + + /** + * Remove the specified resource from storage. + * + * @param \App\Models\Range $range + * @return \Illuminate\Http\Response + */ + public function destroy(Range $range) + { + if ($range->deleteOrFail()) { + return redirect() + ->route('bo.ranges.index') + ->with('warning', trans( + ':model a bien été supprimé', + ['model' => Str::singular(trans('models.classes.ranges'))] + )); + } + return back(); + } + + /** + * Search uris for ranges input links + * + * @param \App\Http\Requests\Bo\Ranges\RangeSearchUriRequest $request + * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable + */ + public function searchUri(RangeSearchUriRequest $request) + { + + /** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Breeder> */ + $breeders = Breeder::where('firstname', 'LIKE', "%$request->search%") + ->orWhere('lastname', 'LIKE', "%$request->search%") + ->orderBy('updated_at')->paginate(); + + /** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Recipe> */ + $recipes = Recipe::where('title', 'LIKE', "%$request->search%") + ->orWhere('slug', 'LIKE', "%$request->search%") + ->orderBy('updated_at')->paginate(); + + return new UriResource( + collect($breeders->all()) + ->merge($recipes->all()) + ); + } + + /** + * Get Model title input text uri (from DB value) + * + * @param \App\Http\Requests\Bo\Ranges\RangeModelTitleRequest $request + * @return \Illuminate\Http\Response + */ + public function modelTitle(RangeModelTitleRequest $request) + { + $title = ''; + switch ($request->model) { + case Breeder::class: + /** @var \App\Models\Breeder */ + $breeder = Breeder::whereId($request->id)->first(); + $title = \trim(optional($breeder)->lastname . ' ' . optional($breeder)->firstname); + break; + case Recipe::class: + $title = Recipe::whereId($request->id)->first()->title ?? ''; + break; + } + return \response()->json($title); + } +} diff --git a/app/Http/Controllers/Bo/SliderController.php b/app/Http/Controllers/Bo/SliderController.php index 79ca0a5..b470aeb 100644 --- a/app/Http/Controllers/Bo/SliderController.php +++ b/app/Http/Controllers/Bo/SliderController.php @@ -4,20 +4,19 @@ namespace App\Http\Controllers\Bo; use App\Enums\Sliders\SliderType; use App\Http\Controllers\Controller; -use App\Http\Requests\Bo\ChangeOrderRequest; use App\Http\Requests\Bo\Sliders\StoreSliderRequest; use App\Http\Requests\Bo\Sliders\UpdateSliderRequest; use App\Models\Slider; use App\Traits\Controllers\UpdateModelStatus; -use Illuminate\Database\Eloquent\ModelNotFoundException; +use App\Traits\Models\ChangesModelOrder; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; use Kwaadpepper\Enum\Exceptions\EnumNotRoutableException; class SliderController extends Controller { + use ChangesModelOrder; use UpdateModelStatus; /** @@ -145,43 +144,4 @@ class SliderController extends Controller } return back(); } - - /** - * Change Item Order - * - * @param \App\Http\Requests\Bo\ChangeOrderRequest $request - * @param \App\Models\Slider $slider - * @return \Illuminate\Http\RedirectResponse - */ - public function changeOrder(ChangeOrderRequest $request, Slider $slider) - { - try { - DB::transaction(function () use ($request, $slider) { - $tmp = Slider::query(); - if ($request->action === 'up') { - // * Push Item upward - $tmp->where('order', '<', $slider->order) - ->orderBy('order', 'DESC'); - } else { - // * Push Item downward - $tmp->where('order', '>', $slider->order) - ->orderBy('order', 'ASC'); - } - $tmp->whereType('type', $slider->type); - $tmp = $tmp->firstOrFail(); - $newOrder = $tmp->order; - $tmp->order = $slider->order; - $slider->order = $newOrder; - if (!$slider->saveOrFail() or !$tmp->saveOrFail()) { - throw new \ErrorException("Error Processing Request", 1); - } - }); - } catch (\ErrorException | ModelNotFoundException $e) { - Session::flash('error', trans('Erreur lors de la modification de l\'ordre :message', [ - 'message' => $e->getMessage() - ])); - } //end try - - return redirect()->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 @@ +check() and $this->json(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'id' => 'required|numeric', + 'model' => [ + \sprintf('in:%s,%s', Breeder::class, Recipe::class), + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed,Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed + function ($attribute, $value, $fail) { + $table = (new $value())->getTable(); + Validator::validate(['model' => $this->id], [ + 'model' => "required|exists:{$table},id", + ]); + } + ] + ]; + } +} diff --git a/app/Http/Requests/Bo/Ranges/RangeSearchUriRequest.php b/app/Http/Requests/Bo/Ranges/RangeSearchUriRequest.php new file mode 100644 index 0000000..ac723e3 --- /dev/null +++ b/app/Http/Requests/Bo/Ranges/RangeSearchUriRequest.php @@ -0,0 +1,31 @@ +check() and $this->json(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'search' => 'required|string', + 'page' => 'sometimes|numeric', + ]; + } +} diff --git a/app/Http/Requests/Bo/Ranges/StoreRangeRequest.php b/app/Http/Requests/Bo/Ranges/StoreRangeRequest.php new file mode 100644 index 0000000..58a4da0 --- /dev/null +++ b/app/Http/Requests/Bo/Ranges/StoreRangeRequest.php @@ -0,0 +1,128 @@ +check(); + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation() + { + $this->merge([ + 'slug' => Str::slug($this->title), + 'status' => $this->status ? true : false + ]); + $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', + 'category' => ['required', new EnumIsValidRule(RangeCategoryEnum::class)], + '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', + 'link1' => self::getUrlValidationRules(), + 'link2' => self::getUrlValidationRules(), + ], + $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'), + 'link1' => trans('lien 1'), + 'link2' => trans('lien 2'), + ]; + } + + /** + * Url validation rules + * Validates an internal url format + * + * @return array + */ + public static function getUrlValidationRules(): array + { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed + return ['nullable', function ($attribute, $value, $fail) { + $params = collect(\explode('#', \ltrim(\rtrim($value, '#'), '#'))); + if ( + !\in_array( + $params->get(0), + [Breeder::class, Recipe::class] + ) + ) { + $fail(trans('Invalid internal url')); + return; + } + // Check if model id exists. Param 0 is class, 1 is id. + if (!\forward_static_call([$params->get(0), 'find'], $params->get(1))) { + $fail(trans("$attribute n\'est plus un lien valide.")); + } + }]; + } +} diff --git a/app/Http/Requests/Bo/Ranges/UpdateRangeRequest.php b/app/Http/Requests/Bo/Ranges/UpdateRangeRequest.php new file mode 100644 index 0000000..a093c0c --- /dev/null +++ b/app/Http/Requests/Bo/Ranges/UpdateRangeRequest.php @@ -0,0 +1,10 @@ +resource; + return [ + 'class' => Breeder::class, + 'label' => \trim("{$model->lastname} {$model->firstname}"), + 'value' => $model->id + ]; + } +} diff --git a/app/Http/Resources/RecipeUriResource.php b/app/Http/Resources/RecipeUriResource.php new file mode 100644 index 0000000..a381987 --- /dev/null +++ b/app/Http/Resources/RecipeUriResource.php @@ -0,0 +1,27 @@ +resource; + return [ + 'class' => Recipe::class, + 'label' => \trim("{$model->title}"), + 'value' => $model->id + ]; + } +} diff --git a/app/Http/Resources/UriResource.php b/app/Http/Resources/UriResource.php new file mode 100644 index 0000000..0daaec6 --- /dev/null +++ b/app/Http/Resources/UriResource.php @@ -0,0 +1,37 @@ +resource; + return $collection->map(function (Model $model) { + switch (\get_class($model)) { + case Breeder::class: + return new BreederUriResource($model); + case Recipe::class: + return new RecipeUriResource($model); + default: + throw new \RuntimeException(sprintf( + 'Unhandled model %s', + \get_class($model) + )); + } + }); + } +} diff --git a/app/Jobs/ProductStatusUpdate.php b/app/Jobs/ProductStatusUpdate.php new file mode 100644 index 0000000..2dc74ab --- /dev/null +++ b/app/Jobs/ProductStatusUpdate.php @@ -0,0 +1,41 @@ +toDateTime()) + ->whereNotNull('publish_at') + ->whereStatus(false)->get(); + Log::debug(__CLASS__ . " products to publish " . $productsToPublish->count()); + foreach ($productsToPublish as $product) { + $product->publish_at = null; + $product->status = true; + $product->saveOrFail(); + } + $productsToUnPublish = Product::where('unpublish_at', '<=', Carbon::now()->toDateTime()) + ->whereNotNull('unpublish_at') + ->whereStatus(true)->get(); + Log::debug(__CLASS__ . " products to unpublish " . $productsToPublish->count()); + foreach ($productsToUnPublish as $product) { + $product->unpublish_at = null; + $product->status = false; + $product->saveOrFail(); + } + } +} diff --git a/app/Jobs/RangesStatusUpdate.php b/app/Jobs/RangesStatusUpdate.php new file mode 100644 index 0000000..de93de0 --- /dev/null +++ b/app/Jobs/RangesStatusUpdate.php @@ -0,0 +1,41 @@ +toDateTime()) + ->whereNotNull('publish_at') + ->whereStatus(false)->get(); + Log::debug(__CLASS__ . " ranges to publish " . $rangesToPublish->count()); + foreach ($rangesToPublish as $range) { + $range->publish_at = null; + $range->status = true; + $range->saveOrFail(); + } + $rangesToUnPublish = Range::where('unpublish_at', '<=', Carbon::now()->toDateTime()) + ->whereNotNull('unpublish_at') + ->whereStatus(true)->get(); + Log::debug(__CLASS__ . " ranges to unpublish " . $rangesToPublish->count()); + foreach ($rangesToUnPublish as $range) { + $range->unpublish_at = null; + $range->status = false; + $range->saveOrFail(); + } + } +} diff --git a/app/Lib/ToolboxHelper.php b/app/Lib/ToolboxHelper.php index dcc71da..f2e4e2c 100644 --- a/app/Lib/ToolboxHelper.php +++ b/app/Lib/ToolboxHelper.php @@ -192,6 +192,50 @@ class ToolboxHelper } } + /** + * Remove pictures in ckeditor + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $attribute + * @return void + */ + public static function removeImagesOnCkEditor(Model $model, string $attribute) + { + // * Remove inline uploaded images + collect(ToolboxHelper::getCkEditorLocalUploadedFiles($model->getOriginal($attribute))) + ->map(function (string $originalPicturePath) { + // * If a picture is not used anymore + if (File::exists(\public_path($originalPicturePath))) { + File::delete(\public_path($originalPicturePath)); + } + }); + } + /** + * Remove unused pictures in ckeditor + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $attribute + * @return void + */ + public static function removeUnusedImagesOnCkEditor(Model $model, string $attribute) + { + // * Remove unused inline uploaded images + if ($model->{$attribute} != $model->getOriginal($attribute)) { + $updatedPicturesPath = collect( + ToolboxHelper::getCkEditorLocalUploadedFiles($model->{$attribute}) + ); + collect(ToolboxHelper::getCkEditorLocalUploadedFiles($model->getOriginal($attribute))) + ->map(function (string $originalPicturePath) use ($updatedPicturesPath) { + // * If a picture is not used anymore + if (!$updatedPicturesPath->contains($originalPicturePath)) { + if (File::exists(\public_path($originalPicturePath))) { + File::delete(\public_path($originalPicturePath)); + } + } + }); + } + } + /** * Store an image of different types * diff --git a/app/Models/Breeder.php b/app/Models/Breeder.php new file mode 100644 index 0000000..d771816 --- /dev/null +++ b/app/Models/Breeder.php @@ -0,0 +1,11 @@ + 'weight', + 'val_energ_kcal_r' => 'energy', + 'val_energ_kj_r' => 'energy_kj', + 'proteine_r' => 'protein', + 'sucre_r' => 'sugar', + 'mat_grass_r' => 'fat', + 'fib_alim_r' => 'dietary_fiber', + 'glucides_r' => 'carbohydrates', + 'ags_r' => 'saturated_fatty_acids', + 'sel_r' => 'salt' + ]; + $o = []; + + foreach ($cvt as $k => $t) { + $o[$t] = $xmlInco[$k] ?? null; + } + + return $o; + } + + // * RELATIONSHIPS + + /** + * BelongsTo product relation + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/News.php b/app/Models/News.php index 7c3356f..8b5ebf1 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -6,7 +6,6 @@ use App\Enums\PublishedStatus; use App\Lib\ToolboxHelper; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Kwaadpepper\Enum\Traits\CastsEnums; @@ -85,27 +84,14 @@ class News extends Model } }); static::updated(function (self $model) { - // * Remove unused inline uploaded images - if ($model->content != $model->getOriginal('content')) { - $updatedPicturesPath = collect( - ToolboxHelper::getCkEditorLocalUploadedFiles($model->content) - ); - collect(ToolboxHelper::getCkEditorLocalUploadedFiles($model->getOriginal('content'))) - ->map(function (string $originalPicturePath) use ($updatedPicturesPath) { - // * If a picture is not used anymore - if (!$updatedPicturesPath->contains($originalPicturePath)) { - if (File::exists(\public_path($originalPicturePath))) { - File::delete(\public_path($originalPicturePath)); - } - } - }); - } + ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'content'); if ($model->picture != $model->getOriginal('picture')) { $model->picture = ToolboxHelper::removeOldImage($model, 'picture'); } }); static::deleting(function (self $model) { ToolboxHelper::removeImage($model, 'picture'); + ToolboxHelper::removeImagesOnCkEditor($model, 'content'); }); } diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..74d3031 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,204 @@ + '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' + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + static::creating(function (self $model) { + static::assertTitleIsUnique($model->title); + self::validateLabels($model->labels); + $model->slug = Str::slug($model->title); + ToolboxHelper::assertNoInternalBoUrls('description', $model->description); + ToolboxHelper::assertNoInternalBoUrls('ingredients', $model->ingredients); + ToolboxHelper::assertNoInternalBoUrls('preparation_advices', $model->preparation_advices); + ToolboxHelper::assertNoInternalBoUrls('sales_pitch', $model->sales_pitch); + ToolboxHelper::assertNoInternalBoUrls('allergen_list', $model->allergen_list); + $model->slug = Str::slug($model->title); + $model->description = clean($model->description); + $model->ingredients = clean($model->ingredients); + $model->preparation_advices = clean($model->preparation_advices); + $model->sales_pitch = clean($model->sales_pitch); + $model->allergen_list = clean($model->allergen_list); + $model->picture = ToolboxHelper::storeImage($model, $model->picture); + $model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb); + if (!$model->status) { + $model->publish_at = null; + } else { + $model->unpublish_at = null; + } + }); + static::updating(function (self $model) { + static::assertTitleIsUnique($model->title, $model->id); + self::validateLabels($model->labels); + $model->slug = Str::slug($model->title); + ToolboxHelper::assertNoInternalBoUrls('description', $model->description); + ToolboxHelper::assertNoInternalBoUrls('ingredients', $model->ingredients); + ToolboxHelper::assertNoInternalBoUrls('preparation_advices', $model->preparation_advices); + ToolboxHelper::assertNoInternalBoUrls('sales_pitch', $model->sales_pitch); + ToolboxHelper::assertNoInternalBoUrls('allergen_list', $model->allergen_list); + $model->slug = Str::slug($model->title); + $model->description = clean($model->description); + $model->ingredients = clean($model->ingredients); + $model->preparation_advices = clean($model->preparation_advices); + $model->sales_pitch = clean($model->sales_pitch); + $model->allergen_list = clean($model->allergen_list); + // * Store new picture if changed + if ($model->picture != $model->getOriginal('picture')) { + $model->picture = ToolboxHelper::storeImage($model, $model->picture); + } + // * Store new picture_thumb if changed + if ($model->picture_thumb != $model->getOriginal('picture_thumb')) { + $model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb); + } + }); + static::updated(function (self $model) { + ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'description'); + ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'ingredients'); + ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'preparation_advices'); + ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'sales_pitch'); + ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'allergen_list'); + if ($model->picture != $model->getOriginal('picture')) { + $model->picture = ToolboxHelper::removeOldImage($model, 'picture'); + } + }); + static::deleting(function (self $model) { + ToolboxHelper::removeImage($model, 'picture'); + ToolboxHelper::removeImage($model, 'picture_thumb'); + ToolboxHelper::removeImagesOnCkEditor($model, 'description'); + ToolboxHelper::removeImagesOnCkEditor($model, 'ingredients'); + ToolboxHelper::removeImagesOnCkEditor($model, 'preparation_advices'); + ToolboxHelper::removeImagesOnCkEditor($model, 'sales_pitch'); + ToolboxHelper::removeImagesOnCkEditor($model, 'allergen_list'); + }); + } + + // * RELATIONSHIPS + + /** + * HasOne incoNutritionalValue relation + * + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function incoNutritionalValues() + { + return $this->hasOne(IncoNutritionalValue::class); + } + + /** + * BelongsTo range relation + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function range() + { + return $this->belongsTo(Range::class); + } + + // * METHODS + + /** + * Asserts using validation that the title is unique + * + * @param mixed $title + * @param integer|null $id + * @return void + * @throws \Illuminate\Validation\ValidationException If an actu title already exists. + */ + private static function assertTitleIsUnique($title, ?int $id = null) + { + $table = (new self())->getTable(); + ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id); + ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id); + } + + /** + * + * @param array $labels + * @return void + * @throws \Illuminate\Validation\ValidationException If a label is invalid. + */ + private static function validateLabels(array $labels) + { + collect($labels)->map(function ($label) { + if (!\is_object($label) or !($label instanceof ProductLabelEnum)) { + if ( + !\is_array($label) or + !\array_key_exists('value', $label) or + !\in_array($label['value'], ProductLabelEnum::toValues()) + ) { + throw ValidationException::withMessages([ + 'labels' => 'Invalid label given' + ]); + } + } + }); + } +} diff --git a/app/Models/Range.php b/app/Models/Range.php new file mode 100644 index 0000000..42826d2 --- /dev/null +++ b/app/Models/Range.php @@ -0,0 +1,180 @@ + RangeCategoryEnum::class + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'status' => 'bool', + 'publish_at' => 'datetime', + 'unpublish_at' => 'datetime' + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh + */ + protected static function booted() + { + // phpcs:enable + static::creating(function (self $model) { + static::assertTitleIsUnique($model->title); + ToolboxHelper::assertNoInternalBoUrls('description', $model->description); + $model->slug = Str::slug($model->title); + $model->description = clean($model->description); + $model->picture = ToolboxHelper::storeImage($model, $model->picture); + $model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb); + if (!$model->status) { + $model->publish_at = null; + } else { + $model->unpublish_at = null; + } + if ($model->link1) { + self::validateLinkAttribute('link1', $model->link1); + } + if ($model->link2) { + self::validateLinkAttribute('link2', $model->link2); + } + }); + static::updating(function (self $model) { + static::assertTitleIsUnique($model->title, $model->id); + ToolboxHelper::assertNoInternalBoUrls('description', $model->description); + if ($model->picture != $model->getOriginal('picture')) { + $model->picture = ToolboxHelper::storeImage($model, $model->picture); + } + if ($model->picture_thumb != $model->getOriginal('picture_thumb')) { + $model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb); + } + // * If status changed + if (\boolval($model->getOriginal('status')) !== $model->status) { + if ($model->status) { + $model->publish_at = null; + Session::flash('info', trans('La gamme est à présent publié')); + } else { + $model->unpublish_at = null; + Session::flash('warning', trans('La gamme est à présent dépublié')); + } + } + if ($model->link1) { + self::validateLinkAttribute('link1', $model->link1); + } + if ($model->link2) { + self::validateLinkAttribute('link2', $model->link2); + } + }); + static::updated(function (self $model) { + if ($model->picture != $model->getOriginal('picture')) { + $model->picture = ToolboxHelper::removeOldImage($model, 'picture'); + } + if ($model->picture_v != $model->getOriginal('picture_v')) { + $model->picture_v = ToolboxHelper::removeOldImage($model, 'picture_v'); + } + }); + static::deleting(function (self $model) { + ToolboxHelper::removeImage($model, 'picture'); + ToolboxHelper::removeImage($model, 'picture_v'); + }); + } + + // * METHODS + + /** + * Asserts using validation that the title is unique + * + * @param mixed $title + * @param integer|null $id + * @return void + * @throws \Illuminate\Validation\ValidationException If an actu title already exists. + */ + private static function assertTitleIsUnique($title, ?int $id = null) + { + $table = (new self())->getTable(); + ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id); + ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id); + } + + /** + * Validates an internal link + * + * @param string $attribute + * @param string $value + * @return void + * @throws \Illuminate\Validation\ValidationException If the $attribute link is invalid. + */ + public static function validateLinkAttribute(string $attribute, string $value): void + { + // Validate case internal url. + $params = collect(\explode('#', \ltrim(\rtrim($value, '#'), '#'))); + if ( + !\in_array( + $params->get(0), + [Breeder::class, Recipe::class] + ) + ) { + throw ValidationException::withMessages([ + $attribute => trans('Le lien contient un modèle invalide \':model\'', [ + 'model' => $params->get(0) + ]) + ]); + } + // Check if model id exists. Param 0 is class, 1 is id. + if (!\forward_static_call([$params->get(0), 'find'], $params->get(1))) { + throw ValidationException::withMessages([ + $attribute => trans('Le lien n\'est plus valide.') + ]); + } + } +} diff --git a/app/Models/Recipe.php b/app/Models/Recipe.php new file mode 100644 index 0000000..a4699ec --- /dev/null +++ b/app/Models/Recipe.php @@ -0,0 +1,45 @@ +title); + }); + static::updating(function (self $model) { + static::assertTitleIsUnique($model->title, $model->id); + }); + } + + // * METHODS + + /** + * Asserts using validation that the title is unique + * + * @param mixed $title + * @param integer|null $id + * @return void + * @throws \Illuminate\Validation\ValidationException If an actu title already exists. + */ + private static function assertTitleIsUnique($title, ?int $id = null) + { + $table = (new self())->getTable(); + ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id); + ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 297985d..0da5b87 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -4,6 +4,8 @@ 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; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; @@ -43,6 +45,8 @@ 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) { return News::where(function ($q) use ($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/app/Traits/Models/ChangesModelOrder.php b/app/Traits/Models/ChangesModelOrder.php new file mode 100644 index 0000000..249c43b --- /dev/null +++ b/app/Traits/Models/ChangesModelOrder.php @@ -0,0 +1,39 @@ +direction ? '<' : '>', $model->order) + ->orderBy('order', $request->direction ? 'DESC' : 'ASC')->first(); + if (!$tmp) { + return back()->with('error', trans('L\'ordre n\'a pas pu être modifié')); + } + $newOrder = $tmp->order; + $tmp->order = $model->order; + $model->order = $newOrder; + $tmp->saveOrFail(); + $model->saveOrFail(); + Session::flash('success', trans('L\'ordre à été modifié')); + return back(); + }); + } +} diff --git a/app/Traits/Requests/HasPicture.php b/app/Traits/Requests/HasPicture.php index e83b03d..1a7a9a6 100644 --- a/app/Traits/Requests/HasPicture.php +++ b/app/Traits/Requests/HasPicture.php @@ -37,6 +37,7 @@ trait HasPicture /** * Image rules * + * @param string $name * @param integer|null $minWith * @param integer|null $minHeight * @param integer|null $maxWith @@ -44,20 +45,23 @@ trait HasPicture * @return array */ public function pictureRules( + string $name = 'picture', ?int $minWith = null, ?int $minHeight = null, ?int $maxWith = null, ?int $maxHeight = null ): array { - $this->minWith = $minWith ?? $this->minWith; - $this->minHeight = $minHeight ?? $this->minHeight; - $this->maxWith = $maxWith ?? $this->maxWith; - $this->maxHeight = $maxHeight ?? $this->maxHeight; + $sizes = (object)[ + 'minWith' => $minWith ?? $this->minWith, + 'minHeight' => $minHeight ?? $this->minHeight, + 'maxWith' => $maxWith ?? $this->maxWith, + 'maxHeight' => $maxHeight ?? $this->maxHeight + ]; return [ - 'picture' => [ + $name => [ 'required', - function (string $attribute, $value, callable $fail) { - return $this->validatePicture($attribute, $value, $fail); + function (string $attribute, $value, callable $fail) use ($sizes) { + return $this->validatePicture($attribute, $value, $fail, $sizes); } ] ]; @@ -66,6 +70,7 @@ trait HasPicture /** * Images rules * + * @param string $name * @param integer|null $minWith * @param integer|null $minHeight * @param integer|null $maxWith @@ -73,22 +78,25 @@ trait HasPicture * @return array */ public function picturesRules( + string $name = 'pictures', ?int $minWith = null, ?int $minHeight = null, ?int $maxWith = null, ?int $maxHeight = null ): array { - $this->minWith = $minWith ?? $this->minWith; - $this->minHeight = $minHeight ?? $this->minHeight; - $this->maxWith = $maxWith ?? $this->maxWith; - $this->maxHeight = $maxHeight ?? $this->maxHeight; + $sizes = (object)[ + 'minWith' => $minWith ?? $this->minWith, + 'minHeight' => $minHeight ?? $this->minHeight, + 'maxWith' => $maxWith ?? $this->maxWith, + 'maxHeight' => $maxHeight ?? $this->maxHeight + ]; return [ - 'pictures' => 'sometimes|array', - 'pictureValidate' => 'required_with:pictures|array', - 'pictureValidate.*' => [ + $name => 'sometimes|array', + "{$name}Validate" => "required_with:{$name}|array", + "{$name}Validate.*" => [ 'required', - function (string $attribute, $value, callable $fail) { - return $this->validatePicture($attribute, $value, $fail); + function (string $attribute, $value, callable $fail) use ($sizes) { + return $this->validatePicture($attribute, $value, $fail, $sizes); } ] ]; @@ -100,10 +108,11 @@ trait HasPicture * @param string $attribute * @param mixed $value * @param callback $fail + * @param object $sizes * @return void * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed */ - private function validatePicture(string $attribute, $value, callable $fail) + private function validatePicture(string $attribute, $value, callable $fail, object $sizes) { if (!File::exists($value)) { $fail(trans(":attribute n'existe pas dans le système de fichier")); @@ -119,22 +128,22 @@ trait HasPicture $height = intval($dimensions[1]); $xMessage = trans( - ":attribute la largeur doit être comprise entre {$this->minWith}px et {$this->maxWith}px" + ":attribute la largeur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px" ); - if ($this->minWith === $this->maxWith) { - $xMessage = trans(":attribute la largeur doit être de {$this->minWith}px"); + if ($sizes->minWith === $sizes->maxWith) { + $xMessage = trans(":attribute la largeur doit être de {$sizes->minWith}px"); } $yMessage = trans( - ":attribute la hauteur doit être comprise entre {$this->minWith}px et {$this->maxWith}px" + ":attribute la hauteur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px" ); - if ($this->minHeight === $this->maxHeight) { - $yMessage = trans(":attribute la hauteur doit être de {$this->minWith}px"); + if ($sizes->minHeight === $sizes->maxHeight) { + $yMessage = trans(":attribute la hauteur doit être de {$sizes->minWith}px"); } - if (($width < $this->minWith) or ($width > $this->maxWith)) { + if (($width < $sizes->minWith) or ($width > $sizes->maxWith)) { $fail($xMessage); } - if (($height < $this->minHeight) or ($height > $this->maxHeight)) { + if (($height < $sizes->minHeight) or ($height > $sizes->maxHeight)) { $fail($yMessage); } } //end if @@ -143,29 +152,30 @@ trait HasPicture /** * Merges products pictures fields with request data * + * @param string $name * @return void */ - protected function mergePicture(): void + protected function mergePicture(string $name = 'picture'): void { - $picturePath = $this->picture; + $picturePath = $this->{$name}; if (is_string($picturePath)) { - $this->merge(['pictureValidate' => []]); + $this->merge(["{$name}Validate" => []]); // ! Empty picture. if ($picturePath === 'false') { // * Empty picture and exit - $this->merge(['pictures' => []]); + $this->merge([$name => []]); return; } $decodedPath = \urldecode($picturePath); $this->merge([ - 'pictureValidate' => array_merge( - $this->pictureValidate, + "{$name}Validate" => array_merge( + $this->{"{$name}Validate"}, [\public_path(ltrim($decodedPath, '/'))] ) ]); $changedPicture = ltrim($decodedPath, '/'); $this->merge([ - 'picture' => $changedPicture + $name => $changedPicture ]); } //end if } @@ -173,31 +183,32 @@ trait HasPicture /** * Merges products pictures fields with request data * + * @param string $name * @return void */ - protected function mergePictures(): void + protected function mergePictures(string $name = 'pictures'): void { - $pictures = $this->pictures; + $pictures = $this->{$name}; if (is_array($pictures)) { - $this->merge(['pictureValidate' => []]); + $this->merge(["{$name}Validate" => []]); foreach ($pictures as $key => $picturePath) { // ! Empty picture. if ($picturePath === 'false') { // * Empty picture and exit - $this->merge(['pictures' => []]); + $this->merge([$name => []]); return; } $decodedPath = \urldecode($picturePath); $this->merge([ - 'pictureValidate' => array_merge( - $this->pictureValidate, + "{$name}Validate" => array_merge( + $this->{"{$name}Validate"}, [\public_path(ltrim($decodedPath, '/'))] ) ]); - $changedPicture = $this->pictures; + $changedPicture = $this->{$name}; $changedPicture[$key] = ltrim($decodedPath, '/'); $this->merge([ - 'pictures' => $changedPicture + $name => $changedPicture ]); } } //end if diff --git a/composer.json b/composer.json index 9b94e5e..9f856f9 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "@php artisan package:discover --ansi" ], "post-update-cmd": [ - "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + "@php artisan vendor:publish --tag=laravel-assets --ansi --force", + "local-php-security-checker" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" diff --git a/composer.lock b/composer.lock index 75cd379..dc9dda0 100644 --- a/composer.lock +++ b/composer.lock @@ -2569,16 +2569,16 @@ }, { "name": "nesbot/carbon", - "version": "2.58.0", + "version": "2.59.1", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "97a34af22bde8d0ac20ab34b29d7bfe360902055" + "reference": "a9000603ea337c8df16cc41f8b6be95a65f4d0f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/97a34af22bde8d0ac20ab34b29d7bfe360902055", - "reference": "97a34af22bde8d0ac20ab34b29d7bfe360902055", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/a9000603ea337c8df16cc41f8b6be95a65f4d0f5", + "reference": "a9000603ea337c8df16cc41f8b6be95a65f4d0f5", "shasum": "" }, "require": { @@ -2593,11 +2593,12 @@ "doctrine/orm": "^2.7", "friendsofphp/php-cs-fixer": "^3.0", "kylekatarnls/multi-tester": "^2.0", + "ondrejmirtes/better-reflection": "*", "phpmd/phpmd": "^2.9", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.54 || ^1.0", - "phpunit/php-file-iterator": "^2.0.5", - "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "phpstan/phpstan": "^0.12.99 || ^1.7.14", + "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", + "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", "squizlabs/php_codesniffer": "^3.4" }, "bin": [ @@ -2654,15 +2655,19 @@ }, "funding": [ { - "url": "https://opencollective.com/Carbon", - "type": "open_collective" + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", "type": "tidelift" } ], - "time": "2022-04-25T19:31:17+00:00" + "time": "2022-06-29T21:43:55+00:00" }, { "name": "nikic/php-parser", @@ -3266,16 +3271,16 @@ }, { "name": "psy/psysh", - "version": "v0.11.5", + "version": "v0.11.7", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "c23686f9c48ca202710dbb967df8385a952a2daf" + "reference": "77fc7270031fbc28f9a7bea31385da5c4855cb7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/c23686f9c48ca202710dbb967df8385a952a2daf", - "reference": "c23686f9c48ca202710dbb967df8385a952a2daf", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/77fc7270031fbc28f9a7bea31385da5c4855cb7a", + "reference": "77fc7270031fbc28f9a7bea31385da5c4855cb7a", "shasum": "" }, "require": { @@ -3336,9 +3341,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.11.5" + "source": "https://github.com/bobthecow/psysh/tree/v0.11.7" }, - "time": "2022-05-27T18:03:49+00:00" + "time": "2022-07-07T13:49:11+00:00" }, { "name": "ralouphie/getallheaders", @@ -6382,16 +6387,16 @@ }, { "name": "facade/ignition", - "version": "2.17.5", + "version": "2.17.6", "source": { "type": "git", "url": "https://github.com/facade/ignition.git", - "reference": "1d71996f83c9a5a7807331b8986ac890352b7a0c" + "reference": "6acd82e986a2ecee89e2e68adfc30a1936d1ab7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/facade/ignition/zipball/1d71996f83c9a5a7807331b8986ac890352b7a0c", - "reference": "1d71996f83c9a5a7807331b8986ac890352b7a0c", + "url": "https://api.github.com/repos/facade/ignition/zipball/6acd82e986a2ecee89e2e68adfc30a1936d1ab7c", + "reference": "6acd82e986a2ecee89e2e68adfc30a1936d1ab7c", "shasum": "" }, "require": { @@ -6456,7 +6461,7 @@ "issues": "https://github.com/facade/ignition/issues", "source": "https://github.com/facade/ignition" }, - "time": "2022-02-23T18:31:24+00:00" + "time": "2022-06-30T18:26:59+00:00" }, { "name": "fakerphp/faker", diff --git a/database/factories/BreederFactory.php b/database/factories/BreederFactory.php new file mode 100644 index 0000000..50f2bb7 --- /dev/null +++ b/database/factories/BreederFactory.php @@ -0,0 +1,29 @@ + $this->faker->firstName(), + 'lastname' => $this->faker->lastName(), + ]; + } +} diff --git a/database/factories/IncoNutritionalValueFactory.php b/database/factories/IncoNutritionalValueFactory.php new file mode 100644 index 0000000..522878b --- /dev/null +++ b/database/factories/IncoNutritionalValueFactory.php @@ -0,0 +1,50 @@ + $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) ? + null : $this->faker->numberBetween(964 - 150, 964 + 150), + "protein" => $this->faker->boolean(10) ? + null : $this->faker->numberBetween(9.2 - 3, 9.2 + 15), + "carbohydrates" => $this->faker->boolean(10) ? + null : $this->faker->numberBetween(20 - 10, 20 + 25), + "sugar" => $this->faker->boolean(10) ? + null : $this->faker->numberBetween(1.5 - 1, 1.5 + 6), + "fat" => $this->faker->boolean(10) ? + null : $this->faker->numberBetween(12 - 6, 12 + 6), + "saturated_fatty_acids" => $this->faker->boolean(10) ? + null : $this->faker->numberBetween(3.4 - 2, 3.4 + 6), + "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) + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..a1033cb --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,53 @@ +faker->unique()->words(\mt_rand(3, 4), true); + return [ + 'title' => $title, + 'slug' => \Illuminate\Support\Str::slug($title), + 'seo_title' => $this->faker->words(8, true), + 'seo_description' => $this->faker->words(25, true), + 'description' => $this->faker->words(25, true), + '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/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(), + 'ingredients' => $this->faker->words(8, true), + 'preparation_advices' => $this->faker->text(\mt_rand(15, 150)), + '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), + '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/factories/RangeFactory.php b/database/factories/RangeFactory.php new file mode 100644 index 0000000..5337ddd --- /dev/null +++ b/database/factories/RangeFactory.php @@ -0,0 +1,43 @@ +faker->unique()->words(rand(6, 12), true); + return [ + 'title' => $title, + 'slug' => Str::slug($title), + 'seo_title' => $this->faker->words(8, true), + 'seo_description' => $this->faker->words(25, true), + 'description' => $this->faker->words(25, true), + 'category' => collect(RangeCategoryEnum::toArray())->random(1)->get(0), + 'picture' => '/assets/images/ranges/range_fake_700_700.jpg', + 'picture_title' => $this->faker->words(8, true), + 'picture_alt' => $this->faker->words(8, true), + 'picture_thumb' => '/assets/images/ranges/range_fake_thumb_300_300.jpg', + 'picture_thumb_title' => $this->faker->words(8, true), + 'picture_thumb_alt' => $this->faker->words(8, true), + 'status' => $this->faker->boolean(75), + ]; + } +} diff --git a/database/factories/RecipeFactory.php b/database/factories/RecipeFactory.php new file mode 100644 index 0000000..064a189 --- /dev/null +++ b/database/factories/RecipeFactory.php @@ -0,0 +1,31 @@ +faker->unique()->words(rand(2, 4), true); + return [ + 'title' => $title, + 'slug' => Str::slug($title) + ]; + } +} diff --git a/database/migrations/2022_07_12_113538_create_breeders_table.php b/database/migrations/2022_07_12_113538_create_breeders_table.php new file mode 100644 index 0000000..1a56018 --- /dev/null +++ b/database/migrations/2022_07_12_113538_create_breeders_table.php @@ -0,0 +1,35 @@ +id(); + + $table->string('firstname')->comment('The breeder firstname'); + $table->string('lastname')->comment('The breeder lastname'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('breeders'); + } +} diff --git a/database/migrations/2022_07_12_113555_create_recipes_table.php b/database/migrations/2022_07_12_113555_create_recipes_table.php new file mode 100644 index 0000000..5a2c9d6 --- /dev/null +++ b/database/migrations/2022_07_12_113555_create_recipes_table.php @@ -0,0 +1,35 @@ +id(); + + $table->string('title')->comment('Nom de la recette'); + $table->string('slug')->comment('slug de la recette'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('recipes'); + } +} diff --git a/database/migrations/2022_07_12_164017_create_ranges_table.php b/database/migrations/2022_07_12_164017_create_ranges_table.php new file mode 100644 index 0000000..ad8c5b5 --- /dev/null +++ b/database/migrations/2022_07_12_164017_create_ranges_table.php @@ -0,0 +1,64 @@ +id(); + + $table->string('title')->comment('Nom de la gamme'); + $table->string('slug')->comment('slug de la gamme'); + + $table->string('seo_title') + ->comment('Utilisé pour la balise title de la gamme. De 53 à 60 chars recommandés.'); + $table->string('seo_description') + ->comment('Utilisé pour la balise méta description de la gamme. De 50 à 160 chars recommandés.'); + + $table->text('description')->comment('Description de la gamme'); + + $table->unsignedTinyInteger('category')->comment('Enum Ranges\\RangeCategoryEnum'); + + $table->string('picture')->nullable()->comment('L\'illustration de la gamme 700x700 px.'); + $table->string('picture_title')->nullable()->comment('Attribut title de l\'image.'); + $table->string('picture_alt')->nullable()->comment('Attribut alt de l\'image.'); + + $table->string('picture_thumb')->nullable()->comment('La vignette de la gamme 300x300 px.'); + $table->string('picture_thumb_title')->nullable()->comment('Attribut title de l\'image.'); + $table->string('picture_thumb_alt')->nullable()->comment('Attribut alt de l\'image.'); + + $table->boolean('status')->comment('La gamme est visible ou non'); + + $table->timestamp('publish_at')->nullable() + ->comment('The date where the category status state should be set on.'); + $table->timestamp('unpublish_at')->nullable() + ->comment('The date where the category status state should be set off.'); + + $table->string('link1')->nullable() + ->comment('Category Link 1 #App\Models\Model#ID#, Sould be validated on store and update'); + $table->string('link2')->nullable() + ->comment('Category Link 2 #App\Models\Model#ID#, Sould be validated on store and update'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('ranges'); + } +} diff --git a/database/migrations/2022_07_12_164018_create_products_table.php b/database/migrations/2022_07_12_164018_create_products_table.php new file mode 100644 index 0000000..2ab24e3 --- /dev/null +++ b/database/migrations/2022_07_12_164018_create_products_table.php @@ -0,0 +1,75 @@ +id(); + + $table->string('title')->comment('Nom du produit'); + $table->string('slug')->comment('slug du produit'); + + $table->string('seo_title') + ->comment('Utilisé pour la balise title de la page. De 53 à 60 chars recommandés.'); + $table->string('seo_description') + ->comment('Utilisé pour la balise méta description de la page. De 50 à 160 chars recommandés.'); + + $table->text('description')->comment('Description du produit'); + + $table->string('picture')->nullable()->comment('L\'illustration du produit 700x700 px.'); + $table->string('picture_title')->nullable()->comment('Attribut title de l\'image.'); + $table->string('picture_alt')->nullable()->comment('Attribut alt de l\'image.'); + + $table->string('picture_thumb')->nullable()->comment('La vignette du produit 300x300 px.'); + $table->string('picture_thumb_title')->nullable()->comment('Attribut title de l\'image.'); + $table->string('picture_thumb_alt')->nullable()->comment('Attribut alt de l\'image.'); + + $table->json('labels')->comment('List of Products\\ProductLabelEnum'); + + $table->text('ingredients')->comment('Ingredients'); + $table->text('preparation_advices')->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() + ->comment('The date where the product status state should be set on.'); + $table->timestamp('unpublish_at')->nullable() + ->comment('The date where the product status state should be set off.'); + + $table->foreignIdFor(Range::class)->constrained(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('products'); + } +} 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 new file mode 100644 index 0000000..fad6627 --- /dev/null +++ b/database/migrations/2022_12_07_135414_create_inco_nutritional_values_table.php @@ -0,0 +1,47 @@ +id(); + + $table->double('weight')->nullable(); + $table->double('energy')->nullable(); + $table->double('energy_kj')->nullable(); + $table->double('protein')->nullable(); + $table->double('carbohydrates')->nullable(); + $table->double('sugar')->nullable(); + $table->double('fat')->nullable(); + $table->double('saturated_fatty_acids')->nullable(); + $table->double('dietary_fiber')->nullable(); + $table->double('salt')->nullable(); + + $table->foreignId('product_id'); + $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('inco_nutritional_values'); + } +} diff --git a/database/seeders/BreederSeeder.php b/database/seeders/BreederSeeder.php new file mode 100644 index 0000000..8d27ca5 --- /dev/null +++ b/database/seeders/BreederSeeder.php @@ -0,0 +1,21 @@ +truncate(); + Breeder::factory(150)->create(); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0d7afa5..8877448 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,11 @@ class DatabaseSeeder extends Seeder { $this->call([ NewsSeeder::class, - SliderSeeder::class + SliderSeeder::class, + BreederSeeder::class, + RangeSeeder::class, + RecipeSeeder::class, + ProductSeeder::class, ]); } } diff --git a/database/seeders/IncoNutritionalValueSeeder.php b/database/seeders/IncoNutritionalValueSeeder.php new file mode 100644 index 0000000..41df5fc --- /dev/null +++ b/database/seeders/IncoNutritionalValueSeeder.php @@ -0,0 +1,21 @@ +truncate(); + IncoNutritionalValue::factory(150)->create(); + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 0000000..16cb45b --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,38 @@ +truncate(); + Product::all()->map(function (Product $product) { + $product->deleteOrFail(); + }); + $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 */ + $incValue = IncoNutritionalValue::factory(1)->make()->get(0); + $incValue->product()->associate($product); + $incValue->saveOrFail(); + } + }); + } +} diff --git a/database/seeders/RangeSeeder.php b/database/seeders/RangeSeeder.php new file mode 100644 index 0000000..9f6317a --- /dev/null +++ b/database/seeders/RangeSeeder.php @@ -0,0 +1,22 @@ +map(function (Range $range) { + $range->deleteOrFail(); + }); + Range::factory(8)->create(); + } +} diff --git a/database/seeders/RecipeSeeder.php b/database/seeders/RecipeSeeder.php new file mode 100644 index 0000000..1b59357 --- /dev/null +++ b/database/seeders/RecipeSeeder.php @@ -0,0 +1,20 @@ +create(); + } +} diff --git a/package-lock.json b/package-lock.json index 2cd6566..8ec7028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "srcs", + "name": "loue", "lockfileVersion": 2, "requires": true, "packages": { @@ -12,6 +12,7 @@ "@types/bootstrap": "^5.1.12", "@types/cropperjs": "^1.3.0", "@types/toastify-js": "^1.11.1", + "@types/vue-select": "^3.16.0", "@types/webpack-env": "^1.17.0", "axios": "^0.21", "bootstrap": "^5.1.3", @@ -30,7 +31,8 @@ "ts-loader": "^9.3.0", "typescript": "^4.7.3", "vue": "^3.2.36", - "vue-loader": "^16.8.3" + "vue-loader": "^16.8.3", + "vue-select": "^4.0.0-beta.3" } }, "node_modules/@ampproject/remapping": { @@ -2253,6 +2255,42 @@ "integrity": "sha512-Ef03kGFWseAQYIQwN83WbhRxD+DOd+X6p22j9olA/TnvE0crDMc3fyoctKSpXgEDVWq5l3p98otIdpNX1pOYMA==", "dev": true }, + "node_modules/@types/vue-select": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@types/vue-select/-/vue-select-3.16.0.tgz", + "integrity": "sha512-eUNzHK8tY7fT6fWR3mcN3kq9BznRrFPFvmpBZ66DAz4k9aD27AwT54gvR58O9P+ql7/iC0yVvl5lUTjKZ7K1tA==", + "dev": true, + "dependencies": { + "vue": "^2.0.0" + } + }, + "node_modules/@types/vue-select/node_modules/@vue/compiler-sfc": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.4.tgz", + "integrity": "sha512-WCaF33mlKLSvHDKvOD6FzTa5CI2FlMTeJf3MxJsNP0KDgRoI6RdXhHo9dtvCqV4Sywf9Owm17wTLT1Ymu/WsOQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.18.4", + "postcss": "^8.4.14", + "source-map": "^0.6.1" + } + }, + "node_modules/@types/vue-select/node_modules/csstype": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", + "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", + "dev": true + }, + "node_modules/@types/vue-select/node_modules/vue": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.4.tgz", + "integrity": "sha512-8KGyyzFSj/FrKj1y7jyEpv8J4osgZx6Lk1lVzh1aP4BqsXZhATH1r0gdJNz00MMyBhK0/m2cNoPuOZ1NzeiUEw==", + "dev": true, + "dependencies": { + "@vue/compiler-sfc": "2.7.4", + "csstype": "^3.1.0" + } + }, "node_modules/@types/webpack-env": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.17.0.tgz", @@ -9059,6 +9097,15 @@ "webpack": "^4.1.0 || ^5.0.0-0" } }, + "node_modules/vue-select": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/vue-select/-/vue-select-4.0.0-beta.3.tgz", + "integrity": "sha512-sXufbTDluz26a39UUZE9jeh9Hojnn8zr9qDDUlVEBc7E0F+h3+Nzy8YzyFHuVNrrYLmK0zrPkb7hWFulAEW41Q==", + "dev": true, + "peerDependencies": { + "vue": "3.x" + } + }, "node_modules/vue-style-loader": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", @@ -11316,6 +11363,44 @@ "integrity": "sha512-Ef03kGFWseAQYIQwN83WbhRxD+DOd+X6p22j9olA/TnvE0crDMc3fyoctKSpXgEDVWq5l3p98otIdpNX1pOYMA==", "dev": true }, + "@types/vue-select": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@types/vue-select/-/vue-select-3.16.0.tgz", + "integrity": "sha512-eUNzHK8tY7fT6fWR3mcN3kq9BznRrFPFvmpBZ66DAz4k9aD27AwT54gvR58O9P+ql7/iC0yVvl5lUTjKZ7K1tA==", + "dev": true, + "requires": { + "vue": "^2.0.0" + }, + "dependencies": { + "@vue/compiler-sfc": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.4.tgz", + "integrity": "sha512-WCaF33mlKLSvHDKvOD6FzTa5CI2FlMTeJf3MxJsNP0KDgRoI6RdXhHo9dtvCqV4Sywf9Owm17wTLT1Ymu/WsOQ==", + "dev": true, + "requires": { + "@babel/parser": "^7.18.4", + "postcss": "^8.4.14", + "source-map": "^0.6.1" + } + }, + "csstype": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", + "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", + "dev": true + }, + "vue": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.4.tgz", + "integrity": "sha512-8KGyyzFSj/FrKj1y7jyEpv8J4osgZx6Lk1lVzh1aP4BqsXZhATH1r0gdJNz00MMyBhK0/m2cNoPuOZ1NzeiUEw==", + "dev": true, + "requires": { + "@vue/compiler-sfc": "2.7.4", + "csstype": "^3.1.0" + } + } + } + }, "@types/webpack-env": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.17.0.tgz", @@ -16483,6 +16568,13 @@ "loader-utils": "^2.0.0" } }, + "vue-select": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/vue-select/-/vue-select-4.0.0-beta.3.tgz", + "integrity": "sha512-sXufbTDluz26a39UUZE9jeh9Hojnn8zr9qDDUlVEBc7E0F+h3+Nzy8YzyFHuVNrrYLmK0zrPkb7hWFulAEW41Q==", + "dev": true, + "requires": {} + }, "vue-style-loader": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", diff --git a/package.json b/package.json index e48c64b..79c78f1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/bootstrap": "^5.1.12", "@types/cropperjs": "^1.3.0", "@types/toastify-js": "^1.11.1", + "@types/vue-select": "^3.16.0", "@types/webpack-env": "^1.17.0", "axios": "^0.21", "bootstrap": "^5.1.3", @@ -35,6 +36,7 @@ "ts-loader": "^9.3.0", "typescript": "^4.7.3", "vue": "^3.2.36", - "vue-loader": "^16.8.3" + "vue-loader": "^16.8.3", + "vue-select": "^4.0.0-beta.3" } } diff --git a/resources/assets/images/products/product_fake_700_700.jpg b/resources/assets/images/products/product_fake_700_700.jpg new file mode 100644 index 0000000..027ab3f Binary files /dev/null and b/resources/assets/images/products/product_fake_700_700.jpg differ diff --git a/resources/assets/images/products/product_fake_thumb_300_300.jpg b/resources/assets/images/products/product_fake_thumb_300_300.jpg new file mode 100644 index 0000000..bb0f5ca Binary files /dev/null and b/resources/assets/images/products/product_fake_thumb_300_300.jpg differ diff --git a/resources/assets/images/ranges/range_fake_700_700.jpg b/resources/assets/images/ranges/range_fake_700_700.jpg new file mode 100644 index 0000000..027ab3f Binary files /dev/null and b/resources/assets/images/ranges/range_fake_700_700.jpg differ diff --git a/resources/assets/images/ranges/range_fake_thumb_300_300.jpg b/resources/assets/images/ranges/range_fake_thumb_300_300.jpg new file mode 100644 index 0000000..bb0f5ca Binary files /dev/null and b/resources/assets/images/ranges/range_fake_thumb_300_300.jpg differ diff --git a/resources/lang/en/models.php b/resources/lang/en/models.php index 380fc98..27c7387 100644 --- a/resources/lang/en/models.php +++ b/resources/lang/en/models.php @@ -5,5 +5,9 @@ return [ 'news' => 'News', 'tags' => 'Tags', 'sliders' => 'Sliders', + 'ranges' => 'Ranges', + 'products' => 'Products', + 'recipes' => 'Recipes', + 'breeders' => 'Breeders', ] ]; diff --git a/resources/lang/fr/models.php b/resources/lang/fr/models.php index f183eb0..d174154 100644 --- a/resources/lang/fr/models.php +++ b/resources/lang/fr/models.php @@ -5,5 +5,9 @@ return [ 'news' => 'Actualités', 'tags' => 'Tags', 'sliders' => 'Sliders', + 'ranges' => 'Gammes', + 'products' => 'Produits', + 'recipes' => 'Recettes', + 'breeders' => 'Éleveurs', ] ]; 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/components/bo/ModelLinkComponent.vue b/resources/ts/components/bo/ModelLinkComponent.vue new file mode 100644 index 0000000..9e718d2 --- /dev/null +++ b/resources/ts/components/bo/ModelLinkComponent.vue @@ -0,0 +1,273 @@ + + + + + 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/index.d.ts b/resources/ts/index.d.ts index 640ff80..abd2a6c 100644 --- a/resources/ts/index.d.ts +++ b/resources/ts/index.d.ts @@ -1,7 +1,8 @@ export {}; import { AxiosStatic } from "axios"; -import type VueTagsInput from "vue3-tags-input"; +import type VueTagsInput from "@sipec/vue3-tags-input"; +import type { VueSelectProps } from "vue-select"; // * Recursive List type StringList = Record; @@ -34,6 +35,8 @@ declare global { type LaravelModel = Model; type LaravelModelList = ModelList; + type VSelectProps = VueSelectProps; + interface Window { $displayMenu: (show: boolean) => void; $toggleBasketMenu: () => void; 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/layouts/nav-menu.blade.php b/resources/views/back/layouts/nav-menu.blade.php index 1cba057..93f64f0 100644 --- a/resources/views/back/layouts/nav-menu.blade.php +++ b/resources/views/back/layouts/nav-menu.blade.php @@ -15,5 +15,15 @@ 'model' => Str::singular(__('models.classes.sliders')) ]) }}" data-bs-toggle="tooltip">{{ __('models.classes.sliders') }} + + @endauth diff --git a/resources/views/back/modules/scriptConstants.blade.php b/resources/views/back/modules/scriptConstants.blade.php index 38df5fe..d8ddb33 100644 --- a/resources/views/back/modules/scriptConstants.blade.php +++ b/resources/views/back/modules/scriptConstants.blade.php @@ -15,6 +15,10 @@ tags: { store: '{{ route('bo.tags.store') }}', jsonStore: '{{ route('bo.tags.jsonStore') }}' - } + }, + ranges: { + 'model-title': '{{ route('bo.ranges.model-title') }}', + 'search-uri': '{{ route('bo.ranges.search-uri') }}', + }, }; 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..9ae214b --- /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épublier') : __('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/resources/views/back/ranges/create.blade.php b/resources/views/back/ranges/create.blade.php new file mode 100644 index 0000000..8fe20a8 --- /dev/null +++ b/resources/views/back/ranges/create.blade.php @@ -0,0 +1,30 @@ +@extends('layouts.back') + +@section('title', sprintf('%s - %s', __('models.classes.ranges'), Str::ucfirst(__('création')))) + +@section('breadcrumb', request()->route()->getName()) + +@section('content') + <div class="card"> + <div class="card-header"> + <div class="row"> + <div class="col"> + @include('back.breadcrumb.breadcrumb-body') + </div> + </div> + <div class="row"> + <h1 class="col h4"> + <a href="{{ route('bo.ranges.index') }}" class="text-decoration-none btn btn-sm btn-primary" role="button" aria-roledescription="{{ _('Revenir en arrière') }}"><i class="fa-solid fa-arrow-left-long"></i></a> + <span> {{ __('models.classes.ranges') }} {{ __('création') }}</span> + </h1> + </div> + </div> + <div class="card-body"> + <form action="{{ route('bo.ranges.store') }}" method="POST" enctype="multipart/form-data"> + @csrf + @include('back.ranges.form') + <button type="submit" class="btn btn-primary">{{ __('Enregister') }}</button> + </form> + </div> + </div> +@endsection diff --git a/resources/views/back/ranges/edit.blade.php b/resources/views/back/ranges/edit.blade.php new file mode 100644 index 0000000..a9935ad --- /dev/null +++ b/resources/views/back/ranges/edit.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.back') + +@section('title', sprintf('%s - %s %s', __('models.classes.ranges'), Str::ucfirst(__('édition')), $rangeModel->title)) + +@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', [ + 'brParam' => $rangeModel + ]) + </div> + </div> + <div class="row"> + <div class="col"> + <h1 class="col h4"> + <a href="{{ route('bo.ranges.index') }}" class="text-decoration-none btn btn-sm btn-primary" role="button" aria-roledescription="{{ _('Revenir en arrière') }}"><i class="fa-solid fa-arrow-left-long"></i></a> + <span> {{ __('models.classes.ranges') }} {{ __('édition') }}</span> + </h1> + </div> + <div class="col text-end"> + <form action="{{ route('bo.ranges.destroy', $rangeModel->id) }}" method="POST" class="d-inline confirmDeleteJS"> + @method('DELETE') + @csrf + <div class="btn-group" role="group"> + {{-- Public View Page --}} + @if($rangeModel->status) + <a class="btn btn-warning" href="" target="_blank" title="{{ __('Visualiser :model', [ + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-eye"></i></a> + @endif + <button class="btn btn-danger" title="{{ __('Supprimer :model', [ + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-trash"></i></button> + </div> + </form> + </div> + </div> + </div> + <div class="card-body"> + <form action="{{ route('bo.ranges.update', $rangeModel->id) }}" method="POST" enctype="multipart/form-data"> + @csrf + @method('PUT') + @include('back.ranges.form', compact('rangeModel')) + <button type="submit" class="btn btn-primary" title="{{ __('Enregister :model en base', [ + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" data-bs-toggle="tooltip">{{ __('Enregister') }}</button> + </form> + </div> + </div> +@endsection diff --git a/resources/views/back/ranges/form.blade.php b/resources/views/back/ranges/form.blade.php new file mode 100644 index 0000000..dd9d1d6 --- /dev/null +++ b/resources/views/back/ranges/form.blade.php @@ -0,0 +1,327 @@ +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Informations générales') }}</legend> + <div class="row mb-3"> + <div class="col-12 col-md-6 form-group"> + <label for="rangeModelTitle" class="col-form-label"><b>{{ __('Titre') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'rangeModelTitle'])'></div> + <input name="title" type="text" value="{{ old('title', $rangeModel->title ?? '') }}" class="form-control" + id="rangeModelTitle" aria-describedby="rangeModelTitleHelp" placeholder="{{ __('Un titre beau et explicite') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'title', 'helperId' => 'rangeModelTitleHelp' , 'helper' => __('Doit décrire précisément la gamme') + ]) + @include('back.modules.input-error', [ + 'inputName' => 'slug' + ]) + </div> + <div class="col-12 col-md-6 form-group"> + <label for="rangeCategory" class="col-form-label"><b>{{ __('Catégorie de la gamme') }}</b></label> + <select id="rangeCategory" class="form-select" name="category" aria-describedby="rangeCategoryHelp" required> + <option value="" @if(is_null(old('category', $rangeModel->category ?? null))) selected @endif disabled>{{ __('Séléctionnez une catégorie') }}</option> + @foreach(App\Enums\Ranges\RangeCategoryEnum::toArray() as $category) + <option value="{{ $category->value }}" @if(old('type', optional($rangeModel->category ?? null)->value) == $category->value) selected @endif>{{ $category->label }}</option> + @endforeach + </select> + @include('back.modules.input-error', [ + 'inputName' => 'category', 'helperId' => 'rangeCategoryHelp' , 'helper' => __('Le type de gamme') + ]) + </div> + </div> + </fieldset> + </div> +</div> + +<div class="row"> + <div class="col"> + <fieldset class="border p-3 mb-3"> + <legend>{{ __('Seo') }}</legend> + <div class="row mb-3"> + <div class="col-12 col-md-6 form-group"> + <label for="rangeModelSeoTitle" class="col-form-label"><b>{{ __('Titre seo') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'rangeModelSeoTitle'])'></div> + <input name="seo_title" type="text" value="{{ old('seo_title', $rangeModel->seo_title ?? '') }}" class="form-control" + id="rangeModelSeoTitle" aria-describedby="rangeModelSeoTitleHelp" placeholder="{{ __('Un titre en 70 caractères maximum') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'seo_title', 'helperId' => 'rangeModelSeoTitleHelp' , 'helper' => __('Doit décrire précisément la gamme, recommandation pour un positionnement optimal dans les moteurs de recherche : entre 53 et 60 caractères, balise <title> ') + ]) + </div> + <div class="col-12 col-md-6 form-group"> + <label for="rangeModelSeoDescription" class="col-form-label"><b>{{ __('Description seo') }}</b></label> + @php + $data = [ + 'id' => 'rangeModelSeoDescription', + 'class' => 'form-control', + 'name' => 'seo_description', + 'placeholder' => __('Une description de la gamme pour les moteurs de recherche'), + 'ariaDescribedby' => 'rangeModelSeoDescriptionHelp', + 'required' => true, + 'value' => old('seo_description', $rangeModel->seo_description ?? '') + ]; + @endphp + <div id="input-text-auto" data-json='@json($data)'></div> + @include('back.modules.input-error', [ + 'inputName' => 'seo_description', 'helperId' => 'rangeModelSeoDescriptionHelp' , 'helper' => __(' Doit décrire précisément la gamme, 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>{{ __('Publication') }}</legend> + <div class="row mb-3"> + <div class="col"> + <div class="form-group"> + <label for="rangePublishedAt"><b>{{ __('Date de publication (Europe/Paris)') }}</b></label> + <input id="rangePublishedAt" name="publish_at" class="form-control publishTime" type="datetime-local" data-form-type="date" aria-describedby="rangePublishAtHelp" value="{{ old('publish_at', ((isset($rangeModel) and $rangeModel->publish_at) ? $rangeModel->publish_at->format('Y-m-d\TH:i') : '')) }}"> + @include('back.modules.input-error', [ + 'inputName' => 'publish_at', 'helperId' => 'rangePublishAtHelp' , 'helper' => (isset($rangeModel) and $rangeModel->publish_at) ? __('Laisser vide pour un affichage immédiat de la gamme.') : __('Vider pour un affichage immédiat de la gamme') + ]) + </div> + </div> + <div class="col"> + <div class="form-group"> + <label for="rangeUnPublishedAt"><b>{{ __('Date de fin de publication (Europe/Paris)') }}</b></label> + <input id="rangeUnPublishedAt" name="unpublish_at" class="form-control publishTime" type="datetime-local" data-form-type="date" aria-describedby="rangeUnPublishAtHelp" value="{{ old('unpublish_at', ((isset($rangeModel) and $rangeModel->unpublish_at) ? $rangeModel->unpublish_at->format('Y-m-d\TH:i') : '')) }}"> + @include('back.modules.input-error', [ + 'inputName' => 'unpublish_at', 'helperId' => 'rangeUnPublishAtHelp' , 'helper' => (isset($rangeModel) and $rangeModel->unpublish_at) ? __('Laisser vide pour un affichage permanent de la gamme.') : __('Vider pour un affichage permanent de la gamme') + ]) + </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="rangePictureAlt" class="col-form-label"><b>{{ __('Texte alternatif de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'rangePictureAlt'])'></div> + <input name="picture_alt" type="text" value="{{ old('picture_alt', $rangeModel->picture_alt ?? '') }}" class="form-control" + id="rangePictureAlt" aria-describedby="rangePictureAltHelp" placeholder="{{ __('Une image de petits chatons') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_alt', 'helperId' => 'rangePictureAltHelp' , '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="rangePictureTitle" class="col-form-label"><b>{{ __('Titre de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'rangePictureTitle'])'></div> + <input name="picture_title" type="text" value="{{ old('picture_title', $rangeModel->picture_title ?? '') }}" class="form-control" + id="rangePictureTitle" aria-describedby="rangePictureTitleHelp" placeholder="{{ __('Une description de la gamme pour les moteurs de recherche') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_title', 'helperId' => 'rangePictureTitleHelp' , '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' => 'rangeModelPicture', + 'name' => 'picture', + 'helper' => __('L\'image qui va servir de vignette à la gamme, doit être de 700px (hauteur) par 700px (largeur)'), + 'width' => 700, + 'height' => 700, + 'value' => $rangeModel->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="rangePictureThumbAlt" class="col-form-label"><b>{{ __('Texte alternatif de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'rangePictureThumbAlt'])'></div> + <input name="picture_thumb_alt" type="text" value="{{ old('picture_thumb_alt', $rangeModel->picture_thumb_alt ?? '') }}" class="form-control" + id="rangePictureThumbAlt" aria-describedby="rangePictureThumbAltHelp" placeholder="{{ __('Une image de petits chatons') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_thumb_alt', 'helperId' => 'rangePictureThumbAltHelp' , '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="rangePictureThumbTitle" class="col-form-label"><b>{{ __('Titre de l\'image') }}</b></label> + <div class="word-counter" data-json='@json(['id' => 'rangePictureThumbTitle'])'></div> + <input name="picture_thumb_title" type="text" value="{{ old('picture_thumb_title', $rangeModel->picture_thumb_title ?? '') }}" class="form-control" + id="rangePictureThumbTitle" aria-describedby="rangePictureThumbTitleHelp" placeholder="{{ __('Une description de la gamme pour les moteurs de recherche') }}" required> + @include('back.modules.input-error', [ + 'inputName' => 'picture_thumb_title', 'helperId' => 'rangePictureThumbTitleHelp' , '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' => 'rangeModelThumbPicture', + 'name' => 'picture_thumb', + 'helper' => __('L\'image qui va servir de vignette à la gamme, doit être de 300px (hauteur) par 300px (largeur)'), + 'width' => 300, + 'height' => 300, + 'value' => $rangeModel->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 de la gamme') }}</legend> + <div class="row mb-3"> + <div class="col-12 form-group"> + @include('back.modules.input-error', [ + 'inputName' => 'description', 'helperId' => 'rangeModelDescriptionHelp' , 'helper' => __('Relisez bien votre description avant de la publier.') + ]) + @php + $data = [ + 'name' => 'description', + 'csrf' => csrf_token(), + 'text' => old('description', $rangeModel->description ?? ''), + ]; + @endphp + <div id="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>{{ __('Liens') }}</legend> + <div class="row mb-3"> + <div class="col-12 form-group"> + @include('back.modules.input-error', [ + 'inputName' => 'link1', 'helperId' => 'rangeModelLink1Help' , 'helper' => __('Lien à afficher sur la gamme') + ]) + @php + $data = [ + 'name' => 'link1', + 'placeholder' => __('lien numéro 1'), + 'value' => old('link1', $rangeModel->link1 ?? null), + ]; + @endphp + <div class="model-link" data-json='@json($data)'></div> + </div> + </div> + <div class="row mb-3"> + <div class="col-12 form-group"> + @include('back.modules.input-error', [ + 'inputName' => 'link2', 'helperId' => 'rangeModelLink1Help' , 'helper' => __('Lien à afficher sur la gamme') + ]) + @php + $data = [ + 'name' => 'link2', + 'placeholder' => __('lien numéro 2'), + 'value' => old('link2', $rangeModel->link2 ?? null), + ]; + @endphp + <div class="model-link" data-json='@json($data)'></div> + </div> + </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="rangeStatus" aria-describedby="rangeStatusHelp" value="1" @if(old('status', $rangeModel->status ?? ''))checked @endif> + <label class="form-check-label user-select-none pt-1" for="rangeStatus"><b>{{ __('Publié') }}</b></label><br> + @include('back.modules.input-error', [ + 'inputName' => 'status', 'helperId' => 'rangeStatusHelp' , 'helper' => __('La gamme 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 === 'rangePublishedAt') { + 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.rangePublishAtHelp ? parent.children.rangePublishAtHelp : parent.children.rangeUnPublishAtHelp; + let isPlanned = false; + if(input.id === 'rangePublishedAt') { + 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 === 'rangePublishedAt' ? + "{{ __('Vider pour un affichage immédiat de la gamme.') }}" : + "{{ __('Vider pour un affichage permanent de la gamme.') }}"; + } else { + // small.textContent = input.id === 'rangePublishedAt' ? + // "{{ __('Uniquement pour planifier la publication') }}" : + // "{{ __('Uniquement pour planifier la fin de la publication') }}"; + small.textContent = input.id === 'rangePublishedAt' ? + "{{ __('Laisser vide pour un affichage immédiat de la gamme.') }}" : + "{{ __('Laisser vide pour un affichage permanent de la gamme.') }}"; + } + }; + 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 === 'rangePublishedAt') { + dates.start = value; + } else { + dates.end = value; + } + setDateHelpers(input, parent); + }); + } +}); +</script> +@endpush diff --git a/resources/views/back/ranges/index.blade.php b/resources/views/back/ranges/index.blade.php new file mode 100644 index 0000000..fcbc37b --- /dev/null +++ b/resources/views/back/ranges/index.blade.php @@ -0,0 +1,106 @@ +@extends('layouts.back') + +@section('title', sprintf('%s - %s', __('models.classes.ranges'), 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.ranges') }} {{ Str::ucfirst(__('liste')) }}</h1> + </div> + <div class="col-md-3"> + <div class="input-group justify-content-end"> + <a href="{{ route('bo.ranges.create') }}" class="btn btn-primary" title="{{ __('Ajouter une :model', [ + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" 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"> + {{ $ranges->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'), 'category' => __('Categorie'), 'description' => __('Description'), 'status' => __('Publication'), 'updated_at' => __('Mis à jour le')]; + @endphp + @include('back.modules.tableColSorter', [ 'cols' => $cols, 'mobileHide' => ['status']]) + </thead> + <tbody> + @if($ranges->count()) + @foreach ($ranges as $rangeModel) + <tr> + <td> + <span>{{ $rangeModel->title }}</span> + </td> + <td> + <span>{{ $rangeModel->category->label }}</span> + </td> + <td> + <span>{{ Str::limit(strip_tags($rangeModel->description)) }}</span> + </td> + <td class="d-none d-md-table-cell"> + <form action="{{ route('bo.ranges.change-published', $rangeModel->id) }}" method="POST"> + @csrf + <button type="submit" class="btn btn-sm" title="{{ __(':action :model', [ + 'action' => $rangeModel->status ? __('Dépublier') : __('Publier'), + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" data-bs-toggle="tooltip"> + <i class="fas @if($rangeModel->status)fa-check check-green @else fa-times cross-red @endif"></i> + </button> + </form> + </td> + <td> + <span>{{ $rangeModel->updated_at->formatLocalized('%d/%m/%Y à %I:%M') }}</span> + </td> + <td> + <div class="btn-group"> + {{-- Public View Page --}} + @if($rangeModel->status) + <a class="btn btn-sm btn-warning" href="" target="_blank" title="{{ __('Visualiser :model', [ + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-eye"></i></a> + @endif + <a class="btn btn-sm btn-primary" href="{{ route('bo.ranges.edit', $rangeModel->id) }}" title="{{ __('Modifier :model', [ + 'model' => Str::singular(__('models.classes.ranges')) + ]) }}" 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.ranges')]) + }}</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"> + {{ $ranges->withQueryString()->onEachSide(1)->links() }} + </div> + </div> + </div> + </div> +@endsection diff --git a/resources/views/back/sliders/index.blade.php b/resources/views/back/sliders/index.blade.php index f0bbbf5..81101de 100644 --- a/resources/views/back/sliders/index.blade.php +++ b/resources/views/back/sliders/index.blade.php @@ -90,7 +90,7 @@ <form action="{{ route('bo.sliders.change-published', $slider->id) }}" method="POST"> @csrf <button type="submit" class="btn btn-sm" title="{{ __(':action :model', [ - 'action' => $slider->status ? __('Déplublier') : __('Publier'), + 'action' => $slider->status ? __('Dépublier') : __('Publier'), 'model' => Str::singular(__('models.classes.sliders')) ]) }}" data-bs-toggle="tooltip"> <i class="fas @if($slider->status)fa-check check-green @else fa-times cross-red @endif"></i> diff --git a/routes/web-bo.php b/routes/web-bo.php index 20210b2..d83dd0c 100644 --- a/routes/web-bo.php +++ b/routes/web-bo.php @@ -2,6 +2,8 @@ use App\Http\Controllers\Bo\HomeController; use App\Http\Controllers\Bo\NewsController; +use App\Http\Controllers\Bo\ProductController; +use App\Http\Controllers\Bo\RangeController; use App\Http\Controllers\Bo\SliderController; use App\Http\Controllers\Bo\TagController; use Illuminate\Support\Facades\Auth; @@ -19,14 +21,37 @@ Route::prefix('bo')->name('bo.')->group(function () { Route::middleware('auth:backend')->group(function () { Route::get('/', [HomeController::class, 'dashboard'])->name('dashboard'); + // * SLIDERS + Route::resource('sliders', SliderController::class)->except(['show']); Route::post('/sliders/{slider}/change-order', [SliderController::class, 'changeOrder']) ->name('sliders.change-order'); Route::post('/sliders/{slider}/change-published', [SliderController::class, 'changePublished']) ->name('sliders.change-published'); - Route::resource('sliders', SliderController::class)->except(['show']); + // * RANGES + Route::resource('ranges', RangeController::class)->except(['show']) + ->withoutMiddleware('htmlsanitize') + ->middleware('htmlsanitize:description'); + Route::post('/ranges/{range}/change-published', [RangeController::class, 'changePublished']) + ->name('ranges.change-published'); + // * Ranges PAGE SEARCH + Route::get('/ranges/search-uri', [RangeController::class, 'searchUri']) + ->name('ranges.search-uri'); + Route::get('/ranges/model-title', [RangeController::class, 'modelTitle']) + ->name('ranges.model-title'); + + // * 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']); Route::post('/tags/store', [TagController::class, 'jsonStore'])->name('tags.jsonStore'); + + // * NEWS Route::resource('news', NewsController::class) ->except(['show']) ->withoutMiddleware('htmlsanitize') diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 0894c60..6ab614c 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -6,15 +6,4 @@ use Tests\TestCase; class ExampleTest extends TestCase { - /** - * A basic test example. - * - * @return void - */ - public function test_example() - { - $response = $this->get('/'); - - $response->assertStatus(200); - } } diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 62e8c3c..46cdcaa 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -6,13 +6,4 @@ use PHPUnit\Framework\TestCase; class ExampleTest extends TestCase { - /** - * A basic test example. - * - * @return void - */ - public function test_example() - { - $this->assertTrue(true); - } }