Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Shop : le compte client 2/2
Samedi 18 janvier 2025 13:08

Dans l'épisode précédent, on a offert au client la possibilité de visualiser et modifier ses informations personnelles. On lui propose également une gestion complète de ses adresses : création, modification et suppression. Nous allons achever cette partie de gestion du compte client en ajoutant la gestion de ses commandes et aussi l'application des contraintes du RGPD.

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

Les commandes

Le but principal d'un site d'e-commerce est évidemment de générer des commandes. Un client va passer une ou plusieurs commandes sur le site. Il est important de lui donner la possibilité de visualiser les données de ses commandes passées. 

La liste des commandes

On crée le composant Volt :

php artisan make:volt account/orders/index --class

Avec ce code :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\Title;

new #[Title('Orders')]
class extends Component {

    public function headers(): array
	{
		return [
            ['key' => 'reference', 'label' => __('Reference')], 
            ['key' => 'date', 'label' => __('Date')],
            ['key' => 'amount', 'label' => __('Amount')],
            ['key' => 'state', 'label' => __('State')],
        ];
	}

    public function with(): array
    {
        return [
            'orders' => Auth::user()->orders()->latest()->with('state')->get(),
            'headers' => $this->headers(),
        ];
    }

}; ?>

<div>
    <x-card class="flex justify-center items-center mt-6" title="{{ __('My orders') }}" shadow separator >
        <x-table striped :headers="$headers" :rows="$orders" link="/account/orders/{id}" >
            @scope('cell_date', $order)
                {{ $order->created_at->isoFormat('LL') }}
            @endscope
            @scope('cell_amount', $order)
                {{ number_format($order->total_order, 2, ',', ' ') }} €
            @endscope
            @scope('cell_state', $order)
                <x-badge value="{{ $order->state->name }}" class="p-3 bg-{{ $order->state->color }}-400" />
            @endscope
        </x-table>
    </x-card>    
</div>

Quelques traductions :

"State": "État",
"Amount": "Montant",
"Reference": "Référence",

On ajoute la route  :

Route::middleware('auth')->group(function () {

	Route::prefix('account')->group(function () {
		...
		Volt::route('/orders', 'account.orders.index')->name('orders');
	});

On renseigne la barre de navigation :

<x-menu-item title="{{ __('My orders') }}" link="{{ route('orders') }}" />

Et la barre latérale :

<x-menu-item title="{{ __('My orders') }}" icon="o-shopping-cart" link="{{ route('orders') }}" />

Pour assurer le bon aspect des badges avec Tailwind, on va ajouter une safelist dans tailwind.config.js :

export default {
    ...
    safelist: [
        'bg-red-400',
        'bg-blue-400',
        'bg-green-400',
        'bg-gray-400',
        'badge-info',
        'badge-success',
        'badge-error',
    ],

Et on obtient le tableau :

Si vous vous étonnez des coins carrés des badges, c'est tout simplement le thème Corporate qui les veut ainsi. Dans la plupart des autres thèmes, ils sont arrondis.

Les détails d'une commande

Un composant pour le détail des articles commandés

On crée un composant Blade pour ces détails :

php artisan make:component details --view

@foreach ($content as $item)
  <div class="flex justify-between">
    <div>
      {{ $item->name }} ({{ $item->quantity }})
    </div>
    <div><strong>{{ number_format($item->total_price_gross ?? ($tax > 0 ? $item->price : price_without_vat($item->price)) * $item->quantity, 2, ',', ' ') }} €</strong></div>
  </div>
  <br><hr><br>
@endforeach
@unless($pick)
  <div class="flex justify-between p-3">
    <div>
      @lang('Colissimo delivery')
    </div>
    <div>
      <strong>{{ number_format($shipping, 2, ',', ' ') }} €</strong>
    </div>
  </div>
@endif
@if($tax > 0)
  <div class="flex justify-between p-3">
    <div>
      @lang('VAT to ') {{ $tax * 100 }}%
    </div>
    <div>
      <strong>{{ number_format($total / (1 + $tax) * $tax, 2, ',', ' ') }} €</strong>
    </div>
  </div>
@endif
<div class="flex justify-between p-3">
  <div>
    @lang('Total incl. VAT')
  </div>
  <div>
    <strong>{{ number_format($total + $shipping, 2, ',', ' ') }} €</strong>
  </div>
</div>

On ajoute les traductions :

"Colissimo delivery": "Livraison en Colissimo",
"Total incl. VAT": "Total TTC",
"VAT to ": "TVA à ",

Vous avez sans doute remarqué dans le composant la fonction personnalisée price_without_vat. On va la créer dans un fichier helpers qu'on place ici (on aura besoin de cette fonction dans plusieurs classes) :

Avec ce code :

<?php

if (!function_exists('price_without_vat')) {

    function price_without_vat(float $price_with_vat, int $vat_rate = 20): float 
    {
        return round($price_with_vat / (1.0 + (float)env('VAT_RATE', $vat_rate)), 2);
    }
}

Et pour que ce fichier soit chargé avec l'autoload, on doit informer composer.json :

"autoload": {
    ...
    "files": [
        "app/helpers.php"
    ]
},

Vous aurez peut-être besoin de rafraîchir l'autoload :

composer dumpautoload

Un composant pour l'ensemble des données de la commande 

On crée un autre composant qui va rassembler les données de la commande :

php artisan make:component elements --view

<x-card class="w-full sm:min-w-[50vw]" title="{{ __('References') }}" shadow separator >
    <p><strong>Commande n° {{ $order->reference }}</strong></p>
    @if($order->purchase_order)
        <p><strong>Bon de commande n° {{ $order->purchase_order }}</strong></p>
    @endif
    <p><strong>Date :</strong> {{ $order->created_at->calendar() }}</p>
</x-card>
<br>
<x-card class="w-full sm:min-w-[50vw]" title="{{ __('Products') }}" shadow separator >
    <x-details 
        :content="$order->products" 
        :shipping="$order->shipping" 
        :tax="$order->tax" 
        :total="$order->total"
        :pick="$order->pick"
    />
</x-card>
<br>
<x-card class="w-full sm:min-w-[50vw]" shadow separator >
    <x-card class="w-full sm:min-w-[50vw]" title="{{ __('Billing address') }} {{ $order->addresses->count() === 1 && !$order->pick ? __('and delivery') : '' }}" shadow separator >
        <x-address :address="$order->addresses->first()" />
    </x-card>
        @if($order->pick)
            <x-alert title="{!! __('I chose to pick up my order on site.') !!}" icon="o-exclamation-triangle" class="mt-2 alert-info" />
        @else
            @if($order->addresses->count() === 2)
                <x-card class="w-full sm:min-w-[50vw]" title="{{ __('Delivery address') }}" shadow separator >
                    <x-address :address="$order->addresses->get(1)" />  
                </x-card>
            @endif              
        @endif
</x-card>

Quelques traductions :

"Details of my order": "Détails de ma commande",
"Payment method": "Moyen de paiement",
"Products": "Produits",
"References": "Références",
"You were unable to make your credit card payment.": "Vous n'avez pas réussi à effectuer votre paiement par carte bancaire.",
"Please contact us.": "Veuillez nous contacter.",
"Download invoice": "Télécharger la facture",
"Billing address": "Adresse de facturation",
"and delivery": "et de livraison",
"I chose to pick up my order on site.": "J'ai choisi de venir chercher ma commande sur place.",
"Back to orders": "Retour aux commandes",
"Delivery address": "Adresse de livraison",

Le composant pour la page

Maintenant, lorsque le client clique sur une ligne de commande dans le tableau, ça doit lui ouvrir une nouvelle page avec tous les détails pour cette commande.

On crée un composant pour cette page :

php artisan make:volt account/orders/show --class

Avec ce code :

<?php

use App\Models\Order;
use Livewire\Volt\Component;
use Livewire\Attributes\Title;

new #[Title('Show order')]
class extends Component {

    public Order $order;

    public function mount(Order $order): void
    {
        if(auth()->user()->id != $order->user_id) {
            abort(403);
        }
        
        $this->order = $order;
    }

    public function invoice()
    {
        // Todo : send invoice
    }

}; ?>

<div>
    <x-card class="flex justify-center items-center mt-6 bg-gray-100" title="{{ __('Details of my order') }}" shadow separator >
        <x-elements :order="$order" />
        <br>
        <x-card class="w-full sm:min-w-[50vw]" title="{{ __('State') }}" shadow separator progress-indicator >
            <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center">
                <p class="mb-2 sm:mb-0 sm:mr-4"><strong>@lang('Payment method') :</strong> {{ $order->payment_text }}</p>
                <x-badge value="{{ $order->state->name }}" class="p-3 bg-{{ $order->state->color }}-400 self-start sm:self-center" />                
            </div>            
            @if($order->state->slug === 'carte' || $order->state->slug === 'erreur')
                <br>
                <x-alert title="{!! __('You were unable to make your credit card payment.') !!}" description="{{ __('Please contact us.') }}" icon="o-exclamation-triangle" class="alert-warning" />
            @endif
            @if($order->invoice_id)
                <br>
                <x-button label="{{ __('Download invoice') }}" wire:click="invoice" class="btn-outline" spinner />
            @endif
        </x-card>
        <x-slot:actions>
            <x-button label="{{ __('Back to orders') }}" class="btn-outline" link="{{ route('orders') }}" icon="c-arrow-long-left" wire:/>
        </x-slot:actions>
    </x-card>
</div>

La partie consacrée au téléchargement éventuel de la facture sera ajoutée lorsqu'on mettra en place cette fonctionnalité.

On ajoute la route :

Route::middleware('auth')->group(function () {

	Route::prefix('account')->group(function () {
		...
		Volt::route('/orders/{order}', 'account.orders.show')->name('orders.show');

On peut ainsi accéder aux détails d'une commande. 

Dans la partie supérieure les références :

Au-dessous la liste des produits avec leur prix, leur quantité, le total et éventuellement la TVA :

Encore au-dessous la ou les adresses :

Et enfin le mode de paiement et l'état actuel de la commande :

Il y a plusieurs situations gérées selon le mode de paiement, l'état de la commande, la livraison ou le retrait sur place, la réussite ou l'échec du paiement par carte, la présence éventuelle d'une facture à télécharger...

Le RGPD

Le RGPD est encore un truc administratif qui nous occupe beaucoup ! Le client doit pouvoir accéder à toutes les informations qu'on possède sur lui. Comme on aura besoin de générer des fichiers PDF on va ajouter un package à notre projet :

composer require barryvdh/laravel-dompdf

La page d'accueil

Pour les informations relatives au RGPD on crée une page d'accueil avec un composant Volt :

php artisan make:volt account/rgpd/index --class

<?php

use App\Models\Shop;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Barryvdh\DomPDF\Facade\Pdf;

new #[Title('Rgpd')]
class extends Component {

	public function getPdf()
	{
		$user = Auth()->user();

		$user->load('addresses', 'orders', 'orders.state', 'orders.products');

		$shop = Shop::firstOrFail();

		$pdf = Pdf::loadView('livewire.account.rgpd.pdf', compact('user', 'shop'));

        return response()->streamDownload(
            function () use ($pdf) {
                echo $pdf->stream();
            },
            'rgpd.pdf'
        );
	}

}; ?>

<div>
	<x-card class="flex items-center justify-center mt-6" title="{{ __('RGPD') }}" shadow separator progress-indicator>
		<x-accordion wire:model="group" class="w-full sm:min-w-[50vw]">
			<x-collapse name="group1" class=" bg-base-200">
				<x-slot:heading>@lang('Access to my informations')</x-slot:heading>
				<x-slot:content class="text-center">
					<p>@lang('You can access your personal information stored in our database. Just click on the button below to download a PDF document containing all your data.')</p>
					<br>
					<x-button label="{{ __('Get my information') }}" wire:click="getPdf" class="w-full btn-primary" />
				</x-slot:content>
			</x-collapse>
			<x-collapse name="group2" class="bg-base-200">
				<x-slot:heading>@lang('Correction of mistakes')</x-slot:heading>
				<x-slot:content class="text-center">
					<p>@lang('You can modify all personal information accessible from your account page: identity, addresses. For any other rectification, please contact us by sending an e-mail to the address below. We will reply as soon as possible.')</p>
					<br>
					<a href="mailto:{{ $shop->email }}" class="text-blue-600">{{ $shop->email }}</a>					
				</x-slot:content>
			</x-collapse>
		</x-accordion>
    </x-card>
</div>

Quelques traductions :

"Access to my informations": "Accès à mes informations",
"You can access your personal information stored in our database. Just click on the button below to download a PDF document containing all your data.": "Vous pouvez accéder aux informations personnelles stockées dans notre base de données. Cliquez sur le bouton ci-dessous pour décharger un document PDF contenant toutes vos données.",
"Get my information": "Récupérer mes informations",
"Correction of mistakes": "Correction des erreurs",
"You can modify all personal information accessible from your account page: identity, addresses. For any other rectification, please contact us by sending an e-mail to the address below. We will reply as soon as possible.": "Vous pouvez modifier toutes les informations personnelles accessibles depuis la page de votre compte : identité, adresses. Pour toute autre rectification contactez nous en nous envoyant un e-mail à l'adresse ci-dessous. Nous vous répondrons dans les plus brefs délais.",

On ajoute la route :

Route::middleware('auth')->group(function () {

	Route::prefix('account')->group(function () {
		...
		Volt::route('/rgpd', 'account.rgpd.index')->name('rgpd');

On renseigne la barre de navigation :

<x-menu-item title="{{ __('RGPD') }}" link="{{ route('rgpd') }}" />

Et la barre latérale :

<x-menu-item title="{{ __('RGPD') }}" icon="o-lock-closed" link="{{ route('rgpd') }}" />

Et voilà le résultat :

La partie supérieure concerne la récupération des informations :

La partie inférieure concerne la rectification des données :

Ici, on rappelle au client qu'il peut modifier ses données à partir de son compte, mais aussi la possibilité de nous joindre avec une adresse email qu'il suffit de cliquer.

La récupération des données

J'ai dit ci-dessus qu'on va générer un fichier PDF pour la récupération des données. Pour cette génération, on doit créer une trame, on la place dans une nouvelle vue :

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
  </head>
  <body>
    <h2>{{ $shop->name }}</h2>
    <p>{{ $user->name }} {{ $user->firstname }}</p>
    <p>{{ \Carbon\Carbon::now() }}</p>
    <h3>@lang('General informations')</h3>
    <table class="table">
      <thead>
        <tr>
          <th scope="col">@lang('Name')</th>
          <th scope="col">@lang('FirstName')</th>
          <th scope="col">@lang('Email')</th>
          <th scope="col">@lang('Account creation date')</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>{{ $user->name }}</td>
          <td>{{ $user->firstname }}</td>
          <td>{{ $user->email }}</td>
          <td>{{ $user->created_at->calendar() }}</td>
        </tr>
      </tbody>
    </table>
    <h3>@lang('Addresses')</h3>
    @foreach($user->addresses as $address)
      <table class="table table-bordered table-striped table-sm">
        <tbody>
          @isset($address->name)
            <tr>
              <td><strong>@lang('Name')</strong></td>
              <td>{{ "$address->civility $address->name $address->firstname" }}</td>
            </tr>
          @endisset
          @if($address->company)
            <tr>
              <td><strong>@lang('Company name')</strong></td>
              <td>{{ $address->company }}</td>
            </tr>          
          @endif 
          <tr>
            <td><strong>@lang('Address')</strong></td>
            <td>{{ $address->address }}</td>
          </tr>
          @if($address->addressbis)
            <tr>
              <td><strong>@lang('Address complement')</strong></td>
              <td>{{ $address->addressbis }}</td>
            </tr>     
          @endif
          @if($address->bp)
            <tr>
              <td><strong>@lang('Postcode')</strong></td>
              <td>{{ $address->bp }}</td>
            </tr>
          @endif
          <tr>
            <td><strong>@lang('City')</strong></td>
            <td>{{ "$address->postal $address->city" }}</td>
          </tr>
          <tr>
            <td><strong>@lang('Country')</strong></td>
            <td>{{ $address->country->name }}</td>
          </tr>
          <tr>
            <td><strong>@lang('Phone number')</strong></td>
            <td>{{ $address->phone }}</td>
          </tr>
        </tbody>
      </table>
      <hr>
    @endforeach
    <h3>@lang('Orders')</h3>
    @foreach($user->orders as $order)
      <table class="table table-bordered table-striped table-sm">
        <thead>
          <tr>
            <th>@lang('Reference')</th>
            <th>@lang('Date')</th>
            <th>@lang('Total incl. VAT')</th>
            <th>@lang('Payment method')</th>
            <th>@lang('State')</th>
          </tr>
        </thead>    
        <tbody>        
          <tr>
            <td>{{ $order->reference }}</td>
            <td>{{ $order->created_at->calendar() }}</td>
            <td>{{ number_format($order->total + $order->shipping, 2, ',', ' ') }} €</td>
            <td>{{ $order->payment_text }}</td>
            <td>{{ $order->state->name }}</td>
          </tr>
        </tbody>
      </table>
      <h5>@lang('Details of the order')</h5>
      <table class="table table-bordered table-striped table-sm">
        @foreach ($order->products as $item)
          <tr>
            <td>{{ $item->name }} ({{ $item->quantity }} @if($item->quantity > 1) items) @else item) @endif</td>
            <td>{{ number_format($item->total_price_gross, 2, ',', ' ') }} €</td>
          </tr>
        @endforeach
        <tr>
          <td>@lang('Colissimo delivery')</td>
          <td>{{ number_format($order->shipping, 2, ',', ' ') }} €</td>
        </tr>
        <tr>
          <td>@lang('VAT to ') {{ $order->tax * 100 }} %</td>
          <td>{{ number_format($order->total / (1 + $order->tax) * $order->tax, 2, ',', ' ') }} €</td>
        </tr>
        <tr>
          <td>@lang('Total incl. VAT')</td>
          <td>{{ number_format($order->total + $order->shipping, 2, ',', ' ') }} €</td>
        </tr>
      </table>
      <hr>
    @endforeach
    <h3>@lang('Newsletter')</h3>
    @if($user->newsletter)
      <p>@lang('You have subscribed to our newsletter.')</p>
    @else
      <p>@lang('You are not subscribed to the newsletter.')</p>
    @endif
  </body>
</html>

C'est une page HTML classique qui sert de trame pour générer le PDF.

On a encore quelques traductions à prévoir :

"Account creation date": "Date de création du compte",
"Address": "Adresse",
"Address complement": "Complément d'adresse",
"You have subscribed to our newsletter.": "Vous avez souscrit à notre lettre d'information.",
"You are not subscribed to the newsletter.": "Vous n'avez pas souscrit à la lettre d'information.",
"Orders": "Commandes",
"Details of the order": "Détails de la commande",
"Newsletter": "Lettre d'information",

Je ne vous montre pas le résultat qui est un peu trop volumineux.

Conclusion

Nous en avons terminé avec le compte client. Dans la prochaine étape, on passera au cœur du sujet en affichant le détail des articles et en implémentant un panier.



Par bestmomo

Aucun commentaire

Article précédent : Shop : le compte client 1/2
Article suivant : Shop : le panier