Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/.server/emails/sendNewsletterDelivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getSesTransport } from "./sendgridTransport";
import { newsletterDeliveryEmail } from "./templates/newsletterDelivery";

export const sendNewsletterDelivery = async ({
email,
subject,
content,
newsletterName,
deliveryIndex,
}: {
email: string;
subject: string;
content: string; // HTML ya renderizado
newsletterName?: string;
deliveryIndex?: number;
}) => {
return getSesTransport()
.sendMail({
from: "EasyBits@easybits.cloud",
subject,
bcc: [email],
html: newsletterDeliveryEmail({
subject,
content,
newsletterName,
deliveryIndex,
}),
})
.then((result: unknown) => console.log(result))
.catch((e: Error) => console.error(e));
};
37 changes: 37 additions & 0 deletions app/.server/emails/templates/newsletterDelivery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const newsletterDeliveryEmail = ({
subject,
content,
newsletterName,
deliveryIndex,
}: {
subject: string;
content: string; // Puede ser HTML o markdown ya renderizado
newsletterName?: string;
deliveryIndex?: number;
}) => `
<div style="font-family:Arial;background-color:#f9f9f9;">
<div style="background: #f9f9f9; margin: 0 auto; padding: 16px">
<div style="background-color: white; border-radius: 16px; margin: 0 auto; max-width: 600px; overflow: hidden; box-shadow: 0 2px 8px #0001;">
<div style="padding: 24px 32px 8px 32px;">
<h2 style="color: #9870ed; font-size: 22px; margin: 0 0 8px 0;">${
newsletterName ? newsletterName : "Newsletter"
}</h2>
<h1 style="color: #222; font-size: 26px; margin: 0 0 16px 0;">${subject}</h1>
${
typeof deliveryIndex === "number"
? `<div style="color: #888; font-size: 14px; margin-bottom: 16px;">Entrega #${
deliveryIndex + 1
}</div>`
: ""
}
<div style="font-size: 16px; color: #222; line-height: 1.6; margin-bottom: 24px;">
${content}
</div>
</div>
<div style="background: #f2f2f2; padding: 16px; text-align: center; border-radius: 0 0 16px 16px; color: #888; font-size: 13px;">
<span>Gracias por ser parte de nuestra comunidad.</span>
</div>
</div>
</div>
</div>
`;
82 changes: 82 additions & 0 deletions app/.server/newsletters/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { EmailNode } from "~/components/newsletters/EmailNodeCard";
import { Agenda } from "agenda";
import { sendNewsletterDelivery } from "../emails/sendNewsletterDelivery";
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";
import { db } from "../db";

const agenda = new Agenda({
db: {
address: process.env.DATABASE_URL || "mongodb://localhost:27017/your-db",
},
processEvery: "30 seconds",
});

async function renderMarkdown(md: string) {
return sanitizeHtml(await marked(md));
}

agenda.define("send_newsletter_delivery", async (job: any) => {
const { email, title, content, newsletterId, subscriberId, deliveryIndex } =
job.attrs.data as any;
// Renderiza el contenido markdown a HTML
const htmlContent = await renderMarkdown(content);
await sendNewsletterDelivery({
email,
subject: title,
content: htmlContent,
newsletterName: newsletterId, // Puedes buscar el nombre real si lo necesitas
deliveryIndex,
});
// Actualizar el progreso del suscriptor
const subscriber = await db.newsletterSubscriber.update({
where: { id: subscriberId },
data: {
currentStep: deliveryIndex + 1,
lastSentAt: new Date(),
},
});
// Programar la siguiente entrega si existe
const newsletter = await db.newsletter.findUnique({
where: { id: newsletterId },
});
if (!newsletter) return;
const deliveries: EmailNode[] = newsletter.data as unknown as EmailNode[];
if (subscriber.currentStep < deliveries.length) {
const nextEntrega = deliveries[subscriber.currentStep];
const when = nextEntrega.delay || "in 1 day";
await agenda.schedule(when, "send_newsletter_delivery", {
email: subscriber.email,
newsletterId,
subscriberId,
deliveryIndex: subscriber.currentStep,
title: nextEntrega.title,
content: nextEntrega.content,
});
}
// Elimina el job ejecutado
await job.remove();
});

export async function scheduleNewsletterDeliveries({
newsletter,
subscriber,
}: {
newsletter: any;
subscriber: any;
}) {
// Solo programa la primera entrega
const deliveries: EmailNode[] = newsletter.data as unknown as EmailNode[];
if (deliveries.length === 0) return;
const entrega = deliveries[0];
const when = entrega.delay || "in 1 day";
await agenda.start(); // Asegura que el worker esté corriendo
await agenda.schedule(when, "send_newsletter_delivery", {
email: subscriber.email,
newsletterId: newsletter.id,
subscriberId: subscriber.id,
deliveryIndex: 0,
title: entrega.title,
content: entrega.content,
});
}
4 changes: 2 additions & 2 deletions app/components/common/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ export const Input = ({
{label && <span>{label}</span>}
<div className="relative flex-1">
<ElementName
autoFocus
defaultValue={defaultValue}
autoFocus
className={cn(
"rounded-xl p-4 text-lg h-12 w-full placeholder:text-tale placeholder:font-light border border-black bg-white text-black",
"focus:border-brand-500 focus:outline-none focus:ring-brand-500",
{
"pr-24": !!copy,
"ring-2 ring-red-500 transition-all border-none": isError,
"px-4 pt-2" : type === "textarea"
"px-4 pt-2": type === "textarea",
},
inputClassName
)}
Expand Down
205 changes: 205 additions & 0 deletions app/components/newsletters/EmailNodeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import React from "react";
import { Input } from "~/components/common/Input";
import { BrutalButton } from "~/components/common/BrutalButton";

export interface EmailNode {
id: string;
title: string;
trigger: string;
content: string;
delay: string;
}

export interface EmailNodeCardProps {
node: EmailNode;
isEditing: boolean;
editValue: string;
editContent: string;
editDelay: string;
onEditClick: () => void;
onEditChange: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => void;
onContentChange: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => void;
onDelaySelectChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onDelayInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onEditKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onDelete: () => void;
onSave: () => void;
onCancel: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
disableMoveUp?: boolean;
disableMoveDown?: boolean;
}

const DELAY_OPTIONS = [
{ label: "Inmediatamente", value: "in 0 minutes" },
{ label: "En 1 hora", value: "in 1 hour" },
{ label: "En 6 horas", value: "in 6 hours" },
{ label: "En 1 día", value: "in 1 day" },
{ label: "En 2 días", value: "in 2 days" },
{ label: "En 1 semana", value: "in 1 week" },
{ label: "Personalizado...", value: "custom" },
];

export const EmailNodeCard: React.FC<EmailNodeCardProps> = ({
node,
isEditing,
editValue,
editContent,
editDelay,
onEditClick,
onEditChange,
onContentChange,
onDelaySelectChange,
onDelayInputChange,
onEditKeyDown,
onDelete,
onSave,
onCancel,
onMoveUp,
onMoveDown,
disableMoveUp,
disableMoveDown,
}) => {
const isCustom =
editDelay &&
!DELAY_OPTIONS.some((opt) => opt.value === editDelay) &&
editDelay !== "";

return (
<div
className="bg-white rounded-lg shadow p-4 mb-2 border border-gray-200 relative group drag-handle"
style={{ minWidth: 320, maxWidth: 400 }}
>
{/* Botones de mover arriba/abajo */}
{(onMoveUp || onMoveDown) && (
<div className="absolute top-2 left-2 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
{onMoveUp && (
<button
onClick={onMoveUp}
disabled={disableMoveUp}
className={`text-gray-400 hover:text-blue-600 bg-white rounded-full p-1 shadow border border-gray-200 disabled:opacity-30 disabled:cursor-not-allowed`}
title="Mover arriba"
type="button"
>
</button>
)}
{onMoveDown && (
<button
onClick={onMoveDown}
disabled={disableMoveDown}
className={`text-gray-400 hover:text-blue-600 bg-white rounded-full p-1 shadow border border-gray-200 disabled:opacity-30 disabled:cursor-not-allowed`}
title="Mover abajo"
type="button"
>
</button>
)}
</div>
)}
<button
onClick={onDelete}
className="absolute top-2 right-2 text-gray-400 hover:text-red-500"
title="Eliminar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{isEditing ? (
<>
<Input
value={editValue}
onChange={onEditChange}
onKeyDown={onEditKeyDown}
inputClassName="font-semibold text-lg text-center border-b border-blue-400 focus:outline-none focus:border-blue-600 mb-1"
maxLength={40}
/>
<div className="w-full mt-2 flex flex-col gap-2">
<label className="text-sm font-medium">
Tiempo de espera antes de enviar:
</label>
<select
className="rounded-lg border border-gray-300 px-3 py-2 text-base"
value={isCustom ? "custom" : editDelay}
onChange={onDelaySelectChange}
>
{DELAY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(editDelay === "custom" || isCustom) && (
<Input
value={isCustom ? editDelay : ""}
onChange={onDelayInputChange}
placeholder="Ej: in 3 days, in 5 hours, at 10:00 am"
className="w-full mt-1"
inputClassName="w-full text-base border border-gray-300"
/>
)}
</div>
<Input
type="textarea"
value={editContent}
onChange={onContentChange}
className="w-full mt-2 h-full"
inputClassName="w-full min-h-[320px] text-base border border-gray-300"
placeholder="Contenido markdown de la entrega..."
/>
<nav className="flex items-center justify-end w-full gap-3">
<BrutalButton
mode="ghost"
onClick={onCancel}
containerClassName="mt-4"
>
Cancelar
</BrutalButton>
<BrutalButton onClick={onSave} containerClassName="mt-4">
Guardar
</BrutalButton>
</nav>
</>
) : (
<>
<span
className="font-semibold text-lg mb-1 cursor-pointer hover:underline"
onClick={onEditClick}
title="Editar título y contenido"
>
{node.title}
</span>
<span className="text-sm text-gray-500 mb-2">{node.trigger}</span>
<div className="w-full text-gray-700 text-sm mt-1 line-clamp-3 whitespace-pre-line">
{node.content ? (
node.content.slice(0, 120) +
(node.content.length > 120 ? "..." : "")
) : (
<span className="italic text-gray-400">Sin contenido</span>
)}
</div>
<div className="w-full text-xs text-gray-500 mt-2 italic">
{node.delay ? `Se enviará: ${node.delay}` : ""}
</div>
</>
)}
</div>
);
};
Loading