Back to "A Newsletter Abonnement Service Pt. 1 - Anforderungen und Abonnement Seite"

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 Abonnement Service Pt. 1 - Anforderungen und Abonnement Seite

Saturday, 21 September 2024

Einleitung

Während der Durchsuchung der Blogs anderer Leute bemerkte ich, dass viele von ihnen einen Abonnement-Service haben, der Benutzern erlaubt, sich zu registrieren, um eine E-Mail an sie wöchentlich mit den Beiträgen von diesem Blog gesendet zu haben. Ich beschloss, meine eigene Version davon zu implementieren und zu teilen, wie ich es getan habe.

HINWEIS: Ich erwarte nicht, dass jemand dies tatsächlich benutzen wird, ich habe viel Zeit auf meinen Händen, nachdem ein Vertrag durchgefallen ist, also hält mich das beschäftigt!

Schritte

Um diesen Service zu schaffen, habe ich mich für folgende Anforderungen entschieden.

  1. Eine flexible Abonnement-Seite, die Benutzern etwas Flexibilität ermöglicht.
    1. Benutzer setzen nur in E-Mail.
    2. Die Möglichkeit, Sprache der E-Mail zu wählen.
    3. Die Möglichkeit, die Häufigkeit der E-Mail zu wählen.
      • Wenn monatlich wählen Sie den Tag des Monats, der gesendet wird.
      • Wenn Wöchentlich wählen Sie den Tag der Woche, die es gesendet wird.
      • Täglich zulassen (die Tageszeit ist nicht wichtig).
      • Lassen Sie Auto-Posting, um eine E-Mail zu senden, wenn ich blog.
    4. Erlauben Sie dem Benutzer, die Kategorien auszuwählen, an denen sie interessiert sind
  2. Einfache Abmeldung zulassen
  3. Vorschau der Mail zulassen, die sie erhalten werden
  4. Erlauben Sie dem Benutzer, seine Präferenzen jederzeit zu ändern.
  5. Ein separater Service, der den Versand von E-Mails übernimmt.
    • Dieser Dienst wird vom Blog-Dienst aufgerufen werden, wann immer ein neuer Beitrag gemacht wird.
    • Dieser Service wird dann die E-Mail an alle Abonnenten senden.
    • Es wird Hangfire verwenden, um das Senden von E-Mails zu planen.

Die Auswirkung ist, dass ich viel Arbeit zu tun habe.

Die Abonnementseite

Ich begann mit dem lustigen Teil und schrieb eine Abonnement-Seite. Ich wollte, dass dies gut funktioniert auf dem Desktop sowie mobile Browser (und sogar die SPA); von meine Umami-Analytik Ich sehe einen gerechten Anteil der Nutzer von mobilen Geräten aus auf diese Aufregung zugreifen.

Umamu-Plattformen

Ich wollte auch, dass die Abonnement-Seite offensichtlich sein, wie man verwendet; Ich bin ein großer Gläubiger in Steve Krugs "Zwing mich nicht zum Nachdenken" Philosophie, wo eine Seite sollte offensichtlich sein, um zu verwenden und nicht verlangen, dass der Benutzer darüber nachzudenken, wie man es verwenden.

Dies bedeutet, dass die Voreinstellungen sollte die Mehrheit der Benutzer wollen. Ich habe mich für folgende Ausfälle entschieden:

  1. Wöchentliche E-Mails
  2. Englische Sprache
  3. Alle Kategorien ausgewählt
  4. E-Mail gesendet an einem Montag

Ich kann das natürlich später ändern, wenn sich diese als falsch erweisen.

Das ist also die Seite, die ich aufgebaut habe:

Abonnementseite

Der Code für die Seite

Wie beim Rest dieser Seite wollte ich den Code so einfach wie möglich machen. Es verwendet das folgende 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>
Sie können sehen, dass dies ziemlich einfach ist, wie es geht. Es verwendet Alpine.js, um alle Benutzerinteraktionen und ein gemeinsames UI-Element für alle Selektionen zu handhaben.
<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>

Sie können sehen, dass dies CSS basiert, mit dem Tailwind CSS-Framework peer CSS-Dienstprogramm, um festzulegen, dass beim Anklicken des Etiketts die geprüfte Eigenschaft der Eingabe eingestellt und das Styling geändert werden soll.

Ich nutze dies später auf der Seite, um zu bestimmen, welcher Selector (Wochentag / Tag des Monats) den Benutzern zur Verfügung steht und welche Elemente die Auswahl erlauben.

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

Sie können sehen, dass ich eine Alpine.js css Template-Klasse habe, die die Deckkraft des Elements auf 50% setzt und Zeigerereignisse deaktiviert, wenn der Zeitplan nicht auf Monthly gesetzt ist. Dies ist eine einfache Möglichkeit, Elemente zu verstecken, die nicht benötigt werden.

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

Es versteckt auch den Monethly Tag Selektor, wenn der Zeitplan nicht auf Monthly gesetzt ist.

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

Das ist also der Witz auf der Planseite. Ich kümmere mich im nächsten Post um das Backend.

Nächste Schritte

Im nächsten Beitrag poste ich die neuen strukturellen Änderungen an dem Projekt, damit ich eine separate Web-Anwendung für Hanfgire und den E-Mail-Sendungsdienst verwenden kann. Dies ist eine wesentliche Veränderung, da ich von einem einzigen Projekt gehe Mostlylucid für eine Reihe von Projekten, die die gemeinsame Nutzung von Dienstleistungen ermöglichen:

  1. Mostlylucid - Das Haupt-Website-Projekt.

  2. Mostlylucid.SchedulerService - Dies ist das Haupt-Hangfire-Projekt, das die Datenbank übernimmt, die E-Mails erstellt und sendet.

  3. Mostlylucid.Services - Wo die Dienste leben, die Daten zu den Top-Level-Projekten zurückgeben

  4. Mostlylucid.Shared - Helfer und Konstanten, die von allen Projekten verwendet werden.

  5. Mostlylucid.DbContext - Der Datenbankkontext für das Projekt.

Sie können sehen, dass dies der Systemarchitektur deutlich mehr Komplexität verleiht; in diesem Fall ist es jedoch notwendig, das Projekt wartungsfähig und skalierbar zu halten. Ich berichte, wie ich das in der restlichen Serie gemacht habe.

Schlussfolgerung

Ich habe noch eine TON Arbeit zu erledigen, um all dies geschehen zu lassen. Das Refactoring ist etwas komplex, da es mehrere Ebenen zum System hinzufügt (und Konzepte wie DTOs zum Projekt), aber ich denke, es lohnt sich auf lange Sicht.

logo

©2024 Scott Galloway