Back to "A Newsletter Servizio abbonamenti Pt. 1 - Requisiti e pagina di abbonamento"

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

A Newsletter Servizio abbonamenti Pt. 1 - Requisiti e pagina di abbonamento

Saturday, 21 September 2024

Introduzione

Mentre perlustrando blog di altre persone ho notato un sacco di loro hanno un servizio di abbonamento che permette agli utenti di iscriversi per avere una email inviata a loro settimanalmente con i post da quel blog. Ho deciso di implementare la mia versione di questo e condividere come ho fatto.

NOTA: Non mi aspetto che qualcuno lo usi davvero, ho un sacco di tempo per le mani dopo che un contratto è caduto, quindi questo mi tiene occupato!

Passi

Così per creare questo servizio ho deciso i seguenti requisiti.

  1. Una pagina di abbonamento flessibile che consente agli utenti una certa flessibilità.
    1. Gli utenti hanno messo solo e-mail.
    2. La possibilità di selezionare la lingua dell'email.
    3. La possibilità di selezionare la frequenza dell'email.
      • Se mensilmente selezionare il giorno del mese viene inviato.
      • Se Settimanale selezionare il giorno della settimana che viene inviato.
      • Permettere ogni giorno (l'ora del giorno non è importante).
      • Permetti l'invio automatico di inviare una mail ogni volta che blog.
    4. Permetti all'utente di selezionare le categorie a cui sono interessati
  2. Permetti la semplice desubscription
  3. Permetti l'anteprima della posta che riceveranno
  4. Permettere all'utente di modificare le proprie preferenze in qualsiasi momento.
  5. Un servizio separato che gestisce l'invio di email.
    • Questo servizio sarà chiamato dal servizio blog ogni volta che viene fatto un nuovo post.
    • Questo servizio invierà l'email a tutti gli abbonati.
    • Userà Hangfire per programmare l'invio di email.

L'impatto di questo è che ho un sacco di lavoro da fare.

La pagina dell'abbonamento

Ho iniziato con la parte divertente, scrivendo una pagina di abbonamento. Volevo che questo per funzionare bene sul desktop e browser mobili (e anche il SPA); da i miei Umami Analytics Vedo una buona percentuale di utenti accedere a questa agitazione da dispositivi mobili.

Piattaforme Umamu

Volevo anche che la pagina Abbonamento fosse ovvio come usare; sono un grande credente in "Non farmi pensare" di Steve Krug. filosofia in cui una pagina dovrebbe essere ovvio da usare e non richiedere all'utente di pensare a come usarlo.

Ciò significa che i valori predefiniti dovrebbero essere la maggior parte degli utenti vorranno utilizzare. Ho deciso i seguenti valori predefiniti:

  1. E-mail settimanali
  2. Lingua inglese
  3. Tutte le categorie selezionate
  4. Email inviata il lunedì

Posso ovviamente cambiare la situazione più tardi, se questi si rivelano errati.

Quindi questa è la pagina che ho finito per costruire:

Pagina di abbonamento

Il codice della pagina

Come per il resto di questo sito ho voluto rendere il codice il più semplice possibile. Usa il seguente HTML:

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>
Potete vedere che questo è abbastanza semplice come va. Utilizza Alpine.js per gestire tutte le interazioni con l'utente e un elemento di interfaccia utente comune per tutte le selezioni.
<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>

Potete vedere che questo è basato sul CSS, usando il framework CSS del Tailwind peer Utilità CSS per specificare che quando l'etichetta è cliccata su di esso dovrebbe impostare la proprietà selezionata dell'input e cambiare è lo styling.

Lo uso più tardi nella pagina per determinare quale selettore (Giorno/Giorno settimanale del mese) mettere a disposizione degli utenti e mostrare gli elementi che permettono la selezione.

<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>

Potete vedere che ho una classe di template Alpine.js css che imposta l'opacità dell'elemento al 50% e disabilita gli eventi del puntatore se il programma non è impostato su Monthly. Questo è un modo semplice per nascondere elementi che non sono necessari.

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

Nasconde anche il selettore del giorno di Monetly se il programma non è impostato su Mensile.

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

Questo è il pugno della pagina del programma. Copriro' il backend nel prossimo post.

Passi successivi

Nel prossimo post (quando avrò finito di rigonfiarlo) pubblicherò le nuove modifiche strutturali al progetto per permettermi di utilizzare un'applicazione web separata per Hanfgire e il servizio di invio e-mail. Questo è un cambiamento sostanziale mentre vado da un singolo progetto Mostlylucid ad una serie di progetti che consentono la condivisione dei servizi:

  1. Mostlylucid - Il progetto principale del sito.

  2. Mostlylucid.SchedulerService - Questo è il principale progetto Hangfire che accederà al Database, costruirà le email e le invierà.

  3. Mostlylucid.Services - Dove vivono i servizi che restituiscono i dati ai progetti di alto livello

  4. Mostlylucid.Shared - Helper e Costanti utilizzati da tutti i progetti.

  5. Mostlylucid.DbContext - Il contesto del database per il progetto.

Potete vedere questo aggiunge significativamente più complessità all'architettura di sistema; ma in questo caso è necessario mantenere il progetto manutenibile e scalabile. Copriro' come ho fatto nel resto di questa serie.

In conclusione

Ho ancora un sacco di lavoro da fare per far accadere tutto questo. Il refactoring è un po' complesso in quanto comporta l'aggiunta di più livelli al sistema (e concetti come DTOs al progetto) ma penso che ne valga la pena a lungo termine.

logo

©2024 Scott Galloway