Back to "En prenumerationstjänst för nyhetsbrev Pt. 1 - Krav och prenumeration sida"

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

En prenumerationstjänst för nyhetsbrev Pt. 1 - Krav och prenumeration sida

Saturday, 21 September 2024

Inledning

Medan du besöker andra människors bloggar märkte jag att många av dem har en prenumerationstjänst som gör det möjligt för användare att registrera sig för att få ett e-postmeddelande skickat till dem varje vecka med inläggen från den bloggen. Jag bestämde mig för att implementera min egen version av detta och dela hur jag gjorde det.

OBS: Jag förväntar mig inte att någon faktiskt kommer att använda detta, Jag har en hel del tid på mina händer efter ett kontrakt föll igenom så detta håller mig upptagen!

Steg

Så för att skapa denna tjänst bestämde jag följande krav.

  1. En flexibel prenumerationssida som ger användarna en viss flexibilitet.
    1. Användare lägger bara in e-post.
    2. Förmågan att välja språk i e-postmeddelandet.
    3. Förmågan att välja frekvensen av e-post.
      • Om varje månad välja dagen i månaden det skickas.
      • Om Weekly väljer veckodagen skickas den.
      • Tillåt dagligt intag (tid på dagen är inte viktigt).
      • Tillåt automatiskt inlägg att skicka ett mail när jag bloggar.
    4. Låt användaren välja de kategorier de är intresserade av
  2. Tillåt enkel avprenumeration
  3. Tillåt förhandsgranskning av brevet de får
  4. Låt användaren ändra sina inställningar när som helst.
  5. En separat tjänst som hanterar e-postutskick.
    • Denna tjänst kommer att kallas av bloggtjänsten när ett nytt inlägg görs.
    • Denna tjänst kommer sedan att skicka e-post till alla prenumeranter.
    • Det kommer att använda Hangfire för att schemalägga skickandet av e-post.

Konsekvensen av detta är att jag har en hel del arbete att göra.

Prenumerationssidan

Jag började med det roliga och skrev en prenumerationssida. Jag ville att detta skulle fungera bra på skrivbordet samt mobila webbläsare (och även SPA), från min Umami analys Jag kan se en rättvis andel användare komma åt denna röra från mobila enheter.

Umamu-plattformar

Jag ville också att prenumerationssidan skulle vara tydlig hur man använder; jag är en stor anhängare av Steve Krugs: "Få mig inte att tänka." filosofi där en sida ska vara uppenbar att använda och inte kräva att användaren ska tänka på hur man använder den.

Detta innebär att standardvärden bör vara majoriteten av användarna kommer att vilja använda. Jag bestämde mig för följande standardvärden:

  1. Veckans e-post
  2. Engelska språket
  3. Alla kategorier valda
  4. E-post skickas på en måndag

Jag kan naturligtvis ändra detta senare om dessa visar sig vara felaktiga.

Så det här är sidan jag byggde upp:

Prenumeration sida

Koden för sidan

Som med resten av denna webbplats ville jag göra koden så enkel som möjligt. Den använder följande 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>
Du kan se att detta är ganska enkelt som det går. Den använder Alpine.js för att hantera alla användarinteraktioner och en gemensam UI element för alla val.
<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>

Du kan se att detta är CSS-baserad, med hjälp av Tailwind CSS ramverk peer CSS verktyg för att ange att när etiketten klickas på den ska ställa in den kontrollerade egenskapen och ändra dess styling.

Jag använder detta senare på sidan för att avgöra vilken väljare (Vecka dagen / Månadens dag) för att göra tillgänglig för användare och visa de element som gör det möjligt att välja.

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

Du kan se att jag har en Alpine.js css mall klass som ställer in opaciteten av elementet till 50% och inaktiverar pekare händelser om schemat inte är satt till Månatlig. Detta är ett enkelt sätt att dölja element som inte behövs.

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

Det döljer också Monethly dagväljaren om schemat inte är satt till Månatlig.

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

Så det är jist på schemasidan. Jag täcker backend i nästa inlägg.

Nästa steg

I nästa inlägg ska jag (när jag är klar bulding det) posta de nya strukturella förändringarna i projektet så att jag kan använda en separat webbapplikation för Hanfgire och e-postsändartjänsten. Detta är en betydande förändring eftersom jag går från ett enda projekt Mostlylucid För ett antal projekt som gör det möjligt att dela tjänster:

  1. Mostlylucid - Huvudsideprojektet.

  2. Mostlylucid.SchedulerService - Detta är det viktigaste Hangfire projektet som kommer att få tillgång till databasen, bygga e-post och skicka dem.

  3. Mostlylucid.Services - Där de tjänster bor som returnerar data till toppnivå projekt

  4. Mostlylucid.Shared - Hjälpare och konstanter som används av alla projekt.

  5. Mostlylucid.DbContext - Databasens sammanhang för projektet.

Du kan se detta ger betydligt mer komplexitet till systemarkitekturen; men i detta fall är det nödvändigt att hålla projektet underhållbart och skalbart. Jag täcker hur jag gjorde i resten av den här serien.

Slutsatser

Jag har fortfarande ett ton arbete att göra för att få allt detta att hända. Refaktorn är något komplex eftersom det innebär att lägga till flera lager i systemet (och begrepp som DTOs till projektet) men jag tycker att det är värt det i det långa loppet.

logo

©2024 Scott Galloway