Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Voir cette série
Shop : les promotions 1/2
Jeudi 13 février 2025 20:01

Je pensais avoir finalisé mon projet Shop, mais c'était sans compter sur les suggestions avisées de l'un de mes lecteurs, Mathieu, qui m'a incité à revisiter ce projet. Parmi ses nombreuses idées, j'ai choisi de me concentrer sur l'intégration des promotions. En effet, il est courant sur les sites e-commerce de proposer des promotions temporaires sur certains produits ou même sur l'ensemble de la boutique. Étant donné la complexité que cela implique, j'ai décidé de commencer par les promotions appliquées à des produits spécifiques dans cet article, en réservant les promotions globales pour un prochain article.

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

Avant de commencer

Le dépôt Github est alimenté par des modifications qui ne figurent pas dans les précédents articles. Si vous tombez sur une erreur, c'est sans doute qu'il vous manque des éléments qui ont été ajoutés.

Helpers

Voici tous les helpers actuels :

<?php

use Illuminate\Support\Facades\{App, Config};
use LaravelLang\LocaleList\Locale;

if (!function_exists('price_without_vat')) {
	function price_without_vat(float $price_with_vat, float $vat_rate = .2): float
	{
		return round($price_with_vat / (1 + (float) env('VAT_RATE', $vat_rate)), 2);
	}
}

if (!function_exists('transL')) {
	function transL($key, $replace = [], $locale = null)
	{
		$key = trans($key, $replace, $locale);

		return mb_substr(mb_strtolower($key, 'UTF-8'), 0, 1) . mb_substr($key, 1);
	}
}

if (!function_exists('__L')) {
	function __L($key, $replace = [], $locale = null)
	{
		return transL($key, $replace, $locale);
	}
}

if (!function_exists(function: 'bigR')) {	
	function bigR(float|int $r, $dec = 2, $locale = null): bool|string
	{
		$locale ??= substr(Config::get('app.locale'), 0, 2);
		$fmt = new NumberFormatter(locale: $locale, style: NumberFormatter::DECIMAL);

		// echo "$locale<hr>";

		return $fmt->format(num: round($r, $dec));
	}
}

if (!function_exists('ftA')) {
	function ftA($amount, $locale = null): bool|string
	{
		$locale ??= config('app.locale');

		$lang = substr($locale, 0, 2);
		preg_match('/_([^_]*)$/', $locale, $matches);
		$currency  = $matches[1] ?? 'EUR';
		$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
		$formatted = $formatter->formatCurrency($amount, $currency);
		return $formatted;
	}
}

AppServiceprovider

Dans AppServiceprovider il faut déclarer une directive Blade :

<?php

namespace App\Providers;

use App\Models\Shop;
use Illuminate\Support\Facades\{Blade, View};
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider {

	public function boot(): void {
		if (app()->runningInConsole()) {
			return;
		}

		View::share('shop', Shop::firstOrFail());

		Blade::directive(name: 'langL', handler: function ($expression): string {
			return "<?= transL({$expression}); ?>";
		});
	}
}
?>

Les données

Pour chaque produit de la boutique, on peut avoir une promotion spécifique. On a donc besoin d'enrichir la base de données. Il nous faut :

  • le prix promotionnel
  • la date de début de la remise
  • la date de fin de la remise

On ajoute ces trois éléments à la migration pour les produits :

public function up(): void
{
    Schema::create('products', function (Blueprint $table) {
        ...
        $table->decimal('promotion_price')->nullable(); 
        $table->date('promotion_start_date')->nullable(); 
        $table->date('promotion_end_date')->nullable(); 
        ...
    });
}

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

php artisan migrate:fresh --seed

On complète aussi le modèle Product :

class Product extends Model
{
    protected $fillable = [
        ...
        'promotion_price',
        'promotion_start_date',
        'promotion_end_date',
    ];

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

Par défaut, Eloquent convertit les colonnes created_at et updated_at en instances de Carbon, qui étend la classe PHP DateTime et fournit un assortiment de méthodes utiles. Vous pouvez convertir d'autres attributs de date en définissant d'autres conversions de date dans la méthode de conversion de votre modèle (casts), comme je l'ai fait ici. 

Il nous faut aussi ajouter la validation et les nouvelles propriétés dans le trait ManageProduct :

trait ManageProduct
{

    ...

    public bool $promotion = false;
    public ?float $promotion_price = null;
    public ?string $promotion_start_date = null;
    public ?string $promotion_end_date = null;

    protected function validateProductData(array $additionalData = []): array
    {
        $rules = [

            ...

            'promotion_price' => 'required_if:promotion,true|nullable|numeric|min:0|regex:/^(\d+(?:[\.\,]\d{1,2})?)$/|lt:price',
            'promotion_start_date' => 'required_if:promotion,true|nullable|date',
            'promotion_end_date' => 'required_if:promotion,true|nullable|date|after:promotion_start_date',
        ];

        return $this->validate(array_merge($rules, $additionalData));
    }
}

Remarquez qu'on a une propriété booléenne qui détermine la situation avec ou sans promotion.

L'administration

Il nous faut compléter l'administration pour donner la possibilité de renseigner ce prix promotionnel et les dates correspondantes.

Le formulaire

On a le formulaire admin.products.form qui nous sert pour la création et la modification d'un produit. On va ajouter les trois contrôles pour nos nouvelles données :

<x-form wire:submit="save">
    ...

    <div class="text-red-500">
        <x-checkbox label="{{ __('Promotion') }}" wire:model="promotion" wire:change="$refresh" />
    </div>

    @if($promotion)
        <x-input 
            label="{{ __('Promotion price') }}" 
            wire:model="promotion_price" 
            placeholder="{{ __('Enter product promotion price') }}"
        />

        <x-datetime  
            label="{{ __('Promotion start date') }}" 
            icon="o-calendar"
            wire:model="promotion_start_date"
            type="date"         
        />

        <x-datetime  
            label="{{ __('Promotion end date') }}" 
            icon="o-calendar"
            wire:model="promotion_end_date"
            type="date"
        />
    @endif

    ...

</x-form>

On complète avec les traductions dans le fichier fr.json :

"Promotion price": "Prix de promotion",
"Enter product promotion price": "Entrez le prix de promotion du produit",
"Promotion start date": "Date de début de la promotion du produit",
"Promotion end date": "Date de fin de la promotion du produit",

J'ai prévu une case à cocher (propriété promotion) qui va déterminer si le produit fait l'objet d'une promotion ou pas. Au niveau du fonctionnement, on veut cette situation sans promotion (avec les contrôles cachés) :

Et cette situation lorsqu'il y a une promotion pour permettre la saisie du prix et des dates :

Création d'un produit

On garde la possibilité de créer un produit avec directement une promotion. Pourquoi pas ? La modification du composant admin.products.create est minime :

class extends Component
{
    ...
    
    public function save(): void
    {
        ...

        if(!$this->promotion) {
            $data['promotion_price'] = null;
            $data['promotion_start_date'] = null;
            $data['promotion_end_date'] = null;
        }

        ...
    }
   
}; ?>

On se contente de fixer à null nos trois données si on n'est pas en situation promotionnelle, sinon on ne fait rien de spécial.

Modification d'un produit

Pour la modification d'un produit dans le composant admin.products.edit, on ne change pas grand-chose non plus :

class extends Component
{
    ...

    public function mount(Product $product): void
    {

        ...

        $this->promotion = $product->promotion_price != null;
    }

    public function save(): void
    {
        ...

        if(!$this->promotion) {
            $data['promotion_price'] = null;
            $data['promotion_start_date'] = null;
            $data['promotion_end_date'] = null;
        }

        ...
    }
  
}; ?>

On renseigne la propriété $promotion et éventuellement les trois données.

Le tableau des produits

On va aussi compléter le tableau des produits pour faire apparaître clairement ceux qui sont en promotion avec les trois situations :

  • bientôt en promotion,
  • actuellement en promotion,
  • promotion terminée.

Comme il y a pas mal de modifications, je mets le code complet :

<?php

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

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

    public array $sortBy = [
        'column'    => 'name',
        'direction' => 'asc',
    ];

    public int $perPage = 10;

    public function headers(): array
    {
        return [
            ['key' => 'image', 'label' => __('Image')],
            ['key' => 'name', 'label' => __('Name')],
            ['key' => 'price', 'label' => __('Price incl. VAT'), 'class' => 'text-right'],
            ['key' => 'active', 'label' => __('Active'), 'class' => 'text-center'],
            ['key' => 'promotion_price', 'label' => __('Promotion'), 'class' => 'text-right'],
            ['key' => 'quantity', 'label' => __('Quantity'), 'class' => 'text-right'],
        ];
    }

    public function deleteProduct(Product $product): void
    {
        $product->delete();
        $this->success(__('Product deleted successfully.'));
    }

    public function updated($property): void
    {
        if (! is_array($property) && $property != "") {
            $this->resetPage();
        }
    } 

    public function with(): array
    {
        return [
            'products' => Product::orderBy(...array_values($this->sortBy))->paginate($this->perPage),
            'headers'  => $this->headers(),
        ];
    }
}; ?>

@section('title', __('Catalog'))
<div>
    <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-plus" label="{!! __('Create a new product') !!}" link="/admin/products/create" spinner
                class="btn-primary" />
        </x-slot:actions>
    </x-header>

    <x-card>
        <x-table striped :headers="$headers" :rows="$products" :sort-by="$sortBy" per-page="perPage" with-pagination
            link="/admin/products/{id}/edit" >
            @scope('cell_image', $product)
                <img src="{{ asset('storage/photos/' . $product->image) }}" width="60" alt="">
            @endscope

            @scope('cell_price', $product)
                {{ ftA($product->price) }}
            @endscope

            @scope('cell_active', $product)
                @if ($product->active)
                    <x-icon name="o-check-circle" class='text-green-400 w-7' />
                @else
                    <x-icon name="o-x-circle" class='text-orange-400 w-7' />
                @endif
            @endscope

            @scope('cell_quantity', $product)
                @if($product->quantity < $product->quantity_alert)
                    <x-badge class="p-3 my-4 badge-error" value="{{ bigR($product->quantity, 0) }}" />
                @else
                    {{ bigR($product->quantity, 0) }}
                @endif
            @endscope

            @scope('cell_promotion_price', $product)
                @if($product->promotion_price)
                    @if(now()->isBefore($product->promotion_start_date))
                        <x-badge class="p-3 my-4 badge-info" value="{{ trans('Coming soon') }}" />
                    @elseif(now()->between($product->promotion_start_date, $product->promotion_end_date))
                        <x-badge class="p-3 my-4 badge-success" value="{{ trans('In promotion') }}" />
                    @else
                        <x-badge class="p-3 my-4 badge-error" value="{{ trans('Expired') }}" />
                    @endIf
                    <span class="{{ now()->between($product->promotion_start_date, $product->promotion_end_date) ? 'text-red-500' : ''}} ml-2">
                        {{ $product->promotion_price }} €
                    </span>
                    <br>
                    <span class="whitespace-nowrap">
                        {{ $product->promotion_start_date->isoFormat('LL') }} - {{ $product->promotion_end_date->isoFormat('LL') }}
                    </span>
                @endif
            @endscope

            @scope('actions', $product)
                <x-popover>
                    <x-slot:trigger>
                        <x-button icon="o-trash" wire:click="deleteProduct({{ $product->id }}"
                            wire:confirm="{{ __('Are you sure you want to delete this product?') }}" spinner
                            class="text-red-500 btn-ghost btn-sm" />
                    </x-slot:trigger>
                    <x-slot:content class="pop-small">
                        @lang('Delete')
                    </x-slot:content>
                </x-popover>
            @endscope
        </x-table>
    </x-card>
</div>

On a quelques traductions :

"Product out of stock": "Produit hors stock",
"Coming soon": "Prochainement",
"In promotion": "En promotion",
"Expired": "Expiré",

On fait apparaître clairement les informations :

On peut maintenant gérer nos produits, concernant les promotions, dans l'administration. Il faut à présent pouvoir les présenter aux clients...

La page d'accueil

Sur la page d'accueil de la boutique, il faut signaler efficacement les produits en promotion en barrant le prix de base et en faisant apparaître en rouge le prix réduit. Ca se passe dans notre vue livewire.index que je remets complète aussi :

<?php

use Livewire\Volt\Component;
use App\Models\Product;
use Carbon\Carbon;

new class extends Component
{
    public function with(): array
    {
        return [
            'products' => Product::whereActive(true)->get(),
        ];
    }

}; ?>

@section('title', __('Home'))
<div class="container mx-auto">
    @if (session('registered'))
        <x-alert
            title="{!! session('registered') !!}"
            icon="s-rocket-launch"
            class="mb-4 alert-info"
            dismissible
        />
    @endif
    <x-card class="w-full shadow-md shadow-gray-500" shadow separator >
        {!! $shop->home !!}
    </x-card>
    <br>
    <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        @foreach ($products as $product)
            @php          
                if ($product->promotion_price && now()->between($product->promotion_start_date, $product->promotion_end_date)) {
                    $titleContent = '<span class="line-through">' . number_format($product->price, 2, ',', ' ') . ' € TTC</span> <span class="text-red-500">' . number_format($product->promotion_price, 2, ',', ' ') . ' € TTC</span>';
                } else {
                    $titleContent = number_format($product->price, 2, ',', ' ') . ' € TTC';
                }
            @endphp
            <x-card
                class="shadow-md transition duration-500 ease-in-out shadow-gray-500 hover:shadow-xl hover:shadow-gray-500" >
                {!! $titleContent !!}<br>               
                {!! $product->name !!}
                @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
    </div>
    <br>
    <x-card class="w-full shadow-md shadow-gray-500" shadow separator >
        <x-accordion class="shadow-md shadow-gray-500">
            <x-collapse name="group1">
                <x-slot:heading>{{ __('General informations') }}</x-slot:heading>
                <x-slot:content>{!! $shop->home_infos !!}</x-slot:content>
            </x-collapse>
            <x-collapse name="group2">
                <x-slot:heading>{{ __('Shipping charges') }}</x-slot:heading>
                <x-slot:content>{!! $shop->home_shipping !!}</x-slot:content>
            </x-collapse>
        </x-accordion>
    </x-card>
</div>

Voilà l'aspect d'un produit en promotion :

La page d'un produit

Lorsque le client clique sur le produit dans la page d'accueil, il arrive sur la page spécifique du produit. Là encore, on doit visualiser la promotion. Ca se passe dans le composant livewire.product que je remets intégralement :

<?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 function mount(Product $product): void
    {
        if (!$product->active) {
            abort(404);
        }

        $this->product = $product;

        $this->hasPromotion = $product->promotion_price && now()->between($product->promotion_start_date, $product->promotion_end_date);
    }

    public function save(): void
    {
        Cart::add([
            'id' => $this->product->id,
            'name' => $this->product->name,
            'price' => $this->hasPromotion ? $this->product->promotion_price : $this->product->price,
            '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($product->promotion_price, 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>

Là encore, on visualise bien la réduction du prix :

Pour la suite du processus de commande, on ne change rien.

Conclusion

On a mis en place la possibilité, pour chaque produit, d'appliquer une promotion spécifique, valable sur une période précise. On peut gérer facilement ces données dans l'administration et on visualise les réductions pour le client. Je me suis un peu demandé que faire des promotions expirées. je me suis contenté de les signaler dans le tableau. peut-être faudrait-il ajouter là un bouton pour faire une purge...

Dans le prochain article on verra les promotions globales, c'est-à-dire celles qui s'appliquent indistinctement à l'ensemble des produits de la boutique.



Par bestmomo

Nombre de commentaires : 5

Article précédent : Shop : les frais de port
Article suivant : Shop : les promotions 2/2