Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Voir cette série
Shop : les promotions 2/2
Samedi 15 février 2025 13:04

Dans le précédent article, nous avons mis en place tout ce qui nous est nécessaire pour gérer et afficher des promotions spécifiques à certains produits. Mais il peut arriver de vouloir appliquer une réduction de prix à l'ensemble des produits d'une boutique. Dans ce cas, il serait vraiment laborieux de modifier les produits un par un ! Alors, nous allons trouver dans cet article une façon plus simple et rapide de procéder.

Vous pouvez trouver le code dans ce dépôt Github.

Avant de commencer

Comme le projet a évolué sur Github, vous aurez besoin de créer ce service pour que tout fonctionne (merci Dasse pour le signalement du problème) :

Avec ce code :

<?php

namespace App\Services;

use App\Models\Order;
use App\Traits\ManageOrders;
use Illuminate\Database\Eloquent\Builder;

class OrderService {
	use ManageOrders;

	private object $req;
	private $adaptedReq;

	public function __construct($params) {
		$this->sortBy = $params->sortBy;
		$this->search = $params->search;
	}

	public function req () {
		$this->adaptedReq = 'sqlite' === config('database.default', 'mysql') ? "users.name || ' ' || users.firstname" : "CONCAT(users.name, ' ', users.firstname)";

		return Order::with('user', 'state', 'addresses')
			->when(
				'user' === $this->sortBy['column'],
				function ($query) {
					$query->orderBy(function ($query) {
						$query
							->selectRaw(
								'COALESCE(
                                        (SELECT company FROM order_addresses WHERE order_addresses.order_id = orders.id LIMIT 1),
                                        (SELECT ' .
								$this->adaptedReq .
								' FROM users
                                        WHERE users.id = orders.user_id)
                                    )',
							)
							->limit(1);
					}, $this->sortBy['direction']);
				},
				function ($query) {
					$query->orderBy(...array_values($this->sortBy));
				},
			)
			->when($this->search, function (Builder $q) {
				$q->where('reference', 'like', "%{$this->search}%")
					->orWhereRelation('addresses', 'company', 'like', "%{$this->search}%")
					->orWhereRelation('user', 'name', 'like', "%{$this->search}%")
					->orWhereRelation('user', 'firstname', 'like', "%{$this->search}%")
					->orWhere('total', 'like', "%{$this->search}%")
					->orWhere('invoice_id', 'like', "%{$this->search}%");
			});
	}
}

D'une manière générale, lorsque vous avez une fonction qui manque, vous pouvez aller jeter un coup d'œil sur le dépôt dans lequel elle y est certainement !

Les données

Nous allons à nouveau avoir à gérer des données. Faisons le point :

  • un pourcentage de réduction à appliquer aux produits
  • une date de départ de la promotion
  • une date de fin de la promotion

Dans quelle table allons-nous placer ces informations ? Il existe plusieurs possibilités, mais la plus cohérente est de créer une table de configuration globale pour stocker des paramètres comme les remises globales. 

On crée une nouvelle table et son modèle avec Artisan :

php artisan make:model Setting --migration

La migration

Pour la migration, on va faire simple avec un système classique clé/valeur avec toutefois l'ajout de données pour les dates :

public function up(): void
{
    Schema::create('settings', function (Blueprint $table) {
        $table->id();
        $table->string('key')->unique();
        $table->text('value')->nullable();
        $table->date('date1')->nullable();
        $table->date('date2')->nullable();
    });
}

Si on utilise plus tard cette table de paramétrage pour des données qui ne nécessitent pas de date, ce n'est pas bien gênant.

Il faut enfin régénérer la base :

php artisan migrate

Le modèle

Pour le modèle, on signale qu'on n'a pas besoin du timestamp, on transforme les dates et on complète la propriété $fillable :

class Setting extends Model
{
	public $timestamps = false;

	protected $fillable = ['key', 'value', 'date1', 'date2'];

	protected function casts(): array
    {
        return [
            'date1' => 'datetime:Y-m-d',
            'date2' => 'datetime:Y-m-d',
        ];
    }
}

Un composant

On crée un composant pour la gestion de la promotion globale :

php artisan make:volt admin/products/promotion

Avec ce code :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\{Layout, Title};
use App\Models\Setting;
use Mary\Traits\Toast;
use Carbon\Carbon;

new #[Layout('components.layouts.admin')] #[Title('Global promotion')] class extends Component {

    use Toast;

    public bool $promotion = false;
    public ?int $promotion_percentage = null;
    public ?string $promotion_start_date = null;
    public ?string $promotion_end_date = null;
    public Setting $setting;

    public function mount(): void
    {
        $this->setting = Setting::where('key', 'promotion')->firstOrCreate(['key' => 'promotion']);
        $this->promotion = !is_null($this->setting->value);
        if($this->promotion){
            $this->promotion_percentage = $this->setting->value;
            $this->promotion_start_date = $this->setting->date1->format('Y-m-d');
            $this->promotion_end_date = $this->setting->date2->format('Y-m-d');
        }
    }

    public function save(): void
    {
        $data = $this->validate(
            [
                'promotion_percentage' => 'required_if:promotion,true|nullable|numeric|min:0|max:100',
                'promotion_start_date' => 'required_if:promotion,true|nullable|date',
                'promotion_end_date' => 'required_if:promotion,true|nullable|date|after:promotion_start_date',
            ]
        );

        $data = [
            'value' => $this->promotion ? $this->promotion_percentage : null,
            'date1' => $this->promotion ? $this->promotion_start_date : null,
            'date2' => $this->promotion ? $this->promotion_end_date : null,
        ];

        $this->setting->update($data);

        $this->success(__('Promotion updated successfully.'), redirectTo: '/admin/dashboard');
    }
    
}; ?>

<div>
    <x-header title="{!! __('Global promotion') !!}" separator progress-indicator >
        <x-slot:actions>
            <x-button 
                icon="s-building-office-2" 
                label="{{ __('Dashboard') }}" 
                class="btn-outline lg:hidden" 
                link="{{ route('admin') }}" 
            />
        </x-slot:actions>
    </x-header>
    <x-card >
        <x-form wire:submit="save">
            <x-checkbox label="{{ __('Active promotion') }}" wire:model="promotion" wire:change="$refresh" />
        
            @if($promotion)
                <x-input 
                    label="{{ __('Percentage discount') }}" 
                    wire:model="promotion_percentage" 
                    placeholder="{{ __('Enter discount percentage') }}"
                    type="number"
                />
        
                <x-datetime  
                    label="{{ __('Start date') }}" 
                    icon="o-calendar"
                    wire:model="promotion_start_date" 
                />
        
                <x-datetime  
                    label="{{ __('End date') }}" 
                    icon="o-calendar"
                    wire:model="promotion_end_date"
                />                
            @endif

            <x-slot:actions>    
                <x-button 
                    label="{{ __('Save') }}" 
                    icon="o-paper-airplane" 
                    spinner="save" 
                    type="submit" 
                    class="btn-primary" 
                />
            </x-slot:actions>

        </x-form>   
    </x-card>
</div>

On ajoute les traductions :

"Global promotion": "Promotion globale",
"Active promotion": "Promotion active",
"Start date": "Date de début",
"End date": "Date de fin",
"Percentage discount": "Remise en pourcentage",
"Enter discount percentage": "Entrez le pourcentage de remise",
"Promotion updated successfully.": "Promotion mise à jour avec succès.",

La route :

Volt::route('/products/promotion', 'admin.products.promotion')->name('admin.products.promotion');

Un bouton pour y accéder sur la page des produits (admin.products.index) :

<x-header title="{{ __('Catalog') }}" separator progress-indicator>
    <x-slot:actions>
        <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline lg:hidden"
            link="{{ route('admin') }}" />
            <x-button icon="o-currency-euro" label="{!! __('Global promotion') !!}" link="/admin/products/promotion" spinner class="btn-success" />
        <x-button icon="o-plus" label="{!! __('Create a new product') !!}" link="/admin/products/create" spinner class="btn-primary" />
    </x-slot:actions>
</x-header>

On obtient la page en cliquant sur le bouton, au départ juste la case à cocher non active :

Et ensuite, en activant, on accède au formulaire :

On doit ajouter une traduction dans js.validation :

"promotion_percentage"     => "pourcentage de promotion",

Vérifiez les validations :

Le tableau de bord

Il est judicieux de rappeler, au niveau du tableau de bord, qu'une remise globale est prévue. Je remets le code complet :

<?php

use App\Traits\ManageOrders;
use Livewire\Volt\Component;
use App\Services\OrderService;
use App\Models\{Order, Product, User, Setting};
use Barryvdh\Debugbar\Facades\Debugbar;
use Livewire\Attributes\{Layout, Title};

new #[Layout('components.layouts.admin')]
class extends Component {
    use ManageOrders;

    public bool $openGlance = true;
    public bool $openOrders = true;
    public bool $paginationOrders = false;

    public function headersProducts(): array
    {
        return [
            ['key' => 'image', 'label' => ''],
            ['key' => 'name', 'label' => __('Name')],
            ['key' => 'quantity_alert', 'label' => __('Quantity alert'), 'class' => 'text-right'],
            ['key' => 'quantity', 'label' => __('Quantity'), 'class' => 'text-right'],
        ];
    }

    public function with(): array
    {
        $orders = (new OrderService($this))->req()->take(6)->get();
        $orders = $this->setPrettyOrdersIndexes($orders);

        $promotion = Setting::where('key', 'promotion')->firstOrCreate(['key' => 'promotion']);
        $textPromotion = '';

        if ($promotion->value) {
            $now = now();
            if ($now->isBefore($promotion->date1)) {
                $textPromotion = transL('Coming soon');
            } elseif ($now->between($promotion->date1, $promotion->date2)) {
                $textPromotion = trans('in progress');
            } else {
                $textPromotion = transL('Expired_feminine');
            }
        }

        return [
            'productsCount' => Product::where('active', true)->count(),
            'ordersCount' => Order::whereRelation('state', 'indice', '>', 3)
                                  ->whereRelation('state', 'indice', '<', 6)
                                  ->count(),
            'usersCount' => User::count(),
            'orders' => $orders->collect(),
            'promotion' => $promotion,
            'textPromotion' => $textPromotion,
            'headersOrders' => $this->headersOrders(),
            'productsDown' => Product::whereColumn('quantity', '<=', 'quantity_alert')->orderBy('quantity', 'asc')->get(),
            'headersProducts' => $this->headersProducts(),
            'row_decoration' => [
                'bg-red-400' => fn(Product $product) => $product->quantity == 0,
            ]
        ];
    }
}; ?>

<div>
    <x-collapse wire:model="openGlance" class="shadow-md">
        <x-slot:heading>
            @lang('In a glance')
        </x-slot:heading>
        <x-slot:content class="flex flex-wrap gap-4">
            <a href="/" class="flex-grow">
                <x-stat title="{{ __('Active products') }}" description="" value="{{ $productsCount }}"
                    icon="s-shopping-bag" class="shadow-hover" />
            </a>
            <a href="{{ route('admin.orders.index') }}" class="flex-grow">
                <x-stat title="{{ __('Successful orders') }}" description="" value="{{ $ordersCount }}"
                    icon="s-shopping-cart" class="shadow-hover" />
            </a>
            <a href="{{ route('admin.customers.index') }}" class="flex-grow">
                <x-stat title="{{ __('Customers') }}" description="" value="{{ $usersCount }}" icon="s-user"
                    class="shadow-hover" />
            </a>
        </x-slot:content>
    </x-collapse>

    @if(!is_null($promotion->value))
        <br>
        <x-alert title="{{ __('Global promotion') }} {{ $textPromotion }}" description="{{ __('From') }} {{ $promotion->date1->isoFormat('LL') }} {{ __('to') }} {{ $promotion->date2->isoFormat('LL') }} {{ __L('Percentage discount') }} {{ $promotion->value }}%" icon="o-currency-euro" class="alert-warning" >
            <x-slot:actions>
                <x-button label="{{ __('Edit') }}" class="btn-outline" link="{{ route('admin.products.promotion') }}" />
            </x-slot:actions>
        </x-alert>
    @endIf

    <x-header separator progress-indicator />

    @if($productsDown->isNotEmpty())
        <x-collapse class="shadow-md bg-red-500">
            <x-slot:heading>
                @lang('Stock alert')
            </x-slot:heading>       
            <x-slot:content>
                <x-card class="mt-6" title="" shadow separator>
                    <x-table striped :rows="$productsDown" :headers="$headersProducts" link="/admin/products/{id}/edit" :row-decoration="$row_decoration" >
                        @scope('cell_image', $product)
                            <img src="{{ asset('storage/photos/' . $product->image) }}" width="60" alt="">
                        @endscope
                    </x-table>
                    <x-slot:actions>
                        <x-button label="{{ __('See all products') }}" class="btn-primary" icon="s-list-bullet"
                            link="{{ route('admin.products.index') }}" />
                    </x-slot:actions>
                </x-card>
            </x-slot:content>
        </x-collapse>
        <br>
    @endif

    <x-collapse wire:model="openOrders" class="shadow-md">
        <x-slot:heading>
            @lang('Latest orders')
        </x-slot:heading>

        <x-slot:content>
            <x-card class="mt-6" title="" shadow separator>
                @include('livewire.admin.orders.table')
                <x-slot:actions>
                    <x-button label="{{ __('See all orders') }}" class="btn-primary" icon="s-list-bullet"
                        link="{{ route('admin.orders.index') }}" />
                </x-slot:actions>
            </x-card>
        </x-slot:content>
    </x-collapse>

</div>

On distingue les trois cas :

  • prochainement
  • en cours
  • expirée

Un bouton permet d'aller directement sur le formulaire.

La page d'accueil

Sur la page d'accueil de la boutique, il faut signaler efficacement  cette promotion en barrant le prix de base et en faisant apparaître en rouge le prix réduit. Il faut aussi comparer une éventuelle promotion sur un produit avec la promotion globale et garder la meilleure. On va créer un nouvel helper pour ce calcul (app.helpers) :

if (!function_exists('getBestPrice')) {
	function getBestPrice($product)
	{
		$promoGlobal = \App\Models\Setting::where('key', 'promotion')->first();

		// Vérifie si la promotion globale est valide
		$globalPromoValid = $promoGlobal && $promoGlobal->value && now()->between($promoGlobal->date1, $promoGlobal->date2);

		// Vérifie si la promotion spécifique du produit est valide
		$productPromoValid = $product->promotion_price && now()->between($product->promotion_start_date, $product->promotion_end_date);

		// Initialise le meilleur prix avec le prix normal du produit
		$bestPrice = $product->price;

		// Si la promotion spécifique du produit est valide, utilise ce prix
		if ($productPromoValid) {
			$bestPrice = $product->promotion_price;
		}

		// Si la promotion globale est valide, calcule le prix avec la réduction globale
		if ($globalPromoValid) {
			$globalPromoPrice = $product->price * (1 - $promoGlobal->value / 100);
			if ($globalPromoPrice < $bestPrice) {
				$bestPrice = $globalPromoPrice;
			}
		}

		return $bestPrice;
	}
}

Et on peut ainsi facilement ajuster la page d'accueil (livewire.index) :

@foreach ($products as $product)
    @php
        $bestPrice = getBestPrice($product);
        if ($bestPrice === $product->price) {
            $titleContent =
                '<span class="">' .
                number_format($product->price, 2, ',', ' ') .
                ' € TTC</span>';
        } else {
            $titleContent =
                '<span class="line-through">' .
                number_format($product->price, 2, ',', ' ') .
                ' € TTC</span> <span class="text-red-500">' .
                number_format($bestPrice, 2, ',', ' ') .
                ' € TTC</span>';
        }
    @endphp
    <x-card
        class="shadow-md transition duration-500 ease-in-out shadow-gray-500 hover:shadow-xl hover:shadow-gray-500 flex flex-col justify-between">
            {!! $titleContent !!}<br>
            <b>{!! $product->name !!}</b>
            @unless ($product->quantity)
            <br><span class="text-red-500">@lang('Product out of stock')</span>
            @endunless

        @if ($product->image)
            <x-slot:figure>
                @if ($product->quantity)
                    <a href="{{ route('products.show', $product) }}">
                @endif
                <img src="{{ asset('storage/photos/' . $product->image) }}" alt="{!! $product->name !!}" />
                @if ($product->quantity)
                    </a>
                @endif
            </x-slot:figure>
        @endif
    </x-card>
@endforeach

La page produit

Il faut aussi ajuster le prix sur la page produit. je remets le composant complet (livewire.product) :

<?php

use Livewire\Volt\Component;
use App\Models\Product;
use Mary\Traits\Toast;

new class extends Component {
    use Toast;

    public Product $product;
    public int $quantity = 1;
    public bool $hasPromotion = false;
    public float $bestPrice = 0;
    
    public function mount(Product $product): void
    {
        if (!$product->active) {
            abort(404);
        }

        $this->product = $product;

        $this->bestPrice = getBestPrice($product);
        $this->hasPromotion = $this->bestPrice < $product->price;
    }

    public function save(): void
    {
        Cart::add([
            'id' => $this->product->id,
            'name' => $this->product->name,
            'price' => $this->bestPrice,
            'quantity' => $this->quantity,            
            'attributes' => ['image' => $this->product->image],
            'associatedModel' => $this->product,
        ]);

        $this->dispatch('cart-updated'); 

        $this->info(__('Product added to cart.'), position: 'bottom-end');
    }
}; ?>

<div class="container p-5 mx-auto">
    <div class="grid gap-10 lg:grid-cols-2">
        <div>
            <img class="mx-auto" src="{{ asset('storage/photos/' . $product->image) }}" alt="{{ $product->name }}" />
        </div>
        <div>
            <div class="text-2xl font-bold">{{ $product->name }}</div>

            @if($hasPromotion)
                <div class="flex items-center space-x-2">
                    <x-badge class="p-3 my-4 badge-neutral line-through" value="{{ number_format($product->price, 2, ',', ' ') . ' € TTC' }}" />
                    <x-badge class="p-3 my-4 badge-error" value="{{ number_format($bestPrice, 2, ',', ' ') . ' € TTC' }}" />
                </div>
            @else
                <x-badge class="p-3 my-4 badge-neutral" value="{{ number_format($product->price, 2, ',', ' ') . ' € TTC' }}" />
            @endif

            <p class="mb-4">{{ $product->description }}</p>
            <x-input class="!w-[80px]" wire:model="quantity" type="number" min="1" label="{{ __('Quantity')}}" />
            <x-button class="mt-4 btn-primary" wire:click="save" icon="o-shopping-cart" spinner >{{ __('Add to cart')}}</x-button>
        </div>
    </div>
</div>

Et tout le reste fonctionnera correctement.

Conclusion

Notre boutique s'est enrichi d'une bonne gestion des promotions avec une approche par produit et aussi générale.



Par bestmomo

Nombre de commentaires : 2

Article précédent : Shop : les promotions 1/2
Article suivant : Shop : les stocks