Merge branch 'develop' into trunk

This commit is contained in:
Noah Lancien 2025-06-25 10:47:19 +02:00
commit c4be55a1bf
31 changed files with 1040 additions and 313 deletions

View file

@ -71,3 +71,21 @@ Models list :
✅ Testimonial
❌ User
✅ Video
# Meilisearch
When deploying, if there any modification to a concerned Model, you may refresh Meilisearch cache. Concerned models are :
- News
- NewsBreeding
- Product
- Recipe
```sh
php artisan scout:flush "\App\Models\News"
php artisan scout:import "\App\Models\News"
php artisan scout:flush "\App\Models\NewsBreeding"
php artisan scout:import "\App\Models\NewsBreeding"
php artisan scout:flush "\App\Models\Product"
php artisan scout:import "\App\Models\Product"
php artisan scout:flush "\App\Models\Recipe"
php artisan scout:import "\App\Models\Recipe"
```

17
app/Enums/RatingEnum.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
use App\Traits\Enums\BaseEnum;
enum RatingEnum: int
{
use BaseEnum;
case inedible = 0;
case bad = 1;
case mid = 2;
case good = 3;
case great = 4;
case amazing = 5;
}

View file

@ -45,7 +45,7 @@ class ProductController extends Controller
public function index(Request $request): \Illuminate\Contracts\View\View
{
/** @var \Illuminate\Database\Eloquent\Builder $productModels */
$productModels = Product::query();
$productModels = Product::query()->with('ratings')->withAvg('ratings', 'rate');
/** @var string $search Search field */
$search = $request->search;

View file

@ -36,7 +36,7 @@ class RecipeController extends Controller
public function index(Request $request): \Illuminate\Contracts\View\View
{
/** @var \Illuminate\Database\Eloquent\Builder $recipeModels */
$recipeModels = Recipe::query();
$recipeModels = Recipe::query()->with('ratings')->withAvg('ratings', 'rate');
/** @var string $search Search field */
$search = $request->search;

View file

@ -2,9 +2,6 @@
namespace App\Http\Controllers;
use App\Enums\Pagination\ItemsPerPaginationEnum;
use App\Enums\Theme\BootstrapThemeEnum;
use App\Lib\Helpers\ToolboxHelper;
use App\Models\Discount;
use App\Models\Message;
use App\Models\Notification;
@ -13,13 +10,11 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator as LengthAwarePaginatorCustom;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Controllers\Controller as BaseController;
class Controller extends BaseController
{
@ -73,203 +68,6 @@ class Controller extends BaseController
return route('club.home.show');
}
/**
* Build Search query on specified fields
* splitting search into words or using whole sentence.
*
* @param \Illuminate\Database\Eloquent\Builder $query The eloquent query builder.
* @param string $search The query string.
* @param callable|null $searchQuery The query string.
* @param string ...$fields Either a simple array or a string
* in case of a string the two fields
* would be concatenate to make the search.
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function searchQuery(
Builder $query,
string $search,
callable|null $searchQuery = null,
string ...$fields
): \Illuminate\Database\Eloquent\Builder {
if (count($fields)) {
// Search using words.
$words = explode(' ', $search);
$query->where(function ($query) use ($searchQuery, $words, $search, $fields) {
foreach ($words as $word) {
if (!strlen($word)) {
continue;
}
$this->searchOnFields($query, $word, $fields);
} //end foreach
// Search whole using whole sentence.
$this->searchOnFields($query, $search, $fields);
if ($searchQuery) {
$searchQuery($query);
}
});
} //end if
return $query;
}
/**
* Build query search on all fields.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $search
* @param array $fields
* @return \Illuminate\Database\Eloquent\Builder
*/
private function searchOnFields(
Builder $query,
string $search,
array $fields
): \Illuminate\Database\Eloquent\Builder {
$table = $query->getModel()->getTable();
foreach ($fields as $field) {
$field = str_replace('?', '\?', $field);
if (is_array($field)) {
$query->orWhereRaw(
sprintf("UPPER(CONCAT(%s)) LIKE UPPER(?)", collect($field)->map(function ($q) use ($table) {
return "`$table`.`$q`";
})->implode(', \' \', ')),
['%' . htmlspecialchars($search) . '%']
);
return $query;
}
// Get models woth enum's label.
if (is_array($this->getSearchValue($query, $search, $field))) {
foreach ($this->getSearchValue($query, $search, $field) as $array) {
$query->orWhereRaw(
"UPPER(`$table`.`$field`) LIKE UPPER(?)",
['%' . htmlspecialchars($array->value) . '%']
);
}
return $query;
}
// Get models with relation's name/label.
if (Str::endsWith($field, '_id')) {
$query->orWhereHas(Str::remove('_id', $field), function ($queryRelation) use ($field, $search) {
if (Schema::hasColumn(Str::remove('_id', $field) . 's', 'name')) {
$queryRelation->where('name', 'like', '%' . $search . '%');
} elseif (Schema::hasColumn(Str::remove('_id', $field) . 's', 'label')) {
$queryRelation->where('label', 'like', '%' . $search . '%');
}
});
return $query;
}
$query->orWhereRaw(
"UPPER(`$table`.`$field`) LIKE UPPER(?)",
['%' . htmlspecialchars($this->getSearchValue($query, $search, $field)) . '%']
);
} //end foreach
return $query;
}
/**
* Get the search value (on string or Enum).
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $search
* @param string $field
* @return string|array
*/
protected function getSearchValue(Builder $query, string $search, string $field): string|array
{
$cast = $query->getModel()->getCasts()[$field] ?? null;
if (
count($query->getModel()->getCasts()) and // Check if the model has casts.
$cast and // Check if there is cast about specific field.
enum_exists($cast) // Check if the cast is an enum.
) {
/** @var \App\Traits\Enums $cast */
// @phpstan-ignore-next-line
$cases = collect($cast::toArray());
$enum = $cases->filter(function ($item) use ($search) {
return preg_match('/' . Str::lower($search) . '/', Str::lower($item->label));
});
return $enum->toArray();
} else {
return $search;
}
}
/**
* Sort columns with a query.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $ignore
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function sortQuery(Builder $query, array $ignore = []): \Illuminate\Database\Eloquent\Builder
{
$rName = request()->route()->getName();
$table = $query->getModel()->getTable();
if (request()->input('sort_col')) {
Session::put("$rName.sort_col", request()->input('sort_col'));
Session::put("$rName.sorted", true);
}
if (request()->input('sort_way')) {
Session::put("$rName.sort_way", request()->input('sort_way'));
Session::put("$rName.sorted", true);
}
if (request()->rst) {
Session::remove("$rName.sorted");
Session::remove("$rName.sort_col");
Session::remove("$rName.sort_way");
}
if (
request()->input('sort_col') and
request()->input('sort_way') and
Schema::hasColumn($table, request()->input('sort_col'))
) {
if (!in_array(request()->input('sort_col'), $ignore)) {
$query = $query->orderBy(
\sprintf("$table.%s", request()->input('sort_col')),
request()->input('sort_way')
);
}
} elseif (Schema::hasColumn($table, 'order')) {
$query = $query->orderBy('order', 'ASC');
Session::put("$rName.sort_col", 'order');
Session::put("$rName.sort_way", 'asc');
} elseif (Schema::hasColumn($table, 'updated_at')) {
$query = $query->orderBy('updated_at', 'DESC');
Session::put("$rName.sort_col", 'updated_at');
Session::put("$rName.sort_way", 'desc');
} elseif (Schema::hasColumn($table, 'created_at')) {
$query = $query->orderBy('created_at', 'DESC');
Session::put("$rName.sort_col", 'created_at');
Session::put("$rName.sort_way", 'desc');
}
return $query;
}
/**
* Customize pagination with cache or config (default).
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \App\Enums\Pagination\ItemsPerPaginationEnum|null $pagination
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
protected function paginate(
Builder $query,
ItemsPerPaginationEnum|null $pagination = null
): \Illuminate\Contracts\Pagination\LengthAwarePaginator {
$currentRoutePath = Str::slug(request()->route()->getName());
$cacheKey = "pagination.{$currentRoutePath}";
$pagination = $pagination ?? ItemsPerPaginationEnum::twelve;
$pagination = ToolboxHelper::getValidatedEnum(
Cache::get($cacheKey, $pagination->value()),
'pagination',
'\App\Enums\Pagination\ItemsPerPaginationEnum',
);
if (Cache::get($cacheKey) !== $pagination) {
Cache::put($cacheKey, $pagination);
}
$query = $query->paginate($pagination);
return $query;
}
/**
* Get a paginate resource.
*
@ -286,25 +84,6 @@ class Controller extends BaseController
return \response()->json($models);
}
/**
* Set the bootstrap theme or light (default).
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
protected function setTheme(Request $request): \Illuminate\Http\RedirectResponse
{
$theme = ToolboxHelper::getValidatedEnum(
$request->theme ?: Cache::get('theme', BootstrapThemeEnum::light->value()),
'theme',
'\App\Http\Controllers\BootstrapThemeEnum',
);
if (Cache::get("theme") !== $theme) {
Cache::put("theme", $theme);
}
return redirect()->back()->with('success', trans('crud.messages.theme_updated'));
}
/**
* Get models titles for search input propositions.
*

View file

@ -8,6 +8,7 @@ use App\Lib\Helpers\CollectionHelper;
use App\Models\Meat;
use App\Models\Product;
use App\Models\Range;
use App\Models\Rating;
use App\Models\Recipe;
use App\Models\StaticPage;
use App\Models\Submeat;
@ -55,18 +56,18 @@ class ProductController extends Controller
$meatModels = Meat::getPublishedOrderedWithSubmeats();
$submeatModels = $submeatModelsFiltered ?? Submeat::getPublishedOrdered();
return view('fo.pages.products.index', [
'viewsPaginate' => CollectionHelper::getViewsFromPaginateModels(
'viewsPaginate' => CollectionHelper::getViewsFromPaginateModels(
$productModels,
'fo.partials.product-card',
'model'
),
'productDiscoverModels' => $productDiscoverModels,
'staticPageModel' => $staticPageModel,
'rangeModels' => $rangeModels,
'subrangeModels' => $subrangeModels,
'meatModels' => $meatModels,
'submeatModels' => $submeatModels,
'searchPropositions' => $this->getProductModelsTitles(),
'productDiscoverModels' => $productDiscoverModels,
'staticPageModel' => $staticPageModel,
'rangeModels' => $rangeModels,
'subrangeModels' => $subrangeModels,
'meatModels' => $meatModels,
'submeatModels' => $submeatModels,
'searchPropositions' => $this->getProductModelsTitles(),
]);
}
@ -116,6 +117,7 @@ class ProductController extends Controller
'recipeModel' => $recipeModel,
'videoModel' => $videoModel,
'staticPageModel' => $staticPageModel,
'ratingsData' => Rating::getRatings($productModel),
]);
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Fo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Fo\StoreRatingRequest;
use App\Models\Rating;
use Illuminate\Support\Facades\DB;
class RatingController extends Controller
{
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Fo\StoreRatingRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function store(StoreRatingRequest $request): \Illuminate\Http\JsonResponse
{
return DB::transaction(function () use ($request) {
$validated = $request->validated();
/** @var \App\Models\Product|\App\Models\Recipe $relatedModel */
$relatedModel = app($validated['rating_type'])::findOrFail($validated['rating_id']);
// Delete rating.
if ($validated['rate'] == 0) {
Rating::query()->where([
['customer_id', $validated['customer_id']],
['rating_id', $validated['rating_id']],
['rating_type', $validated['rating_type']],
])->first()->delete();
return response()->json([
'message' => trans('Votre note a bien été supprimée !'),
'data' => Rating::resetCache($relatedModel),
]);
}
// Save or update rating.
Rating::updateOrCreate(
[
'customer_id' => $validated['customer_id'],
'rating_id' => $validated['rating_id'],
'rating_type' => $validated['rating_type'],
],
[
'rate' => $validated['rate'],
]
);
return response()->json([
'message' => trans('Votre note a bien été enregistrée !'),
'data' => Rating::resetCache($relatedModel),
]);
});
}
}

View file

@ -8,6 +8,7 @@ use App\Http\Resources\Fo\Recipes\RecipeCommericalSheetResource;
use App\Lib\Helpers\CollectionHelper;
use App\Models\ConsumptionMoment;
use App\Models\Meat;
use App\Models\Rating;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Recipe;
use App\Models\StaticPage;
@ -91,6 +92,7 @@ class RecipeController extends Controller
'recipeModels' => $this->getRecipesFiltered($recipeModel),
'associatedProductModels' => $recipeModel->products->where('published', true),
'staticPageModel' => $staticPageModel,
'ratingsData' => Rating::getRatings($recipeModel),
]);
}

View file

@ -11,7 +11,7 @@ class SingleOptinRequest extends FormRequest
*
* @return boolean
*/
public function authorize()
public function authorize(): bool
{
return true;
}
@ -21,7 +21,7 @@ class SingleOptinRequest extends FormRequest
*
* @return array
*/
public function rules()
public function rules(): array
{
return [
'email' => 'required|email:rfc,dns,spoof',

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\Fo;
use Illuminate\Foundation\Http\FormRequest;
class StoreRatingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'rate' => 'required|integer|min:0|max:5',
'rating_id' => 'required|integer|exists:products,id',
'rating_type' => 'required|in:App\\Models\\Product,App\\Models\\Recipe',
'customer_id' => 'required|integer|exists:customers,id',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'rate' => trans('notation'),
'rating_id' => trans('élément ciblé'),
'customer_id' => trans('utilisateur connecté'),
];
}
}

View file

@ -483,6 +483,16 @@ class Customer extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Message::class, 'from');
}
/**
* Get all of the sentMessages for the Customer.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function ratings(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Rating::class);
}
/**
* Get all of the recipes that are favorite by this customer.
*

View file

@ -115,6 +115,15 @@ class Product extends Model implements SeoInterface, Sitemapable, SchemaOrgInter
'sortableAttributes' => ['label', 'description', 'preparation', 'ingredients']
];
/**
* Get sortable virtual column.
*
* @var array
*/
public static $sortableVirtualColumn = [
'ratings_avg_rate',
];
/**
* Perform any actions required after the model boots.
*
@ -126,8 +135,9 @@ class Product extends Model implements SeoInterface, Sitemapable, SchemaOrgInter
self::assertLabelIsUnique($product->label);
self::manageFields($product);
});
static::created(function () {
static::created(function (self $product) {
self::resetCache();
Rating::resetCache($product);
});
static::updating(function (self $product) {
self::assertLabelIsUnique($product->label, $product->getKey());
@ -138,12 +148,14 @@ class Product extends Model implements SeoInterface, Sitemapable, SchemaOrgInter
FileStorageHelper::removeOldFile($product, 'banner');
FileStorageHelper::removeOldFile($product, 'packaging');
self::resetCache();
Rating::resetCache($product);
});
static::deleted(function (self $product) {
FileStorageHelper::removeFile($product, 'picture');
FileStorageHelper::removeFile($product, 'banner');
FileStorageHelper::removeFile($product, 'packaging');
self::resetCache();
Rating::resetCache($product);
});
}
@ -274,7 +286,17 @@ class Product extends Model implements SeoInterface, Sitemapable, SchemaOrgInter
*/
public function toSchemaOrg(bool $asReference = false): \Spatie\SchemaOrg\Product
{
$model = $this->load(['recipes', 'claims', 'productTypes', 'meat', 'submeat', 'range', 'subrange', 'videos']);
$model = $this->load([
'recipes',
'claims',
'productTypes',
'meat',
'submeat',
'range',
'subrange',
'videos',
'ratings'
]);
// Main.
$schema = Schema::Product()
@ -291,17 +313,15 @@ class Product extends Model implements SeoInterface, Sitemapable, SchemaOrgInter
->url(asset($model->packaging))
->headline($model->packaging_title),
])
// FIXME: wait rating functionnality.
// ->aggregateRating(
// Schema::aggregateRating()
// ->ratingValue(10)
// ->bestRating(10)
// ->worstRating(0)
// ->ratingCount(1)
// ->reviewCount(1)
// )
->brand($model->getBrandSchema());
// Ratings.
if ($model->ratings->count() && Rating::getAverageRating($model->ratings) > 2.0) {
$schema->aggregateRating(
Rating::toSchemaOrg($model->ratings)
);
}
// Recipes associated.
if ($model->recipes->count() && !$asReference) {
$schema->subjectOf($model->recipes->where('published', true)->map(function (Recipe $recipe) {
@ -473,4 +493,14 @@ class Product extends Model implements SeoInterface, Sitemapable, SchemaOrgInter
{
return $this->hasMany(IncoNutritionalValue::class)->first();
}
/**
* Get the nutritionnal values for the product.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function ratings(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Rating::class, 'rating');
}
}

143
app/Models/Rating.php Normal file
View file

@ -0,0 +1,143 @@
<?php
namespace App\Models;
use App\Enums\RatingEnum;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Spatie\SchemaOrg\Schema;
/**
* @property \App\Models\Customer $customer_id Rating associated customer.
* @property integer $rating_id Rating key of the associated model.
* @property \App\Enums\RatingEnum $rate Rating note (Enum RatingEnum).
* @property string $rating_type Rating associated model.
* @property string $customer_username Rating customer author.
* @property \Carbon\Carbon $created_at Created date.
* @property \Carbon\Carbon $updated_at Updated date.
*
* @property-read \App\Models\Product $products Product polymorphic relationship.
*/
class Rating extends Model
{
use ActivityLog;
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'customer_id',
'rating_id',
'rate',
'rating_type',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'rate' => RatingEnum::class,
];
// * METHODS
/**
* Get all ratings data.
*
* @param \App\Models\Product|\App\Models\Recipe $model
* @return array
*/
public static function resetCache(Product|Recipe $model): array
{
Cache::forget(sprintf('%s-ratings', $model->slug));
return self::getRatings($model);
}
/**
* Get all ratings data.
*
* @param \App\Models\Product|\App\Models\Recipe $model
* @return array
*/
public static function getRatings(Product|Recipe $model): array
{
return Cache::remember(sprintf('%s-ratings', $model->slug), 86400, function () use ($model) {
/** @var \App\Models\Customer|null customerModel */
$customerModel = auth('club')->user();
return [
'total' => $model->ratings()->count(),
'totalRate' => round($model->ratings()->get()->avg(function (Rating $model) {
return $model->rate->value();
}), 1),
'ratings' => $model->ratings()->inRandomOrder()
->take(6)->with('customer')->get()->each(function (Rating $rating) {
$rating->customer_username = $rating->customer->username;
return $rating;
}),
'ratingType' => get_class($model),
'customerRating' => $customerModel
? $model->ratings()->where('customer_id', $customerModel->getkey())->first()
: null,
];
});
}
/**
* Create a SchemaOrg.
*
* @param \Illuminate\Support\Collection $modelCollection
* @return \Spatie\SchemaOrg\AggregateRating
*/
public static function toSchemaOrg(Collection $modelCollection): \Spatie\SchemaOrg\AggregateRating
{
return Schema::aggregateRating()
->ratingValue(self::getAverageRating($modelCollection))
->bestRating(5)
->worstRating(1)
->ratingCount($modelCollection->count());
}
/**
* Calculate and return the average rating of rating collection.
*
* @param \Illuminate\Support\Collection $modelCollection
* @return float
*/
public static function getAverageRating(Collection $modelCollection): float
{
return round($modelCollection->avg(function (Rating $model) {
return $model->rate->value();
}), 1);
}
// * RELATIONSHIPS
/**
* Get all of the products that are favorite by this customer.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function customer(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Customer::class);
}
/**
* Get the parent rateable model (Product or Recipe).
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function model(): \Illuminate\Database\Eloquent\Relations\MorphTo
{
return $this->morphTo('rating');
}
}

View file

@ -103,6 +103,15 @@ class Recipe extends Model implements SeoInterface, Sitemapable, SchemaOrgInterf
'sortableAttributes' => ['title', 'preparation', 'advice']
];
/**
* Get sortable virtual column.
*
* @var array
*/
public static $sortableVirtualColumn = [
'ratings_avg_rate',
];
/**
* Perform any actions required after the model boots.
*
@ -115,8 +124,9 @@ class Recipe extends Model implements SeoInterface, Sitemapable, SchemaOrgInterf
self::setHighlightValue($model);
self::manageFields($model);
});
static::created(function () {
static::created(function (self $model) {
self::resetCache();
Rating::resetCache($model);
});
static::updating(function (self $model) {
self::assertTitleIsUnique($model->title, $model->getKey());
@ -126,10 +136,12 @@ class Recipe extends Model implements SeoInterface, Sitemapable, SchemaOrgInterf
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'picture');
self::resetCache();
Rating::resetCache($model);
});
static::deleted(function (self $model) {
FileStorageHelper::removeFile($model, 'picture');
self::resetCache();
Rating::resetCache($model);
});
}
@ -279,10 +291,12 @@ class Recipe extends Model implements SeoInterface, Sitemapable, SchemaOrgInterf
*
* @param boolean $asReference
* @return \Spatie\SchemaOrg\Recipe
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public function toSchemaOrg(bool $asReference = false): \Spatie\SchemaOrg\Recipe
{
$model = $this->load(['products', 'consumptionMoment', 'video']);
// @phpcs:enable
$model = $this->load(['products', 'consumptionMoment', 'video', 'ratings']);
// Main.
$schema = Schema::Recipe()
@ -315,15 +329,13 @@ class Recipe extends Model implements SeoInterface, Sitemapable, SchemaOrgInterf
->url(asset($model->picture))
->headline($model->picture_title)
);
// FIXME: wait rating functionnality.
// ->aggregateRating(
// Schema::aggregateRating()
// ->ratingValue(10)
// ->bestRating(10)
// ->worstRating(0)
// ->ratingCount(1)
// ->reviewCount(1)
// );
// Ratings.
if ($model->ratings->count() && Rating::getAverageRating($model->ratings) > 2.0) {
$schema->aggregateRating(
Rating::toSchemaOrg($model->ratings)
);
}
// Cooking time associated.
if ($model->cooking_time) {
@ -446,4 +458,14 @@ class Recipe extends Model implements SeoInterface, Sitemapable, SchemaOrgInterf
{
return $this->morphToMany(Product::class, 'productable')->with(['meat', 'submeat']);
}
/**
* Get the nutritionnal values for the product.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function ratings(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Rating::class, 'rating');
}
}

78
composer.lock generated
View file

@ -4060,16 +4060,16 @@
},
{
"name": "nesbot/carbon",
"version": "3.10.0",
"version": "3.10.1",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
"reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9"
"reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/c1397390dd0a7e0f11660f0ae20f753d88c1f3d9",
"reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00",
"reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00",
"shasum": ""
},
"require": {
@ -4161,7 +4161,7 @@
"type": "tidelift"
}
],
"time": "2025-06-12T10:24:28+00:00"
"time": "2025-06-21T15:19:35+00:00"
},
{
"name": "nette/schema",
@ -5562,16 +5562,16 @@
},
{
"name": "psy/psysh",
"version": "v0.12.8",
"version": "v0.12.9",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
"reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625"
"reference": "1b801844becfe648985372cb4b12ad6840245ace"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/85057ceedee50c49d4f6ecaff73ee96adb3b3625",
"reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace",
"reference": "1b801844becfe648985372cb4b12ad6840245ace",
"shasum": ""
},
"require": {
@ -5635,9 +5635,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.12.8"
"source": "https://github.com/bobthecow/psysh/tree/v0.12.9"
},
"time": "2025-03-16T03:05:19+00:00"
"time": "2025-06-23T02:35:06+00:00"
},
{
"name": "ralouphie/getallheaders",
@ -6004,16 +6004,16 @@
},
{
"name": "sentry/sentry",
"version": "4.14.0",
"version": "4.14.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "f8c50304bd2f1640704345274bdd5af150b6945d"
"reference": "a28c4a6f5fda2bf730789a638501d7a737a64eda"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/f8c50304bd2f1640704345274bdd5af150b6945d",
"reference": "f8c50304bd2f1640704345274bdd5af150b6945d",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/a28c4a6f5fda2bf730789a638501d7a737a64eda",
"reference": "a28c4a6f5fda2bf730789a638501d7a737a64eda",
"shasum": ""
},
"require": {
@ -6077,7 +6077,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.14.0"
"source": "https://github.com/getsentry/sentry-php/tree/4.14.1"
},
"funding": [
{
@ -6089,7 +6089,7 @@
"type": "custom"
}
],
"time": "2025-06-13T17:22:57+00:00"
"time": "2025-06-23T15:25:52+00:00"
},
{
"name": "sentry/sentry-laravel",
@ -9408,16 +9408,16 @@
},
{
"name": "uwil/laravel-common",
"version": "3.2.1",
"version": "3.3.1",
"source": {
"type": "git",
"url": "git@lab.s-web.fr:ldc-web/projets/laravel-common.git",
"reference": "f928001a45fad96874192ec9dd8400834259f0d9"
"reference": "fe5ee26f21a3aba48bc5fbd778bf51fdd63bacab"
},
"dist": {
"type": "zip",
"url": "https://lab.s-web.fr/api/v4/projects/215/packages/composer/archives/uwil/laravel-common.zip?sha=f928001a45fad96874192ec9dd8400834259f0d9",
"reference": "f928001a45fad96874192ec9dd8400834259f0d9",
"url": "https://lab.s-web.fr/api/v4/projects/215/packages/composer/archives/uwil/laravel-common.zip?sha=fe5ee26f21a3aba48bc5fbd778bf51fdd63bacab",
"reference": "fe5ee26f21a3aba48bc5fbd778bf51fdd63bacab",
"shasum": ""
},
"require": {
@ -10826,16 +10826,16 @@
},
{
"name": "laravel-lang/attributes",
"version": "2.13.4",
"version": "2.13.5",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/attributes.git",
"reference": "c3671787b92fb83da1f5a4f9b6dcc9f4e3edfafe"
"reference": "0f7a0c195cbd4eb000b14dc3fd233dacd23a10f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/attributes/zipball/c3671787b92fb83da1f5a4f9b6dcc9f4e3edfafe",
"reference": "c3671787b92fb83da1f5a4f9b6dcc9f4e3edfafe",
"url": "https://api.github.com/repos/Laravel-Lang/attributes/zipball/0f7a0c195cbd4eb000b14dc3fd233dacd23a10f7",
"reference": "0f7a0c195cbd4eb000b14dc3fd233dacd23a10f7",
"shasum": ""
},
"require": {
@ -10889,9 +10889,9 @@
],
"support": {
"issues": "https://github.com/Laravel-Lang/attributes/issues",
"source": "https://github.com/Laravel-Lang/attributes/tree/2.13.4"
"source": "https://github.com/Laravel-Lang/attributes/tree/2.13.5"
},
"time": "2025-03-14T10:43:34+00:00"
"time": "2025-06-23T09:39:20+00:00"
},
{
"name": "laravel-lang/common",
@ -10976,16 +10976,16 @@
},
{
"name": "laravel-lang/http-statuses",
"version": "3.10.3",
"version": "3.10.4",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/http-statuses.git",
"reference": "32f9c3e9a93179ea6769844eaddfe8ae4cf3df63"
"reference": "97fdb638f95bf9ca8131cc887750d70980aa7be3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/http-statuses/zipball/32f9c3e9a93179ea6769844eaddfe8ae4cf3df63",
"reference": "32f9c3e9a93179ea6769844eaddfe8ae4cf3df63",
"url": "https://api.github.com/repos/Laravel-Lang/http-statuses/zipball/97fdb638f95bf9ca8131cc887750d70980aa7be3",
"reference": "97fdb638f95bf9ca8131cc887750d70980aa7be3",
"shasum": ""
},
"require": {
@ -11037,9 +11037,9 @@
],
"support": {
"issues": "https://github.com/Laravel-Lang/http-statuses/issues",
"source": "https://github.com/Laravel-Lang/http-statuses/tree/3.10.3"
"source": "https://github.com/Laravel-Lang/http-statuses/tree/3.10.4"
},
"time": "2025-03-11T20:03:32+00:00"
"time": "2025-06-23T09:39:16+00:00"
},
{
"name": "laravel-lang/lang",
@ -12132,16 +12132,16 @@
},
{
"name": "phpunit/phpunit",
"version": "10.5.46",
"version": "10.5.47",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "8080be387a5be380dda48c6f41cee4a13aadab3d"
"reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8080be387a5be380dda48c6f41cee4a13aadab3d",
"reference": "8080be387a5be380dda48c6f41cee4a13aadab3d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3",
"reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3",
"shasum": ""
},
"require": {
@ -12213,7 +12213,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.46"
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.47"
},
"funding": [
{
@ -12237,7 +12237,7 @@
"type": "tidelift"
}
],
"time": "2025-05-02T06:46:24+00:00"
"time": "2025-06-20T11:29:11+00:00"
},
{
"name": "sebastian/cli-parser",

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('ratings', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->comment('Rating associated customer.')->constrained()
->onUpdate('cascade')->onDelete('cascade');
$table->unsignedBigInteger('rating_id')->comment('Rating key of the associated model.');
$table->unsignedTinyInteger('rate')->comment('Rating note (Enum RatingEnum).');
$table->string('rating_type')->comment('Rating associated model.');
$table->timestamps();
$table->unique(['customer_id', 'rating_id', 'rating_type']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('ratings');
}
};

12
package-lock.json generated
View file

@ -36,6 +36,7 @@
"bootstrap": "^5.3.2",
"chart.js": "^4.4.0",
"cropperjs": "^1.6.1",
"date-fns": "^4.1.0",
"eslint": "latest",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-vue": "latest",
@ -3141,6 +3142,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",

View file

@ -44,6 +44,7 @@
"bootstrap": "^5.3.2",
"chart.js": "^4.4.0",
"cropperjs": "^1.6.1",
"date-fns": "^4.1.0",
"eslint": "latest",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-vue": "latest",

View file

@ -0,0 +1,4 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 20C9.89023 20 9.31581 19.6916 8.9093 19.154L7.58372 17.3915C7.53273 17.3484 7.47212 17.3182 7.40698 17.3033H6.96512C3.28 17.3033 1 16.3075 1 11.3548V6.94852C1 3.05334 3.05907 1 6.96512 1H14.0349C17.9409 1 20 3.05334 20 6.94852V11.3548C20 15.25 17.9409 17.3033 14.0349 17.3033H13.593C13.5223 17.3033 13.4605 17.3386 13.4163 17.3915L12.0907 19.154C11.6842 19.6916 11.1098 20 10.5 20ZM6.96512 2.32189C3.8014 2.32189 2.32558 3.7936 2.32558 6.94852V11.3548C2.32558 15.3381 3.69535 15.9814 6.96512 15.9814H7.40698C7.85767 15.9814 8.37023 16.237 8.64419 16.5983L9.96977 18.3609C10.2791 18.7662 10.7209 18.7662 11.0302 18.3609L12.3558 16.5983C12.6474 16.2106 13.107 15.9814 13.593 15.9814H14.0349C17.1986 15.9814 18.6744 14.5097 18.6744 11.3548V6.94852C18.6744 3.7936 17.1986 2.32189 14.0349 2.32189H6.96512Z" fill="#FBF8F5" stroke="#FBF8F5" stroke-width="0.3"/>
<path d="M14.9185 7.60949H6.08125C5.71892 7.60949 5.41846 7.30986 5.41846 6.94854C5.41846 6.58723 5.71892 6.2876 6.08125 6.2876H14.9185C15.2808 6.2876 15.5812 6.58723 15.5812 6.94854C15.5812 7.30986 15.2808 7.60949 14.9185 7.60949ZM11.3836 12.0158H6.08125C5.71892 12.0158 5.41846 11.7162 5.41846 11.3549C5.41846 10.9935 5.71892 10.6939 6.08125 10.6939H11.3836C11.7459 10.6939 12.0464 10.9935 12.0464 11.3549C12.0464 11.7162 11.7459 12.0158 11.3836 12.0158Z" fill="#FBF8F5" stroke="#FBF8F5" stroke-width="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -39,5 +39,6 @@ return [
'breeder_map_component' => '\'Breeders map\' component',
'discount' => 'discount',
'sell_item' => 'item on sale|items on sale',
'rating' => 'rating',
]
];

View file

@ -39,5 +39,6 @@ return [
'breeder_map_component' => 'composant \'Carte des éleveurs\'',
'discount' => 'réduction',
'sell_item' => 'objet en vente|objets en vente',
'rating' => 'note',
]
];

View file

@ -136,17 +136,6 @@
}
}
.modal {
z-index: 1035 !important;
button {
top: -0.75rem;
right: -0.75rem;
width: 2rem;
height: 2rem;
}
}
.tag-new {
top: -1.5rem;
left: 3rem;
@ -222,6 +211,17 @@
}
}
}
.modal {
z-index: 1035 !important;
.btn-modal-close {
top: -0.75rem;
right: -0.75rem;
width: 2rem;
height: 2rem;
}
}
}
// Set modal filter behind cookies.

View file

@ -0,0 +1,359 @@
<template>
<div :class="`bg-tertiary line-border-large pt-2 pb-5 ${wrapperClass}`">
<div class="container row mx-auto">
<!-- RATE -->
<div class="col-12 col-md-6 col-xl-3 text-center mb-3 mb-md-0 mx-auto">
<h5 class="h3 f-bourton-base text-uppercase">
{{ trans.methods.__('Vos avis comptent') }}
</h5>
<template v-if="rating && rating > 2">
<p class="h2 m-0">
{{ rating }}/5
</p>
<p>
{{ trans.methods.__('Note globale sur :number avis', { number: String(ratingModelsCount) }) }}
</p>
</template>
<div
v-if="customerKey"
class="bottom-left-shadow d-flex justify-content-center align-items-center mx-auto"
>
<div class="bg-white line-border-small w-fit p-1">
<p class="f-bakerie-rough fs-4 fw-bold mb-0 mx-auto px-2">
{{ getTranslationButton }}
</p>
<div class="d-flex justify-content-center align-items-center">
<RatingStarsComponent
wrapper-class="mx-auto"
:rating="customerRating ? Number(customerRating.rate) : 0"
:read-only="false"
@update:rating="sendRating"
/>
<button
v-if="customerRating"
class="btn-remove-rating btn btn-sm btn-light ms-1 my-1 p-2"
:title="trans.methods.__('Supprimer votre note')"
@click="sendRating(0)"
type="button"
>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.4462 0.397052L7.5303 0.469673L7.6101 0.563622C7.799 0.827012 7.7966 1.18522 7.6029 1.44621L7.5303 1.53033L5.061 4L7.5303 6.4697L7.6101 6.5636C7.799 6.827 7.7966 7.1852 7.6029 7.4462L7.5303 7.5303L7.4364 7.6101C7.173 7.799 6.8148 7.7966 6.5538 7.6029L6.4697 7.5303L4 5.061L1.53033 7.5303L1.43638 7.6101C1.17299 7.799 0.814783 7.7966 0.553792 7.6029L0.469673 7.5303L0.389883 7.4364C0.201013 7.173 0.203402 6.8148 0.397052 6.5538L0.469673 6.4697L2.939 4L0.469673 1.53033L0.389883 1.43638C0.201013 1.17299 0.203402 0.814783 0.397052 0.553792L0.469673 0.469673L0.563622 0.389883C0.827012 0.201013 1.18522 0.203402 1.44621 0.397052L1.53033 0.469673L4 2.939L6.4697 0.469673L6.5636 0.389883C6.827 0.201013 7.1852 0.203402 7.4462 0.397052Z"
/>
</svg>
</button>
</div>
</div>
</div>
<div
v-else
class="my-3"
>
<a
:href="String(route.methods.route('club.login'))"
class="d-flex justify-content-center align-items-center btn btn-primary btn-club rounded-pill"
>
<img
class="img-fluid me-2"
src="@/images/common/club-logo.png"
:title="trans.methods.__('Se connecter au club')"
:alt="trans.methods.__('Logo du club Le Gaulois')"
>
{{ trans.methods.__('Veuillez vous connecter au club Le Gaulois pour noter') }}
</a>
</div>
<!-- ERRORS MESSAGES -->
<Transition name="fade">
<div
v-if="errorMessages && errorMessages.length"
class="col-12"
>
<p
v-for="(message, messageIndex) in errorMessages"
:key="messageIndex"
class="text-danger m-0"
>
{{ message }}
</p>
</div>
</Transition>
</div>
<!-- RATINGS SLIDER -->
<div
v-if="rating && rating > 2 && ratingModels.length"
class="col-12 col-md-6 col-xl-9 position-relative d-flex justify-content-center align-items-center px-sm-5"
>
<div class="w-100">
<div
:id="`swiper-ratings`"
class="swiper swiper-ratings overflow-hidden px-3 pt-1 pb-4 pb-sm-3"
>
<div class="swiper-wrapper align-items-stretch w-fit">
<div
v-for="(model, modelKey) in ratingModels"
:key="modelKey"
class="swiper-slide position-relative bottom-left-shadow"
>
<div class="customer-icon-wrapper position-absolute top-0 start-50 translate-middle-x d-flex justify-content-center align-items-center bg-primary rounded-circle p-2 z-1">
<img
src="@/images/ratings/customer-icon.png"
:title="getTranslationButton"
:alt="trans.methods.__('Bulle de discussion')"
class="img-fluid"
>
</div>
<div class="line-border-small bg-white text-center h-auto mt-4 p-4 w-fit mx-auto">
<p class="fw-bold mt-2 mb-1">
{{ model.customer_username }}
</p>
<p class="mb-1">
{{ format(parseISO(String(model.created_at)), 'dd/MM/yyyy') }}
</p>
<div class="d-flex justify-content-center align-items-center">
<RatingStarsComponent :rating="Number(model.rate)" />
<p class="h5 m-0">
{{ model.rate }}/5
</p>
</div>
</div>
</div>
</div>
</div>
<button
class="swiper-button swiper-ratings-button-prev swiper-button-prev-custom btn btn-white position-absolute rounded-circle border-0 p-1 z-2"
:title="trans.methods.__('Suivant')"
:aria-label="trans.methods.__('Suivant')"
type="button"
>
<img
src="@/images/common/arrow-brown.svg"
title="{{ __('Navigation du slider (gauche)') }}"
:alt="trans.methods.__('flèche vers la gauche')"
>
</button>
<button
class="swiper-button swiper-ratings-button-next swiper-button-next-custom btn btn-white position-absolute rounded-circle border-0 p-1 z-2"
:title="trans.methods.__('Précédent')"
:aria-label="trans.methods.__('Précédent')"
type="button"
>
<img
src="@/images/common/arrow-brown.svg"
:title="trans.methods.__('Navigation du slider (droite)')"
:alt="trans.methods.__('flèche vers la droite')"
>
</button>
<div
v-if="ratingModels.length > 1"
class="swiper-pagination swiper-ratings-pagination position-relative pb-3 pb-sm-0 z-1"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineOptions, onMounted, useAttrs, ref, nextTick, computed } from "vue";
import { format, parseISO } from "date-fns";
import Swiper from "swiper";
import { Navigation, Pagination } from "swiper/modules";
import trans from "../../modules/trans";
import route from "../../modules/route";
import toast from "../../modules/toast";
import RatingStarsComponent from "./RatingStarsComponent.vue";
defineOptions({
name: "RatingSectionComponent"
});
// * ATTRIBUTES
const attrs = useAttrs();
// * DATA
const currentRatedModel = ref<LaravelModel|null>(null);
const customerKey = ref<number|null>(null);
const ratingModels = ref<Array<LaravelModel>>([]);
const ratingModelsCount = ref<number>(0);
const rating = ref<number|null>(null);
const ratingType = ref<string>("");
const swiper = ref<Swiper|null>(null);
const wrapperClass = ref<string>("");
const customerRating = ref<LaravelModel|null>(null);
const errorMessages = ref<Array<string>>([]);
// COMPUTED
const getTranslationButton = computed(() => {
return trans.methods.__(customerRating.value ? "Votre note" : "Donnez votre note !");
});
// * LIFECYCLE
onMounted(() => {
const json = String(attrs.json ?? "{}"),
data = JSON.parse(json);
currentRatedModel.value = data.currentRatedModel;
customerKey.value = data.customerKey;
wrapperClass.value = data.wrapperClass;
ratingModels.value = data.ratingsData.ratings;
ratingModelsCount.value = data.ratingsData.total;
ratingType.value = data.ratingsData.ratingType;
rating.value = data.ratingsData.totalRate;
customerRating.value = data.ratingsData.customerRating;
nextTick(() => {
setSwiper();
});
});
// * METHODS
function sendRating(value: number) {
const getRatingStoreRoute = route.methods.route("fo.ratings.store");
if (!getRatingStoreRoute) {
throw new Error("Undefined route " + getRatingStoreRoute);
}
window.axios
.post(getRatingStoreRoute, {
rate: value,
rating_id: currentRatedModel.value?.id,
rating_type: ratingType.value,
customer_id: customerKey.value,
})
.then((response: { data: any; }) => {
ratingModels.value = response.data?.data.ratings;
ratingModelsCount.value = response.data?.data.total;
ratingType.value = response.data?.data.ratingType;
rating.value = response.data?.data.totalRate;
customerRating.value = response.data?.data.customerRating;
toast.methods.notify(response.data.message);
})
.finally(() => {
nextTick(() => {
setSwiper();
});
})
.catch(ajaxErrorHandler);
return;
}
/**
* Return error message after the ajax call.
* @return void
*/
function ajaxErrorHandler(error: any): void {
if (error.response) {
switch (error.response.status) {
case 403:
errorMessages.value = [trans.methods.__("Vous n'êtes pas autorisé à effectuer cette action")];
break;
case 419:
errorMessages.value = [trans.methods.__("Votre session a expiré, la page sera rechargée")];
setTimeout(() => window.location.reload(), 2000);
break;
case 422:
// eslint-disable-next-line no-case-declarations
let allErrors = Object.values(error.response.data.errors) as Array<string>;
errorMessages.value = allErrors.map((error) => error[0]);
break;
default:
errorMessages.value = [trans.methods.__("Une erreur est survenue")];
console.error(trans.methods.__("Une erreur est survenue"));
}
toast.methods.notify(errorMessages.value.length > 1 ? errorMessages.value.join(", ") : errorMessages.value[0]);
} else if (error.request) {
console.error(error.request);
} else {
console.error("Error", error.message);
}
setTimeout(() => errorMessages.value = [], 4000);
}
/** Init ratings slider. */
function setSwiper(): void {
if (swiper.value) {
swiper.value.destroy(true, true);
swiper.value = null;
}
if (ratingModels.value.length === 0) return;
const isSmall = ratingModels.value.length <= 2;
const customOptions = {
modules: [Navigation, Pagination],
grabCursor: true,
slidesPerView: 1,
slidesPerGroup: 1,
centeredSlides: true,
spaceBetween: 10,
navigation: {
nextEl: ".swiper-ratings-button-next",
prevEl: ".swiper-ratings-button-prev",
},
breakpoints: {
1200: {
slidesPerView: isSmall ? ratingModels.value.length : 3,
slidesPerGroup: isSmall ? ratingModels.value.length : 3,
centeredSlides: false,
},
1400: {
slidesPerView: isSmall ? ratingModels.value.length : 3,
slidesPerGroup: isSmall ? ratingModels.value.length : 3,
centeredSlides: false,
spaceBetween: 35,
}
},
pagination: {
el: ".swiper-ratings-pagination",
clickable: true,
renderBullet: function (index: number, className: string) {
return "<button class=\"" + className + "\" title=\"Aller à la slide " + (index + 1) + "\"></button>";
},
},
};
swiper.value = new Swiper(".swiper-ratings", customOptions);
}
</script>
<style lang="scss" scopped>
@import "bootstrap/scss/functions";
@import "../../../sass/fo/utilities/variables";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
.btn-club img {
width: 4rem;
}
.btn-remove-rating {
fill: $color-secondary;
}
.swiper-ratings-button-prev,
.swiper-ratings-button-next {
top: auto;
bottom: 0;
@include media-breakpoint-up(sm) {
top: 50%;
bottom: auto;
}
}
.customer-icon-wrapper {
width: 3rem;
height: 3rem;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.fade-enter-to, .fade-leave-from {
opacity: 1;
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<div
ref="wrapperRef"
:class="`rating-stars-wrapper position-relative bg-tertiary ${wrapperClass} px-2`"
@mouseleave="hovered = 0"
@focusout="onFocusOut"
>
<!-- Yellow background -->
<div class="position-relative h-100">
<div
class="position-absolute bg-warning h-100"
:style="`width: ${(computedRating / 5) * 100}%;`"
/>
</div>
<!-- SVG mask with cut-out stars -->
<svg
class="position-absolute top-0 start-0"
viewBox="0 0 142 32"
>
<defs>
<mask
id="star-mask"
maskUnits="userSpaceOnUse"
>
<rect
class="w-100 h-100"
fill="white"
/>
<g>
<template
v-for="i in 5"
:key="i"
>
<path
:transform="`translate(${10 + (i - 1) * 25},5)`"
d="M11.0905 0.45459L14.654 6.1861L21.2062 7.80414L16.8565 12.9644L17.3424 19.696L11.0905 17.1537L4.83857 19.696L5.32447 12.9644L0.974682 7.80414L7.52688 6.1861L11.0905 0.45459Z"
fill="black"
/>
</template>
</g>
</mask>
</defs>
<rect
class="w-100 h-100"
fill="white"
mask="url(#star-mask)"
/>
</svg>
<!-- Selected div when read only is false -->
<div
v-if="!readOnly"
class="buttons-wrapper position-absolute top-0 start-0 d-flex justify-content-center px-2"
>
<template
v-for="i in 5"
:key="i"
>
<button
@click="selectRating(i)"
@mouseover="hovered = i"
@focus="hovered = i"
class="btn btn-warning bg-transparent border-0 p-0"
/>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, defineProps, defineEmits, watch } from "vue";
const emit = defineEmits(["update:rating"]);
const props = defineProps({
rating: {
type: Number,
default: null,
},
wrapperClass: {
type: String,
default: "",
},
readOnly: {
type: Boolean,
default: true,
},
});
const wrapperRef = ref<HTMLElement | null>(null);
const hovered = ref(0);
const selected = ref<number | null>(props.rating);
const computedRating = computed(() => {
if (props.readOnly) return props.rating ?? 0;
return hovered.value || selected.value || 0;
});
watch(() => props.rating, (val) => {
selected.value = val;
});
const selectRating = (value: number) => {
if (!props.readOnly) {
selected.value = value;
emit("update:rating", value);
}
};
const onFocusOut = (e: FocusEvent) => {
const newFocused = e.relatedTarget as HTMLElement | null;
if (!wrapperRef.value?.contains(newFocused)) {
hovered.value = 0;
}
};
</script>
<style lang="scss" scoped>
.rating-stars-wrapper {
&, svg {
width: 142px;
height: 32px;
}
.buttons-wrapper {
width: 142px;
height: 32px;
z-index: 10;
button {
flex: 1;
&:not(:focus) {
box-shadow: none;
}
}
}
}
</style>

View file

@ -3,6 +3,7 @@
<thead>
@include('bo.modules.table-col-sorter', [
'cols' => [
'ratings_avg_rate' => str(__('models.classes.rating'))->ucfirst(),
'label' => str(__('validation.custom.label'))->ucfirst(),
'packaging' => str(__('validation.attributes.image'))->ucfirst(),
'published' => str(__('validation.custom.publication'))->ucfirst(),
@ -13,6 +14,15 @@
<tbody>
@foreach ($productModels as $productModel)
<tr class="border-bottom">
<td class="text-center align-middle">
@if ($productModel->ratings->count())
<p class="{{ $productModel->ratings_avg_rate > 2.0 ? 'text-success' : 'text-danger' }} fw-bold m-0">
{{ round($productModel->ratings_avg_rate, 1) }}
</p>
@else
<small class="fst-italic text-secondary">{{ __('Aucune note') }}</small>
@endif
</td>
<td class="text-center align-middle">{{ $productModel->label }}</td>
<td class="text-center align-middle">
<img class="image-preview" src="{{ asset($productModel->packaging) }}" title="{{ $productModel->packaging_title }}"

View file

@ -3,6 +3,7 @@
<thead>
@include('bo.modules.table-col-sorter', [
'cols' => [
'ratings_avg_rate' => str(__('models.classes.rating'))->ucfirst(),
'title' => str(__('validation.attributes.title'))->ucfirst(),
'picture' => str(__('validation.attributes.image'))->ucfirst(),
'highlight' => str(__('validation.custom.highlight'))->ucfirst(),
@ -14,6 +15,15 @@
<tbody>
@foreach ($recipeModels as $recipeModel)
<tr class="border-bottom">
<td class="text-center align-middle">
@if ($recipeModel->ratings->count())
<p class="{{ $recipeModel->ratings_avg_rate > 2.0 ? 'text-success' : 'text-danger' }} fw-bold m-0">
{{ round($recipeModel->ratings_avg_rate, 1) }}
</p>
@else
<small class="fst-italic text-secondary">{{ __('Aucune note') }}</small>
@endif
</td>
<td class="text-center align-middle">{{ $recipeModel->title }}</td>
<td class="text-center align-middle">
<img class="image-preview" src="{{ asset($recipeModel->picture) }}"

View file

@ -118,7 +118,6 @@
{{-- BTN INGREDIENTS --}}
<div class="col-12">
<button class="btn btn-white my-3" data-bs-toggle="modal" data-bs-target="#productIngredients">
{{-- FIXME: add nutritionnal values when it's ready --}}
{{ __('ingrédients & valeurs nutritionnelles') }}
</button>
</div>
@ -210,7 +209,7 @@
{{-- PRODUCTTYPE/DEMARCHE DESKTOP --}}
@if (count($productModel->productTypes))
{{-- CHANGEME: quand la relation product<->productType aura été changé, adapter cette partie --}}
<div class="product-paper product-paper-demarche d-none d-xl-block position-absolute bottom-left-shadow">
<div class="product-paper product-paper-demarche d-none d-xl-block position-absolute bottom-left-shadow" tabindex="0">
<img class="img-fluid position-absolute z-1" src="{{ asset($productModel->productTypes[0]->picture) }}"
title="{{ $productModel->productTypes[0]->picture_title }}"
alt="{{ $productModel->productTypes[0]->picture_alt }}">
@ -260,7 +259,7 @@
<div class="modal fade" id="productIngredients" aria-labelledby="productIngredientsLabel" aria-hidden="true" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg px-4">
<div class="modal-content border-0 bg-transparent">
<button class="btn btn-secondary rounded-circle position-absolute z-1 p-1" data-bs-dismiss="modal" type="button"
<button class="btn btn-secondary btn-modal-close rounded-circle position-absolute z-1 p-1" data-bs-dismiss="modal" type="button"
aria-label="Close">
<img class="img-fluid" src="{{ Vite::asset('resources/assets/images/product/modal-cross.svg') }}"
title="{{ __('Bouton de fermeture de la modale') }}" alt="{{ __('Croix marron') }}">
@ -339,12 +338,21 @@
</div>
</div>
</section>
{{-- COMMITMENTS/BANNER --}}
{{-- RATINGS/COMMITMENTS/BANNER --}}
<section class="position-relative bottom-left-shadow" id="product-commitments">
<div class="bg-tertiary line-border-large mb-lg-5 mb-xl-0 mb-0 pt-5">
{{-- RATINGS--}}
@php $data = [
'currentRatedModel' => $productModel->toArray(),
'customerKey' => auth('club')->check() ? auth('club')->user()->getKey() : null,
'ratingsData' => $ratingsData,
]; @endphp
<section class="rating-section z-4" data-json='@json($data)'></section>
{{-- COMMITMENTS--}}
@include('fo.partials.commitments.list-pages', [
'showBtn' => false,
])
{{-- BANNER--}}
<img class="line-border-large w-100"
src="{{ asset($productModel->banner ? $productModel->banner : ($productModel->range ? $productModel->range->banner_ambiance : $productModel->subrange->range->banner_ambiance)) }}"
title="{{ $productModel->banner ? $productModel->banner_title : ($productModel->range ? $productModel->range->banner_ambiance_title : $productModel->subrange->range->banner_ambiance_title) }}"

View file

@ -168,6 +168,14 @@
</div>
</div>
</section>
{{-- RATINGS--}}
@php $data = [
'currentRatedModel' => $recipeModel->toArray(),
'customerKey' => auth('club')->check() ? auth('club')->user()->getKey() : null,
'ratingsData' => $ratingsData,
'wrapperClass' => 'mt-5 pt-5'
]; @endphp
<section class="rating-section z-4" data-json='@json($data)'></section>
{{-- RECIPES --}}
@if ($recipeModels->isNotEmpty() && $associatedProductModels->count())
@include('fo.partials.cards-slider', [

View file

@ -56,8 +56,13 @@
window.__SYSTEM._routes['fo.breeders.get-manageable-text-component'] =
'{{ route('fo.breeders.get-manageable-text-component') }}';
window.__SYSTEM._routes['fo.breeders.search-ocb'] = '{{ route('fo.breeders.search-ocb') }}';
window.__SYSTEM._routes['fo.news.filtered'] = '{{ route('fo.news.filtered', ['legend_slug' => 'LEGENDSLUG']) }}';
window.__SYSTEM._routes['fo.news.filtered'] = '{{ route('fo.news.filtered', ['legend_slug' => 'LEGENDSLUG']) }}';
window.__SYSTEM._routes['fo.policies.show'] = '{{ route('fo.policies.show') }}';
window.__SYSTEM._routes['fo.legals.show'] = '{{ route('fo.legals.show') }}';
window.__SYSTEM._routes['fo.ratings.store'] = '{{ route('fo.ratings.store') }}';
window.__SYSTEM._routes['fo.ratings.update'] = '{{ route('fo.ratings.update') }}';
// CLUB
window.__SYSTEM._routes['club.login'] = '{{ route('club.login') }}';
window.__SYSTEM._routes['club.departaimants.save-state'] = '{{ route('club.departaimants.save-state', ['collectiontype' => 'TYPE']) }}';
window.__SYSTEM._routes['club.departaimants.search'] = '{{ route('club.departaimants.search', ['collectiontype' => 'TYPE']) }}';
window.__SYSTEM._routes['club.notifications.count'] = '{{ route('club.notifications.count') }}';
@ -70,6 +75,5 @@
window.__SYSTEM._routes['club.shop.validate'] = '{{ route('club.shop.validate') }}';
window.__SYSTEM._routes['club.messages.report-customer'] = '{{ route('fo.contact.show') }}';
window.__SYSTEM._routes['club.shop.index'] = '{{ route('club.shop.index') }}';
window.__SYSTEM._routes['club.favorite.update'] =
'{{ route('club.favorite.update', ['customer' => 'CUSTOMER', 'model' => 'MODEL', 'class' => 'CLASS']) }}';
window.__SYSTEM._routes['club.favorite.update'] = '{{ route('club.favorite.update', ['customer' => 'CUSTOMER', 'model' => 'MODEL', 'class' => 'CLASS']) }}';
</script>

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Fo\ExportController;
use App\Http\Controllers\Fo\HomeController;
use App\Http\Controllers\Fo\NewsController;
use App\Http\Controllers\Fo\ProductController;
use App\Http\Controllers\Fo\RatingController;
use App\Http\Controllers\Fo\RecipeController;
use App\Http\Controllers\Fo\SearchController;
use App\Http\Controllers\Fo\StaticPageController;
@ -177,5 +178,12 @@ Route::middleware(['throttle:web'])->name('fo.')->group(function () {
->name('search-ocb');
});
// * RATINGS
Route::post('/send-rating', [RatingController::class, 'store'])
->name('ratings.store');
Route::get('/update-rating', [RatingController::class, 'update'])
->name('ratings.update');
// * OTHERS
Route::view('/tour-de-france', 'fo.pages.tdf')->name('tdf');
});