Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Voir cette série
Shop : le catalogue
Mercredi 5 février 2025 20:47

Notre administration commence à s'étoffer puisque nous avons déjà mis en place la gestion des commandes et des clients. Mais il nous reste encore bien des choses à coder ! Pour cet article, nous allons nous intéresser aux produits de la boutique. Il nous faut également une gestion complète : liste, suppression, modification, ajout... Nous allons continuer le codage dans le même esprit en conservant une homogénéité.

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

Avant de commencer

Ce projet est dynamique et appelé à subir des modifications au fil de l'eau. Le dépôt Github est ouvert à vos suggestions. Lionel de s'en est pas privé et a déjà apporté sa touche personnelle. En particulier, il a ajouté un helper bien pratique (dans app.helpers) :

// Translation Lower case first
if (!function_exists('transL')) {
	function transL($key, $replace = [], $locale = null) {


		$key = trans($key, $replace, $locale);
		// return mb_strtolower($key, 'UTF-8');
		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);
	}
}

Comme il a constaté que j'avais écrit des doublons dans le fichier de traduction, par exemple administration et Administration, pour tenir compte de la présence ou non de la majuscule au début du mot, il a ajouté cet helper transL qui donne la priorité à la minuscule en ne conservant dans le fichier de traduction que la version avec majuscule. Pour la suite de ces articles, j'utiliserai donc cet helper.

Pour qu'il soit pris en compte dans les fichiers Blade, on doit informer AppServiceProvider :

public function boot(): void {
	...

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

Il y a eu d'autres modifications, mais qui ne devrait pas impacter la progression exposée dans les précédents articles. Si jamais c'était le cas, vous pouvez allez jeter un coup d'œil dans le code du dépôt qui est toujours à jour.

Conception du tableau de gestion des produits

Le tableau

Pour faciliter la gestion des produits du catalogue, nous allons créer un tableau intuitif et complet. Ce tableau rassemblera les informations clés de chaque produit en un coup d'œil, permettant aux administrateurs de gérer efficacement le contenu. Voici les éléments que nous inclurons dans notre tableau :

  • Image
  • Nom
  • Prix TTC
  • État : actif ou pas actif
  • Quantité disponible

Fonctionnalités avancées

En plus de ces informations de base, nous allons envisager d'ajouter une fonctionnalité supplémentaire pour améliorer l'expérience utilisateur :

  • Suppression du produit

Un composant pour le tableau

Il nous faut maintenant un composant Volt pour afficher le tableau :

php artisan make:volt admin/products/index --class

Avec ce code :

<?php

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

new 
#[Title('Products')] 
#[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' => __('  ')],
            ['key' => 'name', 'label' => __('Name')], 
            ['key' => 'price', 'label' => __('Price incl. VAT')],
            ['key' => 'active', 'label' => __('Active')],
            ['key' => 'quantity', 'label' => __('Quantity')],
        ];
    }

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

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

<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_active', $product)
                @if ($product->active)
                    <x-icon name="o-check-circle" />
                @endif
            @endscope
            @scope('cell_price', $product)
                {{ $product->price }} €
            @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 ajoute ces traductions :

"Price incl. VAT": "Prix TTC",
"Active": "Actif",
"Product deleted successfully.": "Produit supprimé avec succès.",
"Are you sure you want to delete this product?": "Voulez-vous vraiment supprimer ce produit ?",
"Create a new product": "Création d'un nouveau produit",

La route

On ajoute une route pour l'atteindre :

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

La navigation

On ajoute un lien dans la barre latérale de l'administration (on prévoit un sous-menu parce qu'on ajoutera plus loin les adresses) :

<x-menu-item icon="s-building-storefront" title="{{ __('Catalog') }}" link="{{ route('admin.products.index') }}" />

Une petite traduction :

"Catalog": "Catalogue",

Et on a notre tableau :

Un formulaire pour les adresses

On va avoir un formulaire commun pour la création et la modification d'un produit. On ajoute une vue partielle pour ce formulaire :

Avec ce formulaire :

<x-form wire:submit="save">
    <x-input 
        label="{{ __('Name') }}" 
        wire:model="name" 
        required 
        placeholder="{!! __('Enter product name') !!}" 
    />

    <x-textarea
        label="{{ __('Description') }}"
        wire:model="description"
        placeholder="{!! __('Enter product description') !!}"
        rows="5"
        required
    />
    
    <x-input 
        label="{{ __('Weight in kg') }}" 
        wire:model="weight" 
        required 
        placeholder="{{ __('Enter product weight') }}"
    />

    <x-input 
        label="{{ __('Price') }}" 
        wire:model="price" 
        required 
        placeholder="{{ __('Enter product price') }}"
    />

    <x-input 
        label="{{ __('Quantity available') }}" 
        wire:model="quantity" 
        type="number"
        required 
        placeholder="{{ __('Enter product quantity') }}"
    />

    <x-input 
        label="{{ __('Quantity for stock alert') }}" 
        wire:model="quantity_alert" 
        type="number"
        required 
        placeholder="{{ __('Enter product quantity') }}"
    />

    <x-checkbox label="{{ __('Active product') }}" wire:model="active" />

    <hr>
    <x-file 
        wire:model="image" 
        label="{{ __('Image') }}"
        hint="{!! __('Click on this image to modify it') !!}" 
        accept="image/png, image/jpeg">
        <img src="{{ $image == '' ? asset('storage/ask.jpg') : asset('storage/photos/' . $image) }}" class="h-40" />
    </x-file>

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

On ajoute quelques traductions :

"Enter product name": "Entrez le nom du produit",
"Enter product description": "Entrez la description du produit",
"Enter product price": "Entrez le prix du produit",
"Enter product quantity": "Entrez la quantité du produit",
"Enter product weight": "Entrez le poids du produit",
"Quantity for stock alert": "Quantité pour alerte de stock",
"Click on this image to modify it": "Cliquez sur cette image pour la modifier",
"Active product": "Produit actif",
"Weight in kg": "Poids en kg",
"Quantity available": "Quantité disponible",

Pour l'image par défaut, vous pouvez aller la chercher dans le dépôt et la mettre ici :

Un trait pour les éléments communs

De la même manière, on va avoir des éléments PHP communs pour la création et la modification d'un produit, on crée un trait :

On va y placer les propriétés et les règles de validation :

<?php

namespace App\Traits;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;

trait ManageProduct 
{
    public string $name = '';
    public TemporaryUploadedFile|string|null $image = null;
    public string $description = '';
    public float $price = 0;
    public float $weight = 0;
    public int $quantity = 0;
    public int $quantity_alert = 0;
    public bool $active = false;

    protected function validateProductData(array $additionalData = []): array
    {
        $rules = [
            'name' => 'required|max:255',
            'image' => $this->image instanceof TemporaryUploadedFile ? 'image|mimes:jpeg,png,jpg,gif' : 'required',
            'description' => 'required|string|max:65535',
            'price' => 'required|numeric|min:0|regex:/^(\d+(?:[\.\,]\d{1,2})?)$/',
            'weight' => 'required|numeric|min:0|regex:/^(\d+(?:[\.\,]\d{1,3})?)$/',
            'quantity' => 'required|numeric|min:0',
            'quantity_alert' => 'required|numeric|min:0|lte:quantity',
            'active' => 'required|boolean',
        ];

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

Pour la validation, on a besoin de nouvelles traductions dans fr.validation :

    'weight'                   => 'poids',
    'quantity_alert'           => 'alerte de quantité',
],
'custom' => [
    'firstname' => [
        'required_unless' => 'Le champ prénom est obligatoire sauf si c\'est une adresse professionnelle.',
    ],
    'name' => [
        'required_unless' => 'Le champ nom est obligatoire sauf si c\'est une adresse professionnelle.',
    ],
    'company' => [
        'required_unless' => 'Le champ raison sociale est obligatoire si c\'est une adresse professionnelle.',
    ],
], 

J'en ai profité pouc combler quelques oublis précédents.

La création d'un produit

On crée le composant pour la création d'un produit :

php artisan make:volt admin/products/create --class

Avec la création précédemment de la vue pour le formulaire et le trait, le code devient bien plus léger dans ce composant :

<?php

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

new     
#[Title('Product creation')] 
#[Layout('components.layouts.admin')] 
class extends Component
{
    use Toast, ManageProduct, WithFileUploads;
    
    public function save(): void
    {
        $data = $this->validateProductData();

        $path = basename($this->image->store('photos', 'public'));
        $data['image'] = $path;

        Product::create($data);

        $this->success(__('Product created successfully.'), redirectTo: '/admin/products');
    }
   
}; ?>

<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-slot:actions>
    </x-header>
    <x-card title="{!! __('Create a new product') !!}">
        @include('livewire.admin.products.form')
    </x-card>
</div>

On ajoute la route :

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

On obtient ce gros formulaire :

Vérifiez que tout fonctionne correctement.

La modification d'un produit

On crée le composant pour la modification d'un produit :

php artisan make:volt admin/products/edit --class

Là aussi le code est léger :

<?php

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

new 
#[Title('Product edition')] 
#[Layout('components.layouts.admin')] 
class extends Component
{
    use Toast, ManageProduct, WithFileUploads;
    
    public Product $product;

    public function mount(Product $product): void
    {
        $this->product = $product;
        $this->fill($this->product);
    }

    public function save(): void
    {
        $data = $this->validateProductData();

        if ($this->image instanceof TemporaryUploadedFile) {
            $path = basename($this->image->store('photos', 'public'));
            $data['image'] = $path;
        }

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

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

<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-slot:actions>
    </x-header>
    <x-card title="{!! __('Edit a product') !!}">
        @include('livewire.admin.products.form')
    </x-card>
</div>

On ajoute la route :

Volt::route('/products/{product}/edit', 'admin.products.edit')->name('admin.products.edit');

Des traductions :

"Product updated successfully.": "Produit mis à jour avec succès.",
"Edit a product": "Modification d'un produit",

On a le même formulaire que pour la création, mais renseigné avec les données du produit sélectionné :

Là aussi, vérifiez que tout fonctionne correctement.

Conclusion

On en a fini avec la gestion des produits. Dans la prochaine étape, on commencera à coder le paramétrage de la boutique.



Par bestmomo

Aucun commentaire

Article précédent : Shop : les clients
Article suivant : Shop : la boutique