Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Voir cette série
Shop : les frais de port
Samedi 8 février 2025 17:05

Nous en avons presque terminé avec le paramétrage de notre boutique. Dans cet article, nous allons terminer avec la gestion des frais de port. On va avoir deux parties : les plages de poids et les tarifs selon le pays d'expédition.

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

Les plages de poids

La table des plages de poids (ranges) ne comporte qu'une colonne (en plus de l'index) :

Chaque valeur représente le poids maximum de la plage. 

Il nous faut un composant Volt pour afficher et gérer nos plages de poids :

php artisan make:volt admin/parameters/shipping/ranges --class

Et voilà le code :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\{Layout, Title};
use App\Models\{ Country, Range };
use Mary\Traits\Toast;
use Illuminate\Support\Collection;

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

    public array $ranges;

    public function headers(): array
	{
		return [['key' => 'max', 'label' => __('Maximum weight')], ];
	}

    public function mount(): void
    {
        $this->ranges = Range::all()->toArray();
    }

    public function updateRangeMax($index, $value): void
    {
        // Vérification si la valeur est numérique
        if (!is_numeric($value)) {
            $this->resetRangeMaxToOriginal($index);
            $this->error(__('The value must be a valid number.'));
            return;
        }

        $max = (float) $value;

        // Charger la plage actuelle
        $range = Range::find($this->ranges[$index]['id']);
        if (!$range) {
            $this->error(__('Range not found.'));
            return;
        }

        // Vérifier les plages adjacentes
        $previousRange = Range::where('max', '<', $range->max)->orderBy('max', 'desc')->first();
        $nextRange = Range::where('max', '>', $range->max)->orderBy('max', 'asc')->first();

        if ($previousRange && $max <= $previousRange->max) {
            $this->resetRangeMaxToOriginal($index);
            $this->error(__('The new range must be greater than the previous range (:previous)', ['previous' => $previousRange->max]));
            return;
        }

        if ($nextRange && $max >= $nextRange->max) {
            $this->resetRangeMaxToOriginal($index);
            $this->error(__('The new range must be less than the next range (:next)', ['next' => $nextRange->max]));
            return;
        }

        // Mise à jour en base de données
        $range->update(['max' => $max]);

        // Mise à jour locale pour refléter le format arrondi
        $this->ranges[$index]['max'] = number_format($max, 2);

        $this->success(__('Range updated successfully.'));
    }

    // Fonction pour réinitialiser la valeur initiale
    protected function resetRangeMaxToOriginal($index): void
    {
        $originalValue = Range::find($this->ranges[$index]['id'])->max;
        $this->ranges[$index]['max'] = number_format($originalValue, 2);
    }

    public function deleteRange($id): void
    {
        $range = Range::find($this->ranges[$id]['id']);
        $range->delete();

        $this->ranges = Range::all()->toArray();
        $this->success(__('Range deleted successfully.'));
    }

    public function createRange(): void
    {
        $lastRange = Range::orderBy('max', 'desc')->first();
        $newMax = $lastRange ? $lastRange->max + 1 : 1;

        $range = Range::create(['max' => $newMax]);

        $countries = Country::all();
        foreach($countries as $country) {
            $range->countries()->attach($country, ['price' => 0]);
        }

        $this->ranges = Range::all()->toArray();

        $this->success(__('New range added successfully'));
    }

    public function with(): array
	{
		return [
			'headers' => $this->headers(),
		];
	} 

}; ?>

<div>
    <x-header title="{{ __('Weight ranges') }}" 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="{{ __('Add Range') }}" 
                wire:click="createRange" 
                spinner 
                class="btn-primary" 
            />
        </x-slot:actions>
    </x-header>
    <x-alert title="{!! __('If you delete a range, the corresponding values in the country-specific shipping rates will also be deleted. We strongly advise you to make these changes in maintenance mode!') !!}" class="alert-warning" dismissible /><br>
    <x-card>
        <x-table striped :headers="$headers" :rows="$ranges" >
            @scope('cell_max', $range)
                <x-input 
                    label="{{ __('Range') }} {{ $loop->index + 1 }}"
                    suffix="kg"
                    type="number"
                    step="0.1" 
                    wire:model="ranges.{{ $loop->index }}.max"
                    wire:keydown.enter="updateRangeMax({{ $loop->index }}, $event.target.value)"
                    wire:blur="updateRangeMax({{ $loop->index }}, $event.target.value)"
                />
            @endscope
            @scope('actions', $range)
                @if($loop->last)
                    <x-popover>
                        <x-slot:trigger>
                            <x-button icon="o-trash" wire:click="deleteRange({{ $loop->index }})"
                                wire:confirm="{{ __('Are you sure to delete this range?') }}" 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>
                @endif
            @endscope
        </x-table>
    </x-card>
</div>

On a besoin de quelques traductions ;

"Weight ranges": "Plages de poids",
"Maximum weight": "Poids maximum",
"Range updated successfully.": "Plage mise à jour avec succès.",
"New range added successfully": "Nouvelle plage ajoutée avec succès.",
"Add Range": "Ajouter une plage",
"Are you sure to delete this range?": "Voulez-vous vraiment supprimer cette plage ?",
"If you delete a range, the corresponding values in the country-specific shipping rates will also be deleted. We strongly advise you to make these changes in maintenance mode!": "Si vous supprimez une plage, les valeurs correspondantes dans les tarifs des expéditions par pays seront aussi supprimées. Il est vivement conseillé d'effectuer ces modifications en mode maintenance !",
"The new range must be greater than the previous range (:previous)": "La nouvelle plage doit être supérieure à la plage précédente (:previous)",
"The new range must be less than the next range (:next)": "La nouvelle plage doit être inférieure à la plage suivante (:next)",
"Range": "Plage",
"The value must be a valid number.": "La valeur doit être un nombre valide.",
"Range deleted successfully.": "Plage supprimée avec succès.",

La route

On ajoute une route pour l'atteindre :

Volt::route('/ranges', 'admin.parameters.shipping.ranges')->name('admin.parameters.shipping.ranges');

La navigation

On ajoute un lien dans la barre latérale de l'administration :

<x-menu-sub title="{{ __('Shipments') }}" icon="s-truck">
    <x-menu-item title="{{ __('Ranges') }}" icon="o-circle-stack" link="{{ route('admin.parameters.shipping.ranges') }}" />
</x-menu-sub>

On prévoit un sous-menu parce qu'on ajoutera plus loin les tarifs.

On ajoute les deux traductions :

"Ranges": "Plages",
"Shipments": "Expéditions",

Et voilà la page qui en résulte :

On a un message d'alerte en tête pour prévenir du fait que la suppression d'une plage supprime les tarifs correspondants. D'autre part, il est évidemment conseillé d'effectuer des modifications en mode maintenance.

On peut ajouter une plage ou supprimer la dernière plage.

On contrôle la cohérence des saisies, par exemple une plage ne peut pas avoir une valeur supérieure à la plage suivante :

Ni l'inverse :

Quand on ajoute une plage, elle apparaît avec une valeur supérieure d'un kilo par rapport à la précédente et le bouton de suppression s'affiche pour cette nouvelle plage :

Les tarifs

Maintenant qu'on a réglé la question des plages, passons aux tarifs par pays. Je vous rappelle la structure des données :

 

La table colissimos est le pivot entre les pays et les plages avec une relation de type n:n. C'est dans cette table que sont les tarifs.

Il nous faut un composant Volt pour afficher et gérer nos tarifs :

php artisan make:volt admin/parameters/shipping/rates --class

Avec ce code :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\{Layout, Title};
use App\Models\{ Country, Range, Colissimo };
use Mary\Traits\Toast;
use Illuminate\Support\Collection;

new #[Title('Rates')] #[Layout('components.layouts.admin')] class extends Component
{
    use Toast;
    public array $countries;

    public function mount(): void
    {
        $this->countries = Country::with('ranges')->get()->toArray();
    }

    public function save(): void
    {
        // Validation
        $rules = [];
        foreach ($this->countries as $countryIndex => $country) {
            foreach ($country['ranges'] as $rangeIndex => $range) {
                $rules["countries.$countryIndex.ranges.$rangeIndex.pivot.price"] = 'required|numeric';
            }
        }

        $this->validate($rules);

        // Sauvegarde des données
        foreach ($this->countries as $countryData) {
            $country = Country::find($countryData['id']);

            foreach ($countryData['ranges'] as $rangeData) {
                $country->ranges()->updateExistingPivot($rangeData['id'], [
                    'price' => $rangeData['pivot']['price'],
                ]);
            }
        }

        $this->success(__('Rates saved successfully.'));
    }
    
    public function updated($property, $value): void
    {
        $segments = explode('.', $property);
        $countryIndex = $segments[1];
        $rangeIndex = $segments[3];
        $this->countries[$countryIndex]['ranges'][$rangeIndex]['pivot']['price'] = number_format((float) $value, 2, '.', '');
    }

    public function with(): array
	{
		return [
            'ranges' => Range::all(),
		];
	} 
    
}; ?>

<div>
    <x-header title="{{ __('Postal rate management') }}" 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">
            <table class="min-w-full rounded-lg border border-gray-300 shadow-md border-collapse">
                <thead class="text-gray-700 bg-gray-200">
                    <tr>
                        <th class="p-2 text-left border border-gray-300">Pays</th>
                        @foreach ($ranges as $range)
                            <th class="p-2 text-center border border-gray-300">≤ {{ $range->max }} Kg</th>
                        @endforeach
                    </tr>
                </thead>
                <tbody class="divide-y divide-gray-200">
                    @foreach ($countries as $countryIndex => $country)
                        <tr class="hover:bg-gray-100">
                            <td class="p-2 font-medium text-left border border-gray-300">{{ $country['name'] }}</td>
                            @foreach ($country['ranges'] as $rangeIndex => $range)
                                <td class="p-2 text-center border border-gray-300">
                                    <x-input 
                                        wire:model="countries.{{ $countryIndex }}.ranges.{{ $rangeIndex }}.pivot.price"
                                        class="w-full text-center"
                                        required
                                    />
                                </td>
                            @endforeach
                        </tr>
                    @endforeach                
                </tbody>
            </table>                   
            <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>

Les traductions :

"Postal rate management": "Gestion des tarifs postaux",
"Rates": "Tarifs",
"Rates saved successfully.": "Tarifs enregistrés avec succès."

La route :

Volt::route('/rates', 'admin.parameters.shipping.rates')->name('admin.parameters.shipping.rates');

On ajoute un lien dans la barre latérale de l'administration :

<x-menu-sub title="{{ __('Shipments') }}" icon="s-truck">
    ...
    <x-menu-item title="{{ __('Rates') }}" icon="s-currency-euro" link="{{ route('admin.parameters.shipping.rates') }}" />
</x-menu-sub>

Et voilà notre page des tarifs :

Vérifiez que ça fonctionne bien.

Le mode maintenance

Lorsque votre application Laravel est en mode maintenance, une vue personnalisée s'affiche pour toutes les requêtes entrantes. Cette fonctionnalité permet de mettre temporairement votre application hors service, que ce soit pour effectuer des mises à jour ou des opérations de maintenance.

Le middleware par défaut de Laravel intègre une vérification du mode maintenance. Si ce mode est activé, le système génère une exception spécifique avec un code d'état 503. Cette exception, de type HttpException, provient du composant HttpKernel de Symfony.

Cette approche offre une manière élégante de gérer les périodes d'indisponibilité planifiées de votre application, tout en informant les utilisateurs de la situation de manière appropriée. Vous avez tous les détails dans la documentation de Laravel. S'il est possible de tout gérer avec des commandes Artisan, il est quand même plus judicieux de prévoir une utilisation plus sympathique, ce que nous allons prévoir avec un curseur.

On ajoute un nouveau composant Volt :

php artisan make:volt admin/maintenance --class

Avec ce code :

<?php

use Illuminate\Support\Facades\Artisan;
use Livewire\Attributes\{Layout, Rule};
use Livewire\Volt\Component;
use Mary\Traits\Toast;

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

	public bool $maintenance = false;

	public function mount(): void
	{
		$this->maintenance = App::isDownForMaintenance();
	}

	public function updatedMaintenance(): void
	{
		if ($this->maintenance)
		{
			Artisan::call('down', ['--secret' => env('APP_MAINTENANCE_SECRET')]);
		}
		else
		{
			Artisan::call('up');
		}
	}
};

?>

<div>
    <x-header title="{{ __('Maintenance mode') }}" 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 separator class="mb-6 border-4 {{ $maintenance ? 'bg-red-300' : 'bg-zinc-100' }} border-zinc-950">
		<div class="flex items-center justify-between">
			<x-toggle label="{{ __('Maintenance mode') }}" wire:model="maintenance" wire:change="$refresh" />
			@if($maintenance)
				<x-button label="{{ __('Go to bypass page')}}" link="/{{ env('APP_MAINTENANCE_SECRET') }}"  />
			@endif
		</div>
	</x-card>
</div>

Deux traductions :

"Go to bypass page": "Aller sur la page de passage",
"Maintenance mode": "Mode maintenance",

Une route :

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

Un lien dans la barre latérale :

<x-menu-item title="{{ __('Maintenance') }}" icon="c-wrench-screwdriver" link="{{ route('admin.maintenance') }}" :class="App::isDownForMaintenance() ? 'bg-red-300' : ''" />

Et cet aspect :

La clé secrète doit être placée dans le fichier .env :

APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
APP_MAINTENANCE_SECRET=1610242a-116b-4a11-ffa1-d472g4c43515

Une fois le mode maintenance activé, il faut utiliser cette clé pour accéder de nouveau au CMS. Pour éviter d'avoir à taper ça à la main, j'ai prévu un bouton dédié ::

Une fois le mode maintenance activé, il faut utiliser la clé secrète pour accéder de nouveau au CMS. Pour éviter d'avoir à taper ça à la main, j'ai prévu un bouton dédié :

D'autre part, une bonne couleur visible rappelle l'état de maintenance activée. De la même manière, on le signale dans le menu latéral :

Et pour ne pas oublier, on va aussi changer la couleur de la barre de navigation de la boutique :

<x-nav sticky full-width :class="App::isDownForMaintenance() ? 'bg-red-300' : 'bg-cyan-700 text-white'">

Si avec tout ça on oublie, il n'y a plus d'espoir :)

Conclusion

Nous en avons fini avec le paramétrage de notre boutique !



Par bestmomo

Nombre de commentaires : 2

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