Back to "Un service d'abonnement à la newsletter Pt. 1 - Exigences et page d'abonnement"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

Alpine.js ASP.NET Email Newsletter

Un service d'abonnement à la newsletter Pt. 1 - Exigences et page d'abonnement

Saturday, 21 September 2024

Présentation

Tout en utilisant les blogs d'autres personnes, j'ai remarqué qu'un grand nombre d'entre eux ont un service d'abonnement qui permet aux utilisateurs de s'inscrire pour leur envoyer un e-mail hebdomadaire avec les messages de ce blog. J'ai décidé de mettre en œuvre ma propre version de ceci et de partager comment je l'ai fait.

REMARQUE: Je ne m'attends pas à ce que quelqu'un l'utilise réellement, j'ai beaucoup de temps sur les mains après qu'un contrat est tombé à travers ainsi cela me garde occupé!

Étapes

Donc, pour créer ce service, j'ai décidé des exigences suivantes.

  1. Une page d'abonnement flexible qui permet aux utilisateurs une certaine flexibilité.
    1. Les utilisateurs ne mettent que dans l'email.
    2. La possibilité de choisir la langue de l'e-mail.
    3. La possibilité de sélectionner la fréquence de l'email.
      • Si le mois choisit le jour du mois, il est envoyé.
      • Si Weekly choisit le jour de la semaine, il est envoyé.
      • Permettre tous les jours (l'heure de la journée n'est pas importante).
      • Permettre l'affichage automatique pour envoyer un courrier chaque fois que je blog.
    4. Permettre à l'utilisateur de sélectionner les catégories qui l'intéressent
  2. Permettre une désabonnement simple
  3. Autoriser l'aperçu du courrier qu'ils recevront
  4. Permettre à l'utilisateur de changer ses préférences à tout moment.
  5. Un service distinct qui gère l'envoi de courriels.
    • Ce service sera appelé par le service de blog chaque fois qu'un nouveau post est fait.
    • Ce service enverra ensuite l'e-mail à tous les abonnés.
    • Il utilisera Hangfire pour planifier l'envoi des courriels.

L'impact de cela est que j'ai beaucoup de travail à faire.

La page d'abonnement

J'ai commencé avec la partie amusante, en écrivant une page d'abonnement. Je voulais que cela fonctionne bien sur le bureau ainsi que les navigateurs mobiles (et même le ESP); à partir de mon analyse Umami Je peux voir une proportion équitable d'utilisateurs accéder à ce remue-méninges à partir d'appareils mobiles.

Plateformes Umamu

Je voulais aussi que la page Abonnement soit évidente comment utiliser; je suis un grand croyant en Steve Krug "Ne me faites pas réfléchir" philosophie où une page devrait être évidente à utiliser et ne pas exiger de l'utilisateur de réfléchir à la façon de l'utiliser.

Cela signifie que les par défaut devraient être la majorité des utilisateurs voudront utiliser. J'ai décidé des défauts suivants :

  1. Courriels hebdomadaires
  2. Anglais
  3. Toutes les catégories sélectionnées
  4. Courriel envoyé un lundi

Je peux bien sûr changer cela plus tard si cela s'avère incorrect.

C'est la page que j'ai fini de construire :

Page d'abonnement

Le Code de la page

Comme pour le reste de ce site, je voulais rendre le code aussi simple que possible. Il utilise le HTML suivant:

Subscription Page
@using Mostlylucid.Shared
@using Mostlylucid.Shared.Helpers
@model Mostlylucid.EmailSubscription.Models.EmailSubscribeViewModel

<form x-data="{schedule :'@Model.SubscriptionType'}" x-init="$watch('schedule', value => console.log(value))" hx-boost="true" asp-action="Save" asp-controller="EmailSubscription" 
      hx-target="#contentcontainer" hx-swap="#outerHTML">
    <div class="flex flex-col mb-4">
        <div class="flex flex-wrap lg:flex-nowrap lg:space-x-4 space-y-4 lg:space-y-0 items-start">
            <label class="input input-bordered flex items-center gap-2 mb-2 dark:bg-custom-dark-bg bg-white w-full lg:w-2/3">
                <i class='bx bx-envelope'></i>
                <input type="email" class="grow text-black dark:text-white bg-transparent border-0"
                       asp-for="Email" placeholder="Email (optional)"/>
            </label>
            <div class="grid grid-cols-2 sm:grid-cols-[repeat(auto-fit,minmax(100px,1fr))] w-full lg:w-1/3">
                @{
                    var frequency = Enum.GetValues(typeof(SubscriptionType)).Cast<SubscriptionType>().ToList();
                }
                @foreach (var freq in frequency)
                {
                    <div class="flex items-center w-auto h-full min-h-[30px] lg:mb-0 mb-3">
                        <input x-model="schedule" id="@freq" type="radio" value="@freq.ToString()" name="SubscriptionType" class="hidden peer">
                        <label for="@freq" class="ml-2 text-sm font-medium text-white 
                bg-blue-dark border-gray-light border rounded-xl px-1 py-2 w-full 
                peer-checked:text-blue-600 peer-checked:dark:bg-green text-center justify-center">
                            @freq.EnumDisplayName()
                        </label>
                    </div>
                }
            </div>
        </div>
        @{
            var languages = LanguageConverter.LanguageMap;
        }

        <div class="grid grid-cols-[repeat(auto-fit,minmax(85px,1fr))] mt-4 gap-2 pl-6 large:pl-0 w-auto">
            @foreach (var language in languages)
            {
                var [email protected] == language.Key ? "checked" : "";
                <div class="tooltip lg:mb-0 mb-2" data-tip="@language.Value)"  >
                    <div class="flex items-center justify-center w-[85px] h-full min-h-[70px]">
                        <input id="@language.Key" type="radio" value="@language.Key" @isChecked name="language" class="hidden peer">
                        <label for="@language.Key" class="flex flex-col items-center justify-center text-sm font-medium text-white bg-blue-dark opacity-50 peer-checked:opacity-100 w-full h-full">
                            <img src="/img/flags/@(language.Key).svg" asp-append-version="true" class="border-gray-light border rounded-l w-full h-full object-cover" alt="@language.Value">
                        </label>
                    </div>

                </div>
            }
        </div>
        <div class="mt-3 border-neutral-400 dark:border-neutral-600 border rounded-lg" x-data="{ hideCategories: false, showCategories: false }">
            <h4
                class="px-5 py-1 bg-neutral-500 bg-opacity-10 rounded-lg font-body text-primary dark:text-white w-full flex justify-between items-center cursor-pointer"
                x-on:click="if(!hideCategories) { showCategories = !showCategories }">
                <span class="flex flex-row items-center space-x-1">
                    Categories
                    <label class="label cursor-pointer ml-4" x-on:click.stop="">
                        all
                    </label>
                    <input type="checkbox" x-on:click.stop="" x-model="hideCategories" asp-for="AllCategories" class="toggle toggle-info toggle-sm" />
                </span>
                <span>
                    <i
                        class="bx text-2xl"
                        x-show="!hideCategories"
                        :class="showCategories ? 'bx-chevron-up' : 'bx-chevron-down'"></i>
                </span>
            </h4>

            <div class="flex flex-wrap gap-2 pt-2 pl-5 pr-5 pb-2"
                x-show="showCategories"
                x-cloak
                x-transition:enter="max-h-0 opacity-0"
                x-transition:enter-end="max-h-screen opacity-100"
                x-transition:leave="max-h-screen opacity-100"
                x-transition:leave-end="max-h-0 opacity-0">
                <div class="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] mt-4 w-full">
                    @foreach (var category in Model.Categories)
                    {
                        var categoryKey = category.Replace(" ", "_").Replace(".", "_").Replace("-", "_");
                        <div class="flex items-center w-auto h-full min-h-[50px]">
                            <input id="@categoryKey" type="checkbox" value="@category"  name="@nameof(Model.SelectedCategories)" class="hidden peer">
                            <label for="@categoryKey" class="ml-2 text-sm font-medium text-white 
            bg-blue-dark border-gray-light border rounded-xl px-1 py-2 w-full 
            peer-checked:text-blue-600 peer-checked:dark:bg-green text-center justify-center">
                                @category
                            </label>
                        </div>
                    }
                </div>
            </div></div>


        <div :class="{ 'opacity-50 pointer-events-none': schedule !== 'Weekly' }" class=" mt-2 border-neutral-400 dark:border-neutral-600 border rounded-lg" >
            <h4
                class="px-5 py-1 bg-neutral-500 bg-opacity-10 rounded-lg font-body text-primary dark:text-white w-full flex justify-between items-center cursor-pointer"
               >
                <span class="flex flex-row items-center space-x-1 ">
                    Day of Week to Send On
                    
                  
                </span>
               
            </h4>

        <div class="grid grid-cols-3 sm:grid-cols-[repeat(auto-fit,minmax(80px,1fr))] my-2 w-full lg:w-1/2" x-show="schedule === 'Weekly'">
            @foreach (var day in Model.DaysOfWeek)
            {
                var checkedDay = day.ToString() == Model.Day ? "checked" : "";
                <div class="flex items-center w-auto h-full min-h-[50px]">
                    <input id="@day" type="radio" value="@day" name="day" @checkedDay class="hidden peer">
                    <label for="@day" class="ml-2 text-sm font-medium text-white 
            bg-blue-dark border-gray-light border rounded-xl px-1 py-2 w-full 
            peer-checked:text-blue-600 peer-checked:dark:bg-green text-center justify-center">
                        @day.ToString()
                    </label>
                </div>
            }
        </div>

            </div>
        <div :class="{ 'opacity-50 pointer-events-none': schedule !== 'Monthly' }" class=" mt-2 border-neutral-400 dark:border-neutral-600 border rounded-lg" >
            <h4
                class="px-5 py-1 bg-neutral-500 bg-opacity-10 rounded-lg font-body text-primary dark:text-white w-full flex justify-between items-center cursor-pointer">
                <span class="flex flex-row items-center space-x-1 ">
                    Day of Month to Send On
                </span>
            </h4>
            <div class="grid grid-cols-[repeat(auto-fit,minmax(35px,1fr))] w-full mx-2" x-show="schedule === 'Monthly'">
                @for(int i=1; i<32; i++)
                {
                    var checkedMonthDay = i == Model.DayOfMonth ? "checked" : "";
                    <div class="flex items-center w-auto my-2 h-full min-h-[35px]">
                        <input id="Day_@i" type="radio" value="@i" name="daypfmonth" @checkedMonthDay class="hidden peer">
                        <label for="Day_@i" class="ml-2 text-sm font-medium text-white 
            bg-blue-dark border-gray-light border rounded-xl px-1 py-2 w-full 
            peer-checked:text-blue-600 peer-checked:dark:bg-green text-center justify-center">
                             @i.GetOrdinal()
                        </label>
                    </div>
                }
            </div>
        </div>
        @* Action Buttons *@
        <div class="flex flex-row gap-2 mt-4">
            <button type="submit" class="btn btn-primary">Subscribe</button>
            <button type="reset" class="btn-warning btn">Reset</button>
        </div>
    </div>
</form>
Vous pouvez voir que c'est assez simple comme ça. Il utilise Alpine.js pour gérer toutes les interactions utilisateur et un élément d'interface utilisateur commun pour toutes les sélections.
<div class="grid grid-cols-2 sm:grid-cols-[repeat(auto-fit,minmax(100px,1fr))] w-full lg:w-1/3">
 @{
var frequency = Enum.GetValues(typeof(SubscriptionType)).Cast<SubscriptionType>().ToList();
 }
 @foreach (var freq in frequency)
{
<div class="flex items-center w-auto h-full min-h-[30px] lg:mb-0 mb-3">
     <input x-model="schedule" id="@freq" type="radio" value="@freq.ToString()" name="SubscriptionType" class="hidden peer">
      <label for="@freq" class="ml-2 text-sm font-medium text-white 
                bg-blue-dark border-gray-light border rounded-xl px-1 py-2 w-full 
                peer-checked:text-blue-600 peer-checked:dark:bg-green text-center justify-center">
                            @freq.EnumDisplayName()
      </label>
</div>
}
</div>

Vous pouvez voir que c'est basé sur CSS, en utilisant le framework de Tailwind CSS peer utilitaire CSS pour spécifier que lorsque l'étiquette est cliqué sur elle devrait définir la propriété cochée de l'entrée et changer son style.

Je l'utilise plus tard dans la page pour déterminer quel sélecteur (Semaine / Jour du Mois) mettre à la disposition des utilisateurs et afficher les éléments permettant la sélection.

<div :class="{ 'opacity-50 pointer-events-none': schedule !== 'Monthly' }" class=" mt-2 border-neutral-400 dark:border-neutral-600 border rounded-lg" >
   <h4
       class="px-5 py-1 bg-neutral-500 bg-opacity-10 rounded-lg font-body text-primary dark:text-white w-full flex justify-between items-center cursor-pointer">
       <span class="flex flex-row items-center space-x-1 ">
           Day of Month to Send On
       </span>
   </h4>
   <div class="grid grid-cols-[repeat(auto-fit,minmax(35px,1fr))] w-full mx-2" x-show="schedule === 'Monthly'">
       @for(int i=1; i<32; i++)
       {
           var checkedMonthDay = i == Model.DayOfMonth ? "checked" : "";
           <div class="flex items-center w-auto my-2 h-full min-h-[35px]">
               <input id="Day_@i" type="radio" value="@i" name="daypfmonth" @checkedMonthDay class="hidden peer">
               <label for="Day_@i" class="ml-2 text-sm font-medium text-white 
   bg-blue-dark border-gray-light border rounded-xl px-1 py-2 w-full 
   peer-checked:text-blue-600 peer-checked:dark:bg-green text-center justify-center">
                    @i.GetOrdinal()
               </label>
           </div>
       }
   </div>
</div>

Vous pouvez voir que j'ai une classe de gabarit Alpine.js css qui fixe l'opacité de l'élément à 50% et qui désactive les événements pointeurs si le calendrier n'est pas fixé à Monthly. C'est une façon simple de cacher des éléments qui ne sont pas nécessaires.

:class="{ 'opacity-50 pointer-events-none': schedule !== 'Monthly' }" 

Il cache également le sélecteur de jour Monethly si l'horaire n'est pas fixé au mois.

x-show="schedule === 'Monthly'"

Donc c'est le jeu de la page des horaires. Je couvrirai l'arrière-plan dans le prochain post.

Prochaines étapes

Dans le post suivant, j'afficherai les nouveaux changements structurels au projet pour me permettre d'utiliser une application web séparée pour Hanfgire et le service d'envoi d'emails. Il s'agit d'un changement important à mesure que je passe d'un seul projet. Mostlylucid à un certain nombre de projets permettant le partage des services:

  1. Mostlylucid - Le projet principal du site.

  2. Mostlylucid.SchedulerService - C'est le principal projet Hangfire qui permettra d'accéder à la base de données, de construire les e-mails et de les envoyer.

  3. Mostlylucid.Services - Où vivent les services qui retournent les données aux projets de haut niveau

  4. Mostlylucid.Shared - Helpers et Constants utilisés par tous les projets.

  5. Mostlylucid.DbContext - Le contexte de la base de données pour le projet.

Vous pouvez voir que cela ajoute beaucoup plus de complexité à l'architecture du système; mais dans ce cas, il est nécessaire de maintenir le projet à jour et évolutive. Je vais raconter comment j'ai fait ça dans le reste de cette série.

En conclusion

J'ai encore un tonneau de travail à faire pour que tout cela arrive. La refacturation est un peu complexe car elle implique l'ajout de plusieurs couches au système (et de concepts comme les DTO au projet) mais je pense que cela en vaut la peine à long terme.

logo

©2024 Scott Galloway