Un servicio de suscripción de boletín de noticias Pt. 1 - Requisitos y página de suscripción (Español (Spanish))

Un servicio de suscripción de boletín de noticias Pt. 1 - Requisitos y página de suscripción

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Saturday, 21 September 2024

//

Less than a minute

Introducción

Mientras revisaba los blogs de otras personas me di cuenta de que muchos de ellos tienen un servicio de suscripción que permite a los usuarios registrarse para recibir un correo electrónico semanalmente con las publicaciones de ese blog. Decidí implementar mi propia versión de esto y compartir cómo lo hice.

NOTA: ¡No espero que nadie realmente use esto, tengo mucho tiempo en mis manos después de que un contrato se derrumbó así que esto me mantiene ocupado!

Pasos

Así que para crear este servicio decidí los siguientes requisitos.

  1. Una página de suscripción flexible que permite a los usuarios cierta flexibilidad.
    1. Los usuarios sólo ponen en el correo electrónico.
    2. La capacidad de seleccionar el idioma del correo electrónico.
    3. La capacidad de seleccionar la frecuencia del correo electrónico.
      • Si se selecciona mensualmente el día del mes que se envía.
      • Si Weekly selecciona el día de la semana que se envía.
      • Permita diariamente (la hora del día no es importante).
      • Permitir que la publicación automática para enviar un correo electrónico cada vez que blogueo.
    4. Permitir que el usuario seleccione las categorías en las que está interesado
  2. Permitir una suscripción sencilla
  3. Permitir una vista previa del correo que recibirán
  4. Permitir que el usuario cambie sus preferencias en cualquier momento.
  5. Un servicio separado que se encarga del envío de correos electrónicos.
    • Este servicio será llamado por el servicio de blog cada vez que se haga un nuevo post.
    • Este servicio enviará el correo electrónico a todos los suscriptores.
    • Utilizará Hangfire para programar el envío de correos electrónicos.

El impacto de esto es que tengo mucho trabajo que hacer.

Página de suscripción

Empecé con la parte divertida, escribiendo una página de suscripción. Quería que esto funcione bien en el escritorio, así como los navegadores móviles (e incluso el SPA); de mi análisis de Umami Puedo ver una proporción justa de usuarios acceden a este movimiento desde dispositivos móviles.

Plataformas de Umamu

También quería que la página de suscripción fuera obvio cómo usar; soy un gran creyente en Steve Krug dice "No me hagas pensar" filosofía donde una página debe ser obvia para usar y no requerir que el usuario piense en cómo utilizarla.

Esto significa que los valores predeterminados deben ser la mayoría de los usuarios desearán utilizar. Decidí sobre los siguientes valores predeterminados:

  1. Correos electrónicos semanales
  2. Idioma inglés
  3. Todas las categorías seleccionadas
  4. Correo electrónico enviado un lunes

Por supuesto, puedo cambiar esto más tarde si éstos resultan ser incorrectos.

Así que esta es la página que terminé construyendo:

Suscripción Página

El código de la página

Como con el resto de este sitio quería hacer el código lo más simple posible. Utiliza el siguiente 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>
Se puede ver que esto es bastante simple como va. Utiliza Alpine.js para manejar todas las interacciones del usuario y un elemento común de interfaz de usuario para todas las selecciones.
<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>

Puede ver que esto se basa en CSS, utilizando el marco CSS de Tailwind peer Utilidad CSS para especificar que cuando la etiqueta se haga clic en ella debe establecer la propiedad de entrada y cambiar su estilo.

Utilizo esto más tarde en la página para determinar qué selector (Día de la Semana / Día del Mes) para poner a disposición de los usuarios y mostrar los elementos que permiten la selección.

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

Puedes ver que tengo una clase de plantilla Alpine.js css que establece la opacidad del elemento en un 50% y desactiva los eventos de puntero si el horario no está configurado en Mensual. Esta es una manera sencilla de ocultar elementos que no son necesarios.

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

También oculta el selector del día Monethly si el horario no está establecido en Mensual.

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

Así que esa es la clave de la página de programación. Cubriré el motor en el próximo post.

Próximos pasos

En el próximo post (cuando termine de abultarlo) publicaré los nuevos cambios estructurales en el proyecto para permitirme usar una aplicación web separada para Hanfgire y el servicio de envío de correo electrónico. Este es un cambio sustancial a medida que paso de un solo proyecto Mostlylucid a una serie de proyectos que permiten compartir servicios:

  1. Mostlylucid - El proyecto principal del sitio web.

  2. Mostlylucid.SchedulerService - Este es el proyecto principal de Hangfire que accederá a la base de datos, construirá los correos electrónicos y los enviará.

  3. Mostlylucid.Services - Donde viven los servicios que devuelven los datos a los proyectos de nivel superior

  4. Mostlylucid.Shared - Ayudantes y Constantes utilizados por todos los proyectos.

  5. Mostlylucid.DbContext - El contexto de la base de datos para el proyecto.

Se puede ver que esto añade mucha más complejidad a la arquitectura del sistema; pero en este caso es necesario mantener el proyecto mantenible y escalable. Cubriré cómo hice esto en el resto de esta serie.

Conclusión

Todavía tengo un montón de trabajo que hacer para hacer que todo esto suceda. La refactorización es algo compleja ya que implica añadir múltiples capas al sistema (y conceptos como DTOs al proyecto), pero creo que vale la pena a largo plazo.

logo

©2024 Scott Galloway