Merge pull request #10 from useplunk/dev-driaug-formatting

Update Biome formatting settings, improved self-hosting capability and reformatted
This commit is contained in:
Dries Augustyns
2024-08-02 10:54:15 +02:00
committed by GitHub
59 changed files with 841 additions and 2982 deletions
+1 -1
View File
@@ -1 +1 @@
github: [driaug] github: [ driaug ]
+1
View File
@@ -13,6 +13,7 @@ Support can be asked in the `#contributions` channel of the [Plunk Discord serve
- Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders. - Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders.
### 3. Start resources ### 3. Start resources
- Run `yarn services:up` to start a local database and a local redis server. - Run `yarn services:up` to start a local database and a local redis server.
- Run `yarn migrate` to apply the migrations to the database. - Run `yarn migrate` to apply the migrations to the database.
- Run `yarn build:shared` to build the shared package. - Run `yarn build:shared` to build the shared package.
+17 -3
View File
@@ -13,16 +13,30 @@
</p> </p>
## Introduction ## Introduction
Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your applications.
It can be considered as a self-hosted alternative to services like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/). Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your
applications.
It can be considered as a self-hosted alternative to services
like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
## Features ## Features
- **Transactional Emails**: Send emails straight from your API - **Transactional Emails**: Send emails straight from your API
- **Automations**: Create automations based on user actions - **Automations**: Create automations based on user actions
- **Broadcasts**: Send newsletters and product updates to big audiences - **Broadcasts**: Send newsletters and product updates to big audiences
## Self-hosting Plunk ## Self-hosting Plunk
The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image. The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image.
You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/). You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/).
A complete guide on how to deploy Plunk can be found in the [documentation](https://docs.useplunk.com/getting-started/self-hosting). A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/getting-started/self-hosting).
## Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
<a href="https://github.com/useplunk/plunk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
</a>
+5
View File
@@ -17,5 +17,10 @@
"noStaticOnlyClass": "off" "noStaticOnlyClass": "off"
} }
} }
},
"formatter": {
"indentStyle": "tab",
"indentWidth": 1,
"lineWidth": 120
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
# ENV # ENV
JWT_SECRET=mysupersecretJWTsecret JWT_SECRET=mysupersecretJWTsecret
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:56379
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
# AWS # AWS
+12 -14
View File
@@ -82,22 +82,20 @@ server.app.use((req, res, next) => {
next(); next();
}); });
server.app.use( server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
(error: Error, req: Request, res: Response, _next: NextFunction) => { const code = error instanceof HttpException ? error.code : 500;
const code = error instanceof HttpException ? error.code : 500;
if (NODE_ENV !== "development") { if (NODE_ENV !== "development") {
signale.error(error); signale.error(error);
} }
res.status(code).json({ res.status(code).json({
code, code,
error: STATUS_CODES[code], error: STATUS_CODES[code],
message: error.message, message: error.message,
time: Date.now(), time: Date.now(),
}); });
}, });
);
void prisma.$connect().then(() => { void prisma.$connect().then(() => {
server.app.listen(4000, () => { server.app.listen(4000, () => {
+3 -11
View File
@@ -3,10 +3,7 @@
* @param key The key * @param key The key
* @param defaultValue An optional default value if the environment variable does not exist * @param defaultValue An optional default value if the environment variable does not exist
*/ */
export function validateEnv<T extends string = string>( export function validateEnv<T extends string = string>(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T {
key: keyof NodeJS.ProcessEnv,
defaultValue?: T,
): T {
const value = process.env[key] as T | undefined; const value = process.env[key] as T | undefined;
if (!value) { if (!value) {
@@ -21,10 +18,7 @@ export function validateEnv<T extends string = string>(
// ENV // ENV
export const JWT_SECRET = validateEnv("JWT_SECRET"); export const JWT_SECRET = validateEnv("JWT_SECRET");
export const NODE_ENV = validateEnv<"development" | "production">( export const NODE_ENV = validateEnv<"development" | "production">("NODE_ENV", "production");
"NODE_ENV",
"production",
);
export const REDIS_URL = validateEnv("REDIS_URL"); export const REDIS_URL = validateEnv("REDIS_URL");
@@ -36,6 +30,4 @@ export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
export const AWS_REGION = validateEnv("AWS_REGION"); export const AWS_REGION = validateEnv("AWS_REGION");
export const AWS_ACCESS_KEY_ID = validateEnv("AWS_ACCESS_KEY_ID"); export const AWS_ACCESS_KEY_ID = validateEnv("AWS_ACCESS_KEY_ID");
export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SECRET_ACCESS_KEY"); export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SECRET_ACCESS_KEY");
export const AWS_SES_CONFIGURATION_SET = validateEnv( export const AWS_SES_CONFIGURATION_SET = validateEnv("AWS_SES_CONFIGURATION_SET");
"AWS_SES_CONFIGURATION_SET",
);
+12 -12
View File
@@ -1,15 +1,15 @@
import cron from 'node-cron'; import cron from "node-cron";
import {API_URI} from './constants'; import signale from "signale";
import signale from 'signale'; import { API_URI } from "./constants";
export const task = cron.schedule('* * * * *', () => { export const task = cron.schedule("* * * * *", () => {
signale.info('Running scheduled tasks'); signale.info("Running scheduled tasks");
void fetch(`${API_URI}/tasks`, { void fetch(`${API_URI}/tasks`, {
method: 'POST', method: "POST",
}); });
signale.info('Updating verified identities'); signale.info("Updating verified identities");
void fetch(`${API_URI}/identities/update`, { void fetch(`${API_URI}/identities/update`, {
method: 'POST', method: "POST",
}); });
}); });
+4 -20
View File
@@ -35,12 +35,7 @@ export class Auth {
return res.json({ success: false, data: "Incorrect email or password" }); return res.json({ success: false, data: "Incorrect email or password" });
} }
await redis.set( await redis.set(Keys.User.id(user.id), JSON.stringify(user), "EX", REDIS_ONE_MINUTE * 60);
Keys.User.id(user.id),
JSON.stringify(user),
"EX",
REDIS_ONE_MINUTE * 60,
);
const token = jwt.sign(user.id); const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions(); const cookie = UserService.cookieOptions();
@@ -70,12 +65,7 @@ export class Auth {
}, },
}); });
await redis.set( await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), "EX", REDIS_ONE_MINUTE * 60);
Keys.User.id(created_user.id),
JSON.stringify(created_user),
"EX",
REDIS_ONE_MINUTE * 60,
);
const token = jwt.sign(created_user.id); const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions(); const cookie = UserService.cookieOptions();
@@ -88,9 +78,7 @@ export class Auth {
@Post("reset") @Post("reset")
public async reset(req: Request, res: Response) { public async reset(req: Request, res: Response) {
const { id, password } = UtilitySchemas.id const { id, password } = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true })).parse(req.body);
.merge(UserSchemas.credentials.pick({ password: true }))
.parse(req.body);
const user = await UserService.id(id); const user = await UserService.id(id);
@@ -115,11 +103,7 @@ export class Auth {
@Get("logout") @Get("logout")
public logout(req: Request, res: Response) { public logout(req: Request, res: Response) {
res.cookie( res.cookie(UserService.COOKIE_NAME, "", UserService.cookieOptions(new Date()));
UserService.COOKIE_NAME,
"",
UserService.cookieOptions(new Date()),
);
return res.json(true); return res.json(true);
} }
} }
+3 -12
View File
@@ -8,12 +8,7 @@ import { type IJwt, isAuthenticated } from "../middleware/auth";
import { ProjectService } from "../services/ProjectService"; import { ProjectService } from "../services/ProjectService";
import { Keys } from "../services/keys"; import { Keys } from "../services/keys";
import { redis } from "../services/redis"; import { redis } from "../services/redis";
import { import { getIdentities, getIdentityVerificationAttributes, ses, verifyIdentity } from "../util/ses";
getIdentities,
getIdentityVerificationAttributes,
ses,
verifyIdentity,
} from "../util/ses";
@Controller("identities") @Controller("identities")
export class Identities { export class Identities {
@@ -117,14 +112,10 @@ export class Identities {
take: 99, take: 99,
}); });
const awsIdentities = await getIdentities( const awsIdentities = await getIdentities(dbIdentities.map((i) => i.email as string));
dbIdentities.map((i) => i.email as string),
);
for (const identity of awsIdentities) { for (const identity of awsIdentities) {
const projectId = dbIdentities.find((i) => const projectId = dbIdentities.find((i) => i.email?.endsWith(identity.email));
i.email?.endsWith(identity.email),
);
const project = await ProjectService.id(projectId?.id as string); const project = await ProjectService.id(projectId?.id as string);
+4 -18
View File
@@ -1,12 +1,7 @@
import { Controller, Middleware, Post } from "@overnightjs/core"; import { Controller, Middleware, Post } from "@overnightjs/core";
import { MembershipSchemas, UtilitySchemas } from "@plunk/shared"; import { MembershipSchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import { HttpException, NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
HttpException,
NotAllowed,
NotAuthenticated,
NotFound,
} from "../exceptions";
import { type IJwt, isAuthenticated } from "../middleware/auth"; import { type IJwt, isAuthenticated } from "../middleware/auth";
import { MembershipService } from "../services/MembershipService"; import { MembershipService } from "../services/MembershipService";
import { ProjectService } from "../services/ProjectService"; import { ProjectService } from "../services/ProjectService";
@@ -38,16 +33,10 @@ export class Memberships {
const invitedUser = await UserService.email(email); const invitedUser = await UserService.email(email);
if (!invitedUser) { if (!invitedUser) {
throw new HttpException( throw new HttpException(404, "We could not find that user, please ask them to sign up first.");
404,
"We could not find that user, please ask them to sign up first.",
);
} }
const alreadyMember = await MembershipService.isMember( const alreadyMember = await MembershipService.isMember(project.id, invitedUser.id);
project.id,
invitedUser.id,
);
if (alreadyMember) { if (alreadyMember) {
throw new NotAllowed(); throw new NotAllowed();
@@ -91,10 +80,7 @@ export class Memberships {
throw new NotFound("user"); throw new NotFound("user");
} }
const isMember = await MembershipService.isMember( const isMember = await MembershipService.isMember(project.id, kickedUser.id);
project.id,
kickedUser.id,
);
if (!isMember) { if (!isMember) {
throw new NotAllowed(); throw new NotAllowed();
+2 -12
View File
@@ -1,11 +1,4 @@
import { import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
Controller,
Delete,
Get,
Middleware,
Post,
Put,
} from "@overnightjs/core";
import { IdentitySchemas, ProjectSchemas, UtilitySchemas } from "@plunk/shared"; import { IdentitySchemas, ProjectSchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import z from "zod"; import z from "zod";
@@ -197,10 +190,7 @@ export class Projects {
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: { where: {
projectId: project.id, projectId: project.id,
OR: [ OR: [{ email: { contains: query, mode: "insensitive" } }, { data: { contains: query, mode: "insensitive" } }],
{ email: { contains: query, mode: "insensitive" } },
{ data: { contains: query, mode: "insensitive" } },
],
}, },
select: { select: {
id: true, id: true,
+5 -20
View File
@@ -46,13 +46,7 @@ export class Tasks {
if (notevents.length > 0) { if (notevents.length > 0) {
const triggers = await ContactService.triggers(contact.id); const triggers = await ContactService.triggers(contact.id);
if ( if (notevents.some((e) => triggers.some((t) => t.contactId === contact.id && t.eventId === e.id))) {
notevents.some((e) =>
triggers.some(
(t) => t.contactId === contact.id && t.eventId === e.id,
),
)
) {
await prisma.task.delete({ where: { id: task.id } }); await prisma.task.delete({ where: { id: task.id } });
continue; continue;
} }
@@ -82,10 +76,7 @@ export class Tasks {
const { messageId } = await EmailService.send({ const { messageId } = await EmailService.send({
from: { from: {
name: project.from ?? project.name, name: project.from ?? project.name,
email: email: project.verified && project.email ? project.email : "[email protected]",
project.verified && project.email
? project.email
: "[email protected]",
}, },
to: [contact.email], to: [contact.email],
content: { content: {
@@ -93,9 +84,7 @@ export class Tasks {
html: EmailService.compile({ html: EmailService.compile({
content: body, content: body,
footer: { footer: {
unsubscribe: campaign unsubscribe: campaign ? true : !!action && action.template.type === "MARKETING",
? true
: !!action && action.template.type === "MARKETING",
}, },
contact: { contact: {
id: contact.id, id: contact.id,
@@ -103,9 +92,7 @@ export class Tasks {
project: { project: {
name: project.name, name: project.name,
}, },
isHtml: isHtml: (campaign && campaign.style === "HTML") ?? (!!action && action.template.style === "HTML"),
(campaign && campaign.style === "HTML") ??
(!!action && action.template.style === "HTML"),
}), }),
}, },
}); });
@@ -130,9 +117,7 @@ export class Tasks {
await prisma.task.delete({ where: { id: task.id } }); await prisma.task.delete({ where: { id: task.id } });
signale.success( signale.success(`Task completed for ${contact.email} from ${project.name}`);
`Task completed for ${contact.email} from ${project.name}`,
);
} }
return res.status(200).json({ success: true }); return res.status(200).json({ success: true });
@@ -43,33 +43,24 @@ export class SNSWebhook {
// The email was a transactional email // The email was a transactional email
if (email.projectId) { if (email.projectId) {
if (body.eventType === "Click") { if (body.eventType === "Click") {
signale.success( signale.success(`Click received for ${email.contact.email} from ${project.name}`);
`Click received for ${email.contact.email} from ${project.name}`,
);
await prisma.click.create({ await prisma.click.create({
data: { emailId: email.id, link: body.click.link }, data: { emailId: email.id, link: body.click.link },
}); });
} }
if (body.eventType === "Complaint") { if (body.eventType === "Complaint") {
signale.warn( signale.warn(`Complaint received for ${email.contact.email} from ${project.name}`);
`Complaint received for ${email.contact.email} from ${project.name}`,
);
} }
if (body.eventType === "Bounce") { if (body.eventType === "Bounce") {
signale.warn( signale.warn(`Bounce received for ${email.contact.email} from ${project.name}`);
`Bounce received for ${email.contact.email} from ${project.name}`,
);
} }
await prisma.email.update({ await prisma.email.update({
where: { messageId: body.mail.messageId }, where: { messageId: body.mail.messageId },
data: { data: {
status: status: eventMap[body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"],
eventMap[
body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"
],
}, },
}); });
@@ -95,9 +86,7 @@ export class SNSWebhook {
} }
if (body.eventType === "Click") { if (body.eventType === "Click") {
signale.success( signale.success(`Click received for ${email.contact.email} from ${project.name}`);
`Click received for ${email.contact.email} from ${project.name}`,
);
await prisma.click.create({ await prisma.click.create({
data: { emailId: email.id, link: body.click.link }, data: { emailId: email.id, link: body.click.link },
@@ -111,12 +100,7 @@ export class SNSWebhook {
if (email.action) { if (email.action) {
event = email.action.template.events.find((e) => event = email.action.template.events.find((e) =>
e.name.includes( e.name.includes(
(body.eventType as (body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
| "Bounce"
| "Delivery"
| "Open"
| "Complaint"
| "Click") === "Delivery"
? "delivered" ? "delivered"
: "opened", : "opened",
), ),
@@ -126,12 +110,7 @@ export class SNSWebhook {
if (email.campaign) { if (email.campaign) {
event = email.campaign.events.find((e) => event = email.campaign.events.find((e) =>
e.name.includes( e.name.includes(
(body.eventType as (body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
| "Bounce"
| "Delivery"
| "Open"
| "Complaint"
| "Click") === "Delivery"
? "delivered" ? "delivered"
: "opened", : "opened",
), ),
@@ -144,9 +123,7 @@ export class SNSWebhook {
switch (body.eventType as "Delivery" | "Open") { switch (body.eventType as "Delivery" | "Open") {
case "Delivery": case "Delivery":
signale.success( signale.success(`Delivery received for ${email.contact.email} from ${project.name}`);
`Delivery received for ${email.contact.email} from ${project.name}`,
);
await prisma.email.update({ await prisma.email.update({
where: { messageId: body.mail.messageId }, where: { messageId: body.mail.messageId },
data: { status: "DELIVERED" }, data: { status: "DELIVERED" },
@@ -158,9 +135,7 @@ export class SNSWebhook {
break; break;
case "Open": case "Open":
signale.success( signale.success(`Open received for ${email.contact.email} from ${project.name}`);
`Open received for ${email.contact.email} from ${project.name}`,
);
await prisma.email.update({ await prisma.email.update({
where: { messageId: body.mail.messageId }, where: { messageId: body.mail.messageId },
data: { status: "OPENED" }, data: { status: "OPENED" },
+4 -31
View File
@@ -1,21 +1,9 @@
import { import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
Controller,
Delete,
Get,
Middleware,
Post,
Put,
} from "@overnightjs/core";
import { ActionSchemas, UtilitySchemas } from "@plunk/shared"; import { ActionSchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { prisma } from "../../database/prisma"; import { prisma } from "../../database/prisma";
import { NotFound } from "../../exceptions"; import { NotFound } from "../../exceptions";
import { import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
type IJwt,
type ISecret,
isAuthenticated,
isValidSecretKey,
} from "../../middleware/auth";
import { ActionService } from "../../services/ActionService"; import { ActionService } from "../../services/ActionService";
import { EventService } from "../../services/EventService"; import { EventService } from "../../services/EventService";
import { MembershipService } from "../../services/MembershipService"; import { MembershipService } from "../../services/MembershipService";
@@ -83,14 +71,7 @@ export class Actions {
throw new NotFound("project"); throw new NotFound("project");
} }
const { const { name, runOnce, delay, template: templateId, events, notevents } = ActionSchemas.create.parse(req.body);
name,
runOnce,
delay,
template: templateId,
events,
notevents,
} = ActionSchemas.create.parse(req.body);
const template = await TemplateService.id(templateId); const template = await TemplateService.id(templateId);
@@ -160,15 +141,7 @@ export class Actions {
throw new NotFound("project"); throw new NotFound("project");
} }
const { const { id, template: templateId, events, notevents, name, runOnce, delay } = ActionSchemas.update.parse(req.body);
id,
template: templateId,
events,
notevents,
name,
runOnce,
delay,
} = ActionSchemas.update.parse(req.body);
let action = await ActionService.id(id); let action = await ActionService.id(id);
+6 -28
View File
@@ -1,22 +1,10 @@
import { import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
Controller,
Delete,
Get,
Middleware,
Post,
Put,
} from "@overnightjs/core";
import { CampaignSchemas, UtilitySchemas } from "@plunk/shared"; import { CampaignSchemas, UtilitySchemas } from "@plunk/shared";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { prisma } from "../../database/prisma"; import { prisma } from "../../database/prisma";
import { HttpException, NotFound } from "../../exceptions"; import { HttpException, NotFound } from "../../exceptions";
import { import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
type IJwt,
type ISecret,
isAuthenticated,
isValidSecretKey,
} from "../../middleware/auth";
import { CampaignService } from "../../services/CampaignService"; import { CampaignService } from "../../services/CampaignService";
import { EmailService } from "../../services/EmailService"; import { EmailService } from "../../services/EmailService";
import { MembershipService } from "../../services/MembershipService"; import { MembershipService } from "../../services/MembershipService";
@@ -39,10 +27,7 @@ export class Campaigns {
throw new NotFound("campaign"); throw new NotFound("campaign");
} }
const isMember = await MembershipService.isMember( const isMember = await MembershipService.isMember(campaign.projectId, userId);
campaign.projectId,
userId,
);
if (!isMember) { if (!isMember) {
throw new NotFound("campaign"); throw new NotFound("campaign");
@@ -122,10 +107,7 @@ export class Campaigns {
await EmailService.send({ await EmailService.send({
from: { from: {
name: project.from ?? project.name, name: project.from ?? project.name,
email: email: project.verified && project.email ? project.email : "[email protected]",
project.verified && project.email
? project.email
: "[email protected]",
}, },
to: members.map((m) => m.email), to: members.map((m) => m.email),
content: { content: {
@@ -197,9 +179,7 @@ export class Campaigns {
throw new NotFound("project"); throw new NotFound("project");
} }
let { subject, body, recipients, style } = CampaignSchemas.create.parse( let { subject, body, recipients, style } = CampaignSchemas.create.parse(req.body);
req.body,
);
if (recipients.length === 1 && recipients[0] === "all") { if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({ const projectContacts = await prisma.contact.findMany({
@@ -251,9 +231,7 @@ export class Campaigns {
} }
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse( let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(req.body);
req.body,
);
if (recipients.length === 1 && recipients[0] === "all") { if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({ const projectContacts = await prisma.contact.findMany({
+5 -23
View File
@@ -1,22 +1,10 @@
import { import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
Controller,
Delete,
Get,
Middleware,
Post,
Put,
} from "@overnightjs/core";
import { ContactSchemas, UtilitySchemas } from "@plunk/shared"; import { ContactSchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import z from "zod"; import z from "zod";
import { prisma } from "../../database/prisma"; import { prisma } from "../../database/prisma";
import { HttpException, NotFound } from "../../exceptions"; import { HttpException, NotFound } from "../../exceptions";
import { import { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth";
type IKey,
type ISecret,
isValidKey,
isValidSecretKey,
} from "../../middleware/auth";
import { ActionService } from "../../services/ActionService"; import { ActionService } from "../../services/ActionService";
import { ContactService } from "../../services/ContactService"; import { ContactService } from "../../services/ContactService";
import { EventService } from "../../services/EventService"; import { EventService } from "../../services/EventService";
@@ -150,9 +138,7 @@ export class Contacts {
await redis.del(Keys.Contact.id(contact.id)); await redis.del(Keys.Contact.id(contact.id));
await redis.del(Keys.Contact.email(project.id, contact.email)); await redis.del(Keys.Contact.email(project.id, contact.email));
return res return res.status(200).json({ success: true, contact: contact.id, subscribed: false });
.status(200)
.json({ success: true, contact: contact.id, subscribed: false });
} }
@Post("subscribe") @Post("subscribe")
@@ -203,9 +189,7 @@ export class Contacts {
await redis.del(Keys.Contact.id(contact.id)); await redis.del(Keys.Contact.id(contact.id));
await redis.del(Keys.Contact.email(project.id, contact.email)); await redis.del(Keys.Contact.email(project.id, contact.email));
return res return res.status(200).json({ success: true, contact: contact.id, subscribed: true });
.status(200)
.json({ success: true, contact: contact.id, subscribed: true });
} }
@Post() @Post()
@@ -262,9 +246,7 @@ export class Contacts {
throw new NotFound("project"); throw new NotFound("project");
} }
const { id, email, subscribed, data } = ContactSchemas.update.parse( const { id, email, subscribed, data } = ContactSchemas.update.parse(req.body);
req.body,
);
let contact = await ContactService.id(id); let contact = await ContactService.id(id);
+6 -28
View File
@@ -1,22 +1,9 @@
import { randomBytes } from "node:crypto"; import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
import {
Controller,
Delete,
Get,
Middleware,
Post,
Put,
} from "@overnightjs/core";
import { TemplateSchemas, UtilitySchemas } from "@plunk/shared"; import { TemplateSchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { prisma } from "../../database/prisma"; import { prisma } from "../../database/prisma";
import { NotAllowed, NotFound } from "../../exceptions"; import { NotAllowed, NotFound } from "../../exceptions";
import { import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
type IJwt,
type ISecret,
isAuthenticated,
isValidSecretKey,
} from "../../middleware/auth";
import { MembershipService } from "../../services/MembershipService"; import { MembershipService } from "../../services/MembershipService";
import { ProjectService } from "../../services/ProjectService"; import { ProjectService } from "../../services/ProjectService";
import { TemplateService } from "../../services/TemplateService"; import { TemplateService } from "../../services/TemplateService";
@@ -38,10 +25,7 @@ export class Templates {
throw new NotFound("template"); throw new NotFound("template");
} }
const isMember = await MembershipService.isMember( const isMember = await MembershipService.isMember(template.projectId, userId);
template.projectId,
userId,
);
if (!isMember) { if (!isMember) {
throw new NotFound("template"); throw new NotFound("template");
@@ -117,9 +101,7 @@ export class Templates {
throw new NotFound("project"); throw new NotFound("project");
} }
const { subject, body, type, style } = TemplateSchemas.create.parse( const { subject, body, type, style } = TemplateSchemas.create.parse(req.body);
req.body,
);
const template = await prisma.template.create({ const template = await prisma.template.create({
data: { data: {
@@ -169,9 +151,7 @@ export class Templates {
throw new NotFound("project"); throw new NotFound("project");
} }
const { id, subject, body, type, style } = TemplateSchemas.update.parse( const { id, subject, body, type, style } = TemplateSchemas.update.parse(req.body);
req.body,
);
let template = await TemplateService.id(id); let template = await TemplateService.id(id);
@@ -238,9 +218,7 @@ export class Templates {
const actions = await TemplateService.actions(id); const actions = await TemplateService.actions(id);
if (actions && actions.length > 0) { if (actions && actions.length > 0) {
throw new NotAllowed( throw new NotAllowed("This template is being used by an action. Unlink the action before deleting the template.");
"This template is being used by an action. Unlink the action before deleting the template.",
);
} }
await prisma.template.delete({ where: { id } }); await prisma.template.delete({ where: { id } });
+22 -63
View File
@@ -1,21 +1,11 @@
import { import { ChildControllers, Controller, Middleware, Post } from "@overnightjs/core";
ChildControllers,
Controller,
Middleware,
Post,
} from "@overnightjs/core";
import { EventSchemas } from "@plunk/shared"; import { EventSchemas } from "@plunk/shared";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import signale from "signale"; import signale from "signale";
import { prisma } from "../../database/prisma"; import { prisma } from "../../database/prisma";
import { HttpException, NotAllowed } from "../../exceptions"; import { HttpException, NotAllowed } from "../../exceptions";
import { import { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth";
type IKey,
type ISecret,
isValidKey,
isValidSecretKey,
} from "../../middleware/auth";
import { ActionService } from "../../services/ActionService"; import { ActionService } from "../../services/ActionService";
import { ContactService } from "../../services/ContactService"; import { ContactService } from "../../services/ContactService";
import { EmailService } from "../../services/EmailService"; import { EmailService } from "../../services/EmailService";
@@ -30,13 +20,7 @@ import { Events } from "./Events";
import { Templates } from "./Templates"; import { Templates } from "./Templates";
@Controller("v1") @Controller("v1")
@ChildControllers([ @ChildControllers([new Actions(), new Templates(), new Campaigns(), new Contacts(), new Events()])
new Actions(),
new Templates(),
new Campaigns(),
new Contacts(),
new Events(),
])
export class V1 { export class V1 {
@Post() @Post()
@Post("track") @Post("track")
@@ -53,14 +37,9 @@ export class V1 {
const result = EventSchemas.post.safeParse(req.body); const result = EventSchemas.post.safeParse(req.body);
if (!result.success) { if (!result.success) {
signale.warn( signale.warn(`${project.name} tried tracking an event with invalid data: ${JSON.stringify(req.body)}`);
`${project.name} tried tracking an event with invalid data: ${JSON.stringify(req.body)}`,
);
if ("unionErrors" in result.error.issues[0]) { if ("unionErrors" in result.error.issues[0]) {
throw new HttpException( throw new HttpException(400, result.error.issues[0].unionErrors[0].errors[0].message);
400,
result.error.issues[0].unionErrors[0].errors[0].message,
);
} }
throw new HttpException(400, result.error.issues[0].message); throw new HttpException(400, result.error.issues[0].message);
@@ -78,10 +57,7 @@ export class V1 {
event = await prisma.event.create({ event = await prisma.event.create({
data: { name, projectId: project.id }, data: { name, projectId: project.id },
}); });
redis.set( redis.set(Keys.Event.event(project.id, event.name), JSON.stringify(event));
Keys.Event.event(project.id, event.name),
JSON.stringify(event),
);
redis.set(Keys.Event.id(event.id), JSON.stringify(event)); redis.set(Keys.Event.id(event.id), JSON.stringify(event));
redis.del(Keys.Project.events(project.id, true)); redis.del(Keys.Project.events(project.id, true));
@@ -139,9 +115,7 @@ export class V1 {
void ActionService.trigger({ event, contact, project }); void ActionService.trigger({ event, contact, project });
signale.success( signale.success(`${project.name} triggered ${event.name} for ${contact.email}`);
`${project.name} triggered ${event.name} for ${contact.email}`,
);
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
@@ -166,30 +140,20 @@ export class V1 {
if (!result.success) { if (!result.success) {
if ("unionErrors" in result.error.issues[0]) { if ("unionErrors" in result.error.issues[0]) {
throw new HttpException( throw new HttpException(400, result.error.issues[0].unionErrors[0].errors[0].message);
400,
result.error.issues[0].unionErrors[0].errors[0].message,
);
} }
throw new HttpException(400, result.error.issues[0].message); throw new HttpException(400, result.error.issues[0].message);
} }
const { from, name, reply, to, subject, body, subscribed, headers } = const { from, name, reply, to, subject, body, subscribed, headers } = result.data;
result.data;
if (!project.email || !project.verified) { if (!project.email || !project.verified) {
throw new HttpException( throw new HttpException(401, "Verify your domain before you start sending");
401,
"Verify your domain before you start sending",
);
} }
if (from && from.split("@")[1] !== project.email?.split("@")[1]) { if (from && from.split("@")[1] !== project.email?.split("@")[1]) {
throw new HttpException( throw new HttpException(401, "Custom from address must be from a verified domain");
401,
"Custom from address must be from a verified domain",
);
} }
const emails: { const emails: {
@@ -231,16 +195,15 @@ export class V1 {
} }
} }
const { subject: enrichedSubject, body: enrichedBody } = const { subject: enrichedSubject, body: enrichedBody } = EmailService.format({
EmailService.format({ subject,
subject, body,
body, data: {
data: { plunk_id: contact.id,
plunk_id: contact.id, plunk_email: contact.email,
plunk_email: contact.email, ...JSON.parse(contact.data ?? "{}"),
...JSON.parse(contact.data ?? "{}"), },
}, });
});
const { messageId } = await EmailService.send({ const { messageId } = await EmailService.send({
from: { from: {
@@ -287,12 +250,8 @@ export class V1 {
redis.del(Keys.Project.emails(project.id)); redis.del(Keys.Project.emails(project.id));
redis.del(Keys.Project.emails(project.id, { count: true })); redis.del(Keys.Project.emails(project.id, { count: true }));
signale.success( signale.success(`${project.name} sent a transactional email to ${to.join(", ")}`);
`${project.name} sent a transactional email to ${to.join(", ")}`,
);
return res return res.status(200).json({ success: true, emails, timestamp: dayjs().toISOString() });
.status(200)
.json({ success: true, emails, timestamp: dayjs().toISOString() });
} }
} }
+6 -26
View File
@@ -25,11 +25,7 @@ export interface IKey {
* @param res * @param res
* @param next * @param next
*/ */
export const isAuthenticated = ( export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
req: Request,
res: Response,
next: NextFunction,
) => {
res.locals.auth = { type: "jwt", userId: parseJwt(req) }; res.locals.auth = { type: "jwt", userId: parseJwt(req) };
next(); next();
@@ -41,11 +37,7 @@ export const isAuthenticated = (
* @param res * @param res
* @param next * @param next
*/ */
export const isValidSecretKey = ( export const isValidSecretKey = (req: Request, res: Response, next: NextFunction) => {
req: Request,
res: Response,
next: NextFunction,
) => {
res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") }; res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") };
next(); next();
@@ -118,10 +110,7 @@ export function parseJwt(request: Request): string {
* @param request The express request object * @param request The express request object
* @param type * @param type
*/ */
export function parseBearer( export function parseBearer(request: Request, type?: "secret" | "public"): string {
request: Request,
type?: "secret" | "public",
): string {
const bearer: string | undefined = request.headers.authorization; const bearer: string | undefined = request.headers.authorization;
if (!bearer) { if (!bearer) {
@@ -135,17 +124,11 @@ export function parseBearer(
const split = bearer.split(" "); const split = bearer.split(" ");
if (!(split[0] === "Bearer") || split.length > 2) { if (!(split[0] === "Bearer") || split.length > 2) {
throw new HttpException( throw new HttpException(401, "Your authorization header is malformed. Please pass your API key as Bearer sk_...");
401,
"Your authorization header is malformed. Please pass your API key as Bearer sk_...",
);
} }
if (!type && !split[1].startsWith("sk_") && !split[1].startsWith("pk_")) { if (!type && !split[1].startsWith("sk_") && !split[1].startsWith("pk_")) {
throw new HttpException( throw new HttpException(401, "Your API key could not be parsed. API keys start with sk_ or pk_");
401,
"Your API key could not be parsed. API keys start with sk_ or pk_",
);
} }
if (!type) { if (!type) {
@@ -153,10 +136,7 @@ export function parseBearer(
} }
if (type === "secret" && split[1].startsWith("pk_")) { if (type === "secret" && split[1].startsWith("pk_")) {
throw new HttpException( throw new HttpException(401, "You attached a public key but this route may only be accessed with a secret key");
401,
"You attached a public key but this route may only be accessed with a secret key",
);
} }
if (type === "secret" && !split[1].startsWith("sk_")) { if (type === "secret" && !split[1].startsWith("sk_")) {
+11 -33
View File
@@ -54,11 +54,9 @@ export class ActionService {
*/ */
public static event(eventId: string) { public static event(eventId: string) {
return wrapRedis(Keys.Action.event(eventId), async () => { return wrapRedis(Keys.Action.event(eventId), async () => {
return prisma.event return prisma.event.findUniqueOrThrow({ where: { id: eventId } }).actions({
.findUniqueOrThrow({ where: { id: eventId } }) include: { events: true, template: true, notevents: true },
.actions({ });
include: { events: true, template: true, notevents: true },
});
}); });
} }
@@ -68,52 +66,35 @@ export class ActionService {
* @param event * @param event
* @param project * @param project
*/ */
public static async trigger({ public static async trigger({ event, contact, project }: { event: Event; contact: Contact; project: Project }) {
event,
contact,
project,
}: { event: Event; contact: Contact; project: Project }) {
const actions = await ActionService.event(event.id); const actions = await ActionService.event(event.id);
const triggers = await ContactService.triggers(contact.id); const triggers = await ContactService.triggers(contact.id);
for (const action of actions) { for (const action of actions) {
const hasTriggeredAction = !!triggers.find( const hasTriggeredAction = !!triggers.find((t) => t.actionId === action.id);
(t) => t.actionId === action.id,
);
if (action.runOnce && hasTriggeredAction) { if (action.runOnce && hasTriggeredAction) {
// User has already triggered this run once action // User has already triggered this run once action
continue; continue;
} }
if ( if (action.notevents.length > 0 && action.notevents.some((e) => triggers.some((t) => t.eventId === e.id))) {
action.notevents.length > 0 &&
action.notevents.some((e) => triggers.some((t) => t.eventId === e.id))
) {
continue; continue;
} }
let triggeredEvents = triggers.filter((t) => t.eventId === event.id); let triggeredEvents = triggers.filter((t) => t.eventId === event.id);
if (hasTriggeredAction) { if (hasTriggeredAction) {
const lastActionTrigger = triggers.filter( const lastActionTrigger = triggers.filter((t) => t.contactId === contact.id && t.actionId === action.id)[0];
(t) => t.contactId === contact.id && t.actionId === action.id,
)[0];
triggeredEvents = triggeredEvents.filter( triggeredEvents = triggeredEvents.filter((e) => e.createdAt > lastActionTrigger.createdAt);
(e) => e.createdAt > lastActionTrigger.createdAt,
);
} }
const updatedTriggers = [ const updatedTriggers = [...new Set(triggeredEvents.map((t) => t.eventId))];
...new Set(triggeredEvents.map((t) => t.eventId)),
];
const requiredTriggers = action.events.map((e) => e.id); const requiredTriggers = action.events.map((e) => e.id);
if ( if (updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")) {
updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")
) {
// Not all required events have been triggered // Not all required events have been triggered
continue; continue;
} }
@@ -140,10 +121,7 @@ export class ActionService {
const { messageId } = await EmailService.send({ const { messageId } = await EmailService.send({
from: { from: {
name: project.from ?? project.name, name: project.from ?? project.name,
email: email: project.verified && project.email ? project.email : "[email protected]",
project.verified && project.email
? project.email
: "[email protected]",
}, },
to: [contact.email], to: [contact.email],
content: { content: {
+7 -15
View File
@@ -467,8 +467,8 @@ ${
<mj-section> <mj-section>
<mj-column> <mj-column>
${ ${
footer.unsubscribe footer.unsubscribe
? ` ? `
<mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider> <mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider>
<mj-text align="center"> <mj-text align="center">
<p style="color: #a3a3a3; text-decoration: none; font-size: 12px; line-height: 1.7142857;"> <p style="color: #a3a3a3; text-decoration: none; font-size: 12px; line-height: 1.7142857;">
@@ -476,8 +476,8 @@ ${
</p> </p>
</mj-text> </mj-text>
` `
: "" : ""
} }
</mj-column> </mj-column>
</mj-section> </mj-section>
</mj-body> </mj-body>
@@ -485,22 +485,14 @@ ${
).html.replace(/^\s+|\s+$/g, ""); ).html.replace(/^\s+|\s+$/g, "");
} }
public static format({ public static format({ subject, body, data }: { subject: string; body: string; data: Record<string, string> }) {
subject,
body,
data,
}: { subject: string; body: string; data: Record<string, string> }) {
return { return {
subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => { subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => {
const [mainKey, defaultValue] = key const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim());
.split("??")
.map((s: string) => s.trim());
return data[mainKey] ?? defaultValue ?? ""; return data[mainKey] ?? defaultValue ?? "";
}), }),
body: body.replace(/\{\{(.*?)}}/g, (match, key) => { body: body.replace(/\{\{(.*?)}}/g, (match, key) => {
const [mainKey, defaultValue] = key const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim());
.split("??")
.map((s: string) => s.trim());
if (Array.isArray(data[mainKey])) { if (Array.isArray(data[mainKey])) {
return data[mainKey].map((e: string) => `<li>${e}</li>`).join("\n"); return data[mainKey].map((e: string) => `<li>${e}</li>`).join("\n");
} }
+27 -36
View File
@@ -6,54 +6,45 @@ import { redis, wrapRedis } from "./redis";
export class MembershipService { export class MembershipService {
public static async isMember(projectId: string, userId: string) { public static async isMember(projectId: string, userId: string) {
return wrapRedis( return wrapRedis(Keys.ProjectMembership.isMember(projectId, userId), async () => {
Keys.ProjectMembership.isMember(projectId, userId), if (NODE_ENV === "development") {
async () => { return true;
if (NODE_ENV === "development") { }
return true;
}
const membership = await prisma.projectMembership.findFirst({ const membership = await prisma.projectMembership.findFirst({
where: { projectId, userId }, where: { projectId, userId },
}); });
return !!membership; return !!membership;
}, });
);
} }
public static async isAdmin(projectId: string, userId: string) { public static async isAdmin(projectId: string, userId: string) {
return wrapRedis( return wrapRedis(Keys.ProjectMembership.isAdmin(projectId, userId), async () => {
Keys.ProjectMembership.isAdmin(projectId, userId), if (NODE_ENV === "development") {
async () => { return true;
if (NODE_ENV === "development") { }
return true;
}
const membership = await prisma.projectMembership.findFirst({ const membership = await prisma.projectMembership.findFirst({
where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } }, where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } },
}); });
return !!membership; return !!membership;
}, });
);
} }
public static async isOwner(projectId: string, userId: string) { public static async isOwner(projectId: string, userId: string) {
return wrapRedis( return wrapRedis(Keys.ProjectMembership.isOwner(projectId, userId), async () => {
Keys.ProjectMembership.isOwner(projectId, userId), if (NODE_ENV === "development") {
async () => { return true;
if (NODE_ENV === "development") { }
return true;
}
const membership = await prisma.projectMembership.findFirst({ const membership = await prisma.projectMembership.findFirst({
where: { projectId, userId, role: "OWNER" }, where: { projectId, userId, role: "OWNER" },
}); });
return !!membership; return !!membership;
}, });
);
} }
public static async kick(projectId: string, userId: string) { public static async kick(projectId: string, userId: string) {
+13 -27
View File
@@ -51,11 +51,7 @@ export class ProjectService {
return wrapRedis(Keys.Project.emails(id), async () => { return wrapRedis(Keys.Project.emails(id), async () => {
return prisma.email.findMany({ return prisma.email.findMany({
where: { where: {
OR: [ OR: [{ action: { projectId: id } }, { campaign: { projectId: id } }, { projectId: id }],
{ action: { projectId: id } },
{ campaign: { projectId: id } },
{ projectId: id },
],
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
@@ -107,9 +103,7 @@ export class ProjectService {
public static memberships(id: string) { public static memberships(id: string) {
return wrapRedis(Keys.Project.memberships(id), async () => { return wrapRedis(Keys.Project.memberships(id), async () => {
const memberships = await prisma.project const memberships = await prisma.project.findUnique({ where: { id } }).memberships({ include: { user: true } });
.findUnique({ where: { id } })
.memberships({ include: { user: true } });
if (!memberships) { if (!memberships) {
return []; return [];
@@ -127,31 +121,23 @@ export class ProjectService {
public static metadata(id: string) { public static metadata(id: string) {
return wrapRedis(Keys.Project.metadata(id), async () => { return wrapRedis(Keys.Project.metadata(id), async () => {
const contacts = await prisma.project const contacts = await prisma.project.findUnique({ where: { id } }).contacts({
.findUnique({ where: { id } }) where: {
.contacts({ data: {
where: { not: null,
data: {
not: null,
},
}, },
distinct: ["data"], },
select: { distinct: ["data"],
data: true, select: {
}, data: true,
}); },
});
if (!contacts) { if (!contacts) {
return []; return [];
} }
return [ return [...new Set(contacts.filter((c) => c.data).flatMap((c) => Object.keys(JSON.parse(c.data as string))))];
...new Set(
contacts
.filter((c) => c.data)
.flatMap((c) => Object.keys(JSON.parse(c.data as string))),
),
];
}); });
} }
+1 -5
View File
@@ -1,9 +1,5 @@
import { SES } from "@aws-sdk/client-ses"; import { SES } from "@aws-sdk/client-ses";
import { import { AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY } from "../app/constants";
AWS_ACCESS_KEY_ID,
AWS_REGION,
AWS_SECRET_ACCESS_KEY,
} from "../app/constants";
export const ses = new SES({ export const ses = new SES({
apiVersion: "2010-12-01", apiVersion: "2010-12-01",
+1 -1
View File
@@ -1,2 +1,2 @@
NEXT_PUBLIC_API_URI=http://localhost:8080 NEXT_PUBLIC_API_URI=http://localhost:4000
NEXT_PUBLIC_AWS_REGION=eu-west-3 NEXT_PUBLIC_AWS_REGION=eu-west-3
@@ -9,7 +9,6 @@ import { AnimatePresence, motion } from "framer-motion";
import React, { useCallback, useRef, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { API_URI } from "../../../lib/constants";
import { Modal } from "../../Overlay"; import { Modal } from "../../Overlay";
import "tippy.js/animations/scale.css"; import "tippy.js/animations/scale.css";
import HTMLEditor from "@monaco-editor/react"; import HTMLEditor from "@monaco-editor/react";
@@ -18,15 +17,7 @@ import { Dropcursor } from "@tiptap/extension-dropcursor";
import FontFamily from "@tiptap/extension-font-family"; import FontFamily from "@tiptap/extension-font-family";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { import { AlignCenter, AlignLeft, AlignRight, ImageIcon, Inspect, LinkIcon } from "lucide-react";
AlignCenter,
AlignLeft,
AlignRight,
ImageIcon,
Inspect,
LinkIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Dropdown } from "../Dropdown"; import { Dropdown } from "../Dropdown";
import { Button } from "./extensions/Button"; import { Button } from "./extensions/Button";
import { EditorBubbleMenu } from "./extensions/EditorBubbleMenu"; import { EditorBubbleMenu } from "./extensions/EditorBubbleMenu";
@@ -48,20 +39,13 @@ export interface MarkdownEditorProps {
* @param root0.value * @param root0.value
* @param root0.onChange * @param root0.onChange
*/ */
export default function Editor({ export default function Editor({ value, onChange, mode, modeSwitcher }: MarkdownEditorProps) {
value,
onChange,
mode,
modeSwitcher,
}: MarkdownEditorProps) {
const [imageModal, setImageModal] = useState(false); const [imageModal, setImageModal] = useState(false);
const [urlModal, setUrlModal] = useState(false); const [urlModal, setUrlModal] = useState(false);
const [barModal, setBarModal] = useState(false); const [barModal, setBarModal] = useState(false);
const [buttonModal, setButtonModal] = useState(false); const [buttonModal, setButtonModal] = useState(false);
const [confirmModal, setConfirmModal] = useState(false); const [confirmModal, setConfirmModal] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
Slash, Slash,
@@ -144,9 +128,7 @@ export default function Editor({
z.object({ z.object({
url: z url: z
.string() .string()
.regex( .regex(/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/)
/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/,
)
.transform((u) => { .transform((u) => {
if (u.startsWith("{{") && u.endsWith("}}")) { if (u.startsWith("{{") && u.endsWith("}}")) {
return u; return u;
@@ -171,23 +153,8 @@ export default function Editor({
}>({ }>({
resolver: zodResolver( resolver: zodResolver(
z.object({ z.object({
percent: z.preprocess( percent: z.preprocess((a) => Number.parseInt(z.string().parse(a), 10), z.number().positive().max(100)),
(a) => Number.parseInt(z.string().parse(a), 10), color: z.enum(["red", "yellow", "green", "blue", "indigo", "purple", "pink", "orange", "black"]).default("blue"),
z.number().positive().max(100),
),
color: z
.enum([
"red",
"yellow",
"green",
"blue",
"indigo",
"purple",
"pink",
"orange",
"black",
])
.default("blue"),
}), }),
), ),
defaultValues: { defaultValues: {
@@ -210,9 +177,7 @@ export default function Editor({
z.object({ z.object({
link: z link: z
.string() .string()
.regex( .regex(/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/)
/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/,
)
.transform((u) => { .transform((u) => {
if (u.startsWith("{{") && u.endsWith("}}")) { if (u.startsWith("{{") && u.endsWith("}}")) {
return u; return u;
@@ -220,19 +185,7 @@ export default function Editor({
return u.startsWith("http") ? u : `https://${u}`; return u.startsWith("http") ? u : `https://${u}`;
}), }),
color: z color: z.enum(["red", "yellow", "green", "blue", "indigo", "purple", "pink", "orange", "black"]).default("blue"),
.enum([
"red",
"yellow",
"green",
"blue",
"indigo",
"purple",
"pink",
"orange",
"black",
])
.default("blue"),
}), }),
), ),
defaultValues: { defaultValues: {
@@ -250,11 +203,7 @@ export default function Editor({
const addBar = useCallback( const addBar = useCallback(
(data: { percent: number; color: colors }) => { (data: { percent: number; color: colors }) => {
editor editor?.chain().focus().setProgress({ percent: data.percent, color: data.color }).run();
?.chain()
.focus()
.setProgress({ percent: data.percent, color: data.color })
.run();
setBarModal(false); setBarModal(false);
resetBar(); resetBar();
}, },
@@ -263,11 +212,7 @@ export default function Editor({
const addButton = useCallback( const addButton = useCallback(
(data: { link: string; color: colors }) => { (data: { link: string; color: colors }) => {
editor editor?.chain().focus().setButton({ href: data.link, color: data.color }).run();
?.chain()
.focus()
.setButton({ href: data.link, color: data.color })
.run();
setButtonModal(false); setButtonModal(false);
resetButton(); resetButton();
}, },
@@ -276,11 +221,7 @@ export default function Editor({
const addUrl = useCallback( const addUrl = useCallback(
(data: { url: string }) => { (data: { url: string }) => {
editor editor?.chain().focus().setLink({ href: data.url, target: "_blank" }).run();
?.chain()
.focus()
.setLink({ href: data.url, target: "_blank" })
.run();
setUrlModal(false); setUrlModal(false);
resetUrl(); resetUrl();
}, },
@@ -311,9 +252,8 @@ export default function Editor({
> >
<div className={"flex flex-col gap-3"}> <div className={"flex flex-col gap-3"}>
<p className={"text-sm text-neutral-700"}> <p className={"text-sm text-neutral-700"}>
Are you sure you want to switch to{" "} Are you sure you want to switch to {mode === "PLUNK" ? "HTML" : "the Plunk Editor"}? <br /> This will clear your
{mode === "PLUNK" ? "HTML" : "the Plunk Editor"}? <br /> This will current content.
clear your current content.
</p> </p>
</div> </div>
</Modal> </Modal>
@@ -358,15 +298,9 @@ export default function Editor({
</> </>
} }
> >
<form <form onSubmit={handleSubmitUrl(addImage)} className="grid gap-6 sm:grid-cols-2">
onSubmit={handleSubmitUrl(addImage)}
className="grid gap-6 sm:grid-cols-2"
>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"url"} className="block text-sm font-medium text-neutral-700">
htmlFor={"url"}
className="block text-sm font-medium text-neutral-700"
>
Image URL Image URL
</label> </label>
<div className="mt-1 flex rounded-md shadow-sm"> <div className="mt-1 flex rounded-md shadow-sm">
@@ -422,10 +356,7 @@ export default function Editor({
className="grid gap-6 sm:grid-cols-2" className="grid gap-6 sm:grid-cols-2"
> >
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"url"} className="block text-sm font-medium text-neutral-700">
htmlFor={"url"}
className="block text-sm font-medium text-neutral-700"
>
URL URL
</label> </label>
<div className="mt-1 flex rounded-md shadow-sm"> <div className="mt-1 flex rounded-md shadow-sm">
@@ -485,10 +416,7 @@ export default function Editor({
className="grid gap-6 sm:grid-cols-2" className="grid gap-6 sm:grid-cols-2"
> >
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"percentage"} className="block text-sm font-medium text-neutral-700">
htmlFor={"percentage"}
className="block text-sm font-medium text-neutral-700"
>
Percentage Percentage
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -519,10 +447,7 @@ export default function Editor({
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"style"} className="flex items-center text-sm font-medium text-neutral-700">
htmlFor={"style"}
className="flex items-center text-sm font-medium text-neutral-700"
>
Color Color
</label> </label>
<Dropdown <Dropdown
@@ -587,10 +512,7 @@ export default function Editor({
className="grid gap-6 sm:grid-cols-2" className="grid gap-6 sm:grid-cols-2"
> >
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"percentage"} className="block text-sm font-medium text-neutral-700">
htmlFor={"percentage"}
className="block text-sm font-medium text-neutral-700"
>
Link Link
</label> </label>
<div className="mt-1 flex rounded-md shadow-sm"> <div className="mt-1 flex rounded-md shadow-sm">
@@ -622,10 +544,7 @@ export default function Editor({
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"style"} className="flex items-center text-sm font-medium text-neutral-700">
htmlFor={"style"}
className="flex items-center text-sm font-medium text-neutral-700"
>
Color Color
</label> </label>
<Dropdown <Dropdown
@@ -668,9 +587,7 @@ export default function Editor({
editor.chain().focus().run(); editor.chain().focus().run();
}} }}
> >
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">Email Body</label>
Email Body
</label>
<div className="mt-1 h-full"> <div className="mt-1 h-full">
<div <div
className={ className={
@@ -712,11 +629,7 @@ export default function Editor({
} }
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
editor editor.chain().focus().setTextAlign("center").run();
.chain()
.focus()
.setTextAlign("center")
.run();
}} }}
> >
<AlignCenter <AlignCenter
@@ -737,11 +650,7 @@ export default function Editor({
} }
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
editor editor.chain().focus().setTextAlign("right").run();
.chain()
.focus()
.setTextAlign("right")
.run();
}} }}
> >
<AlignRight <AlignRight
@@ -787,11 +696,7 @@ export default function Editor({
</div> </div>
</div> </div>
<> <>
<div <div className={"prose prose-sm prose-neutral space-y-4 break-words p-4"}>
className={
"prose prose-sm prose-neutral space-y-4 break-words p-4"
}
>
<div className={"w-full"} style={{ width: "600px" }}> <div className={"w-full"} style={{ width: "600px" }}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
<EditorBubbleMenu <EditorBubbleMenu
@@ -821,9 +726,7 @@ export default function Editor({
<> <>
<div className={"mb-3 grid gap-3 md:grid-cols-1"}> <div className={"mb-3 grid gap-3 md:grid-cols-1"}>
<div> <div>
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">Email Body</label>
Email Body
</label>
<div className="mt-1 h-full"> <div className="mt-1 h-full">
<HTMLEditor <HTMLEditor
height={400} height={400}
@@ -846,15 +749,9 @@ export default function Editor({
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">Preview</label>
Preview
</label>
<div <div className={"mt-1 h-full rounded border border-neutral-300 p-3"}>
className={
"mt-1 h-full rounded border border-neutral-300 p-3"
}
>
<div <div
className={"revert-tailwind"} className={"revert-tailwind"}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -1,11 +1,6 @@
// @ts-nocheck // @ts-nocheck
import React, { import React, { forwardRef, useEffect, useImperativeHandle, useState } from "react";
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
export default forwardRef((props, ref) => { export default forwardRef((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -19,9 +14,7 @@ export default forwardRef((props, ref) => {
}; };
const upHandler = () => { const upHandler = () => {
setSelectedIndex( setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
(selectedIndex + props.items.length - 1) % props.items.length,
);
}; };
const downHandler = () => { const downHandler = () => {
@@ -8,23 +8,15 @@ import MentionList from "./SuggestionList";
export default { export default {
items: async ({ query }: { query: string }) => { items: async ({ query }: { query: string }) => {
const activeProject = const activeProject = typeof window !== "undefined" ? window.localStorage.getItem("project") : null;
typeof window !== "undefined"
? window.localStorage.getItem("project")
: null;
if (!activeProject) { if (!activeProject) {
return []; return [];
} }
const keys = await network.fetch<string[]>( const keys = await network.fetch<string[]>("GET", `/projects/id/${activeProject}/contacts/metadata`);
"GET",
`/projects/id/${activeProject}/contacts/metadata`,
);
return keys.filter((key) => return keys.filter((key) => key.toLowerCase().includes(query.toLowerCase()));
key.toLowerCase().includes(query.toLowerCase()),
);
}, },
render: () => { render: () => {
@@ -16,14 +16,7 @@ import {
Quote, Quote,
Strikethrough, Strikethrough,
} from "lucide-react"; } from "lucide-react";
import React, { import React, { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import tippy from "tippy.js"; import tippy from "tippy.js";
interface CommandItemProps { interface CommandItemProps {
@@ -43,11 +36,7 @@ const Command = Extension.create({
return { return {
suggestion: { suggestion: {
char: "/", char: "/",
command: ({ command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
editor,
range,
props,
}: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range }); props.command({ editor, range });
}, },
}, },
@@ -70,12 +59,7 @@ const getSuggestionItems = ({ query }: { query: string }) => {
description: "Big section heading.", description: "Big section heading.",
icon: <Heading1 size={18} />, icon: <Heading1 size={18} />,
command: ({ editor, range }: Command) => { command: ({ editor, range }: Command) => {
editor editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
}, },
}, },
{ {
@@ -83,12 +67,7 @@ const getSuggestionItems = ({ query }: { query: string }) => {
description: "Medium section heading.", description: "Medium section heading.",
icon: <Heading2 size={18} />, icon: <Heading2 size={18} />,
command: ({ editor, range }: Command) => { command: ({ editor, range }: Command) => {
editor editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
}, },
}, },
{ {
@@ -96,12 +75,7 @@ const getSuggestionItems = ({ query }: { query: string }) => {
description: "Small section heading.", description: "Small section heading.",
icon: <Heading3 size={18} />, icon: <Heading3 size={18} />,
command: ({ editor, range }: Command) => { command: ({ editor, range }: Command) => {
editor editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
}, },
}, },
{ {
@@ -14,8 +14,7 @@ export default function FullscreenLoader() {
Loading... Loading...
</h1> </h1>
<p className={"text-center text-sm text-neutral-600"}> <p className={"text-center text-sm text-neutral-600"}>
Does this take longer than expected? Try clearing your browser's cache Does this take longer than expected? Try clearing your browser's cache or check if you have an ad blocker enabled!
or check if you have an ad blocker enabled!
</p> </p>
<div className={"mt-6"}> <div className={"mt-6"}>
<LineWobble size={200} color={"#262626"} /> <LineWobble size={200} color={"#262626"} />
+2 -8
View File
@@ -51,11 +51,7 @@ function App({ Component, pageProps }: AppProps) {
<> <>
<Head> <Head>
<title>Plunk Dashboard | The Open-Source Email Platform</title> <title>Plunk Dashboard | The Open-Source Email Platform</title>
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0" key={"viewport"} />
name="viewport"
content="width=device-width, initial-scale=1.0"
key={"viewport"}
/>
</Head> </Head>
<Toaster position={"bottom-right"} /> <Toaster position={"bottom-right"} />
@@ -92,9 +88,7 @@ export default function WithProviders(props: AppProps) {
title: "Plunk Dashboard | The Open-Source Email Platform", title: "Plunk Dashboard | The Open-Source Email Platform",
description: description:
"Plunk is the open-source, developer-friendly email platform that brings together marketing, transactional and broadcast emails into one single, complete solution", "Plunk is the open-source, developer-friendly email platform that brings together marketing, transactional and broadcast emails into one single, complete solution",
images: [ images: [{ url: "https://www.useplunk.com/assets/card.png", alt: "Plunk" }],
{ url: "https://www.useplunk.com/assets/card.png", alt: "Plunk" },
],
}} }}
/> />
+30 -126
View File
@@ -11,22 +11,9 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { type FieldError, useForm } from "react-hook-form"; import { type FieldError, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Badge, Card, Dropdown, Empty, FullscreenLoader, Input, MultiselectDropdown, Toggle } from "../../components";
Badge,
Card,
Dropdown,
Empty,
FullscreenLoader,
Input,
MultiselectDropdown,
Toggle,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { import { useAction, useActions, useRelatedActions } from "../../lib/hooks/actions";
useAction,
useActions,
useRelatedActions,
} from "../../lib/hooks/actions";
import { useActiveProject } from "../../lib/hooks/projects"; import { useActiveProject } from "../../lib/hooks/projects";
import { network } from "../../lib/network"; import { network } from "../../lib/network";
@@ -120,15 +107,10 @@ export default function Index() {
const updateAction = (data: ActionValues) => { const updateAction = (data: ActionValues) => {
toast.promise( toast.promise(
network.mock<Action, typeof ActionSchemas.update>( network.mock<Action, typeof ActionSchemas.update>(project.secret, "PUT", "/v1/actions", {
project.secret, id: action.id,
"PUT", ...data,
"/v1/actions", }),
{
id: action.id,
...data,
},
),
{ {
loading: "Saving your action", loading: "Saving your action",
success: () => { success: () => {
@@ -143,14 +125,9 @@ export default function Index() {
const remove = async (e: { preventDefault: () => void }) => { const remove = async (e: { preventDefault: () => void }) => {
e.preventDefault(); e.preventDefault();
toast.promise( toast.promise(
network.mock<Action, typeof UtilitySchemas.id>( network.mock<Action, typeof UtilitySchemas.id>(project.secret, "DELETE", "/v1/actions", {
project.secret, id: action.id,
"DELETE", }),
"/v1/actions",
{
id: action.id,
},
),
{ {
loading: "Deleting your action", loading: "Deleting your action",
success: () => { success: () => {
@@ -192,43 +169,24 @@ export default function Index() {
strokeWidth="1.5" strokeWidth="1.5"
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5" d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
/> />
<path <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M5 7.75H19" />
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M5 7.75H19"
/>
</svg> </svg>
Delete Delete
</button> </button>
</> </>
} }
> >
<form <form onSubmit={handleSubmit(updateAction)} className="mx-auto my-3 max-w-xl space-y-6">
onSubmit={handleSubmit(updateAction)} <Input label={"Name"} placeholder={"Onboarding Flow"} register={register("name")} error={errors.name} />
className="mx-auto my-3 max-w-xl space-y-6"
>
<Input
label={"Name"}
placeholder={"Onboarding Flow"}
register={register("name")}
error={errors.name}
/>
<div> <div>
<label <label htmlFor={"events"} className="block text-sm font-medium text-neutral-800">
htmlFor={"events"}
className="block text-sm font-medium text-neutral-800"
>
Run on triggers Run on triggers
</label> </label>
<MultiselectDropdown <MultiselectDropdown
onChange={(e) => setValue("events", e)} onChange={(e) => setValue("events", e)}
values={events values={events
.filter( .filter((e) => !e.campaignId && !watch("notevents").includes(e.id))
(e) => !e.campaignId && !watch("notevents").includes(e.id),
)
.sort((a, b) => { .sort((a, b) => {
if (a.templateId && !b.templateId) { if (a.templateId && !b.templateId) {
return 1; return 1;
@@ -246,10 +204,7 @@ export default function Index() {
return -1; return -1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -285,18 +240,13 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"events"} className="block text-sm font-medium text-neutral-800">
htmlFor={"events"}
className="block text-sm font-medium text-neutral-800"
>
Exclude contacts with triggers Exclude contacts with triggers
</label> </label>
<MultiselectDropdown <MultiselectDropdown
onChange={(e) => setValue("notevents", e)} onChange={(e) => setValue("notevents", e)}
values={events values={events
.filter( .filter((e) => !e.campaignId && !watch("events").includes(e.id))
(e) => !e.campaignId && !watch("events").includes(e.id),
)
.sort((a, b) => { .sort((a, b) => {
if (a.templateId && !b.templateId) { if (a.templateId && !b.templateId) {
return 1; return 1;
@@ -314,10 +264,7 @@ export default function Index() {
return -1; return -1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -353,10 +300,7 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"template"} className="block text-sm font-medium text-neutral-800">
htmlFor={"template"}
className="block text-sm font-medium text-neutral-800"
>
Template Template
</label> </label>
<div className={"grid gap-6 sm:grid-cols-6"}> <div className={"grid gap-6 sm:grid-cols-6"}>
@@ -381,11 +325,7 @@ export default function Index() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
<Link <Link href={`/templates/${action.templateId}`} passHref className={"sm:col-span-2"}>
href={`/templates/${action.templateId}`}
passHref
className={"sm:col-span-2"}
>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
@@ -416,10 +356,7 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"template"} className="block text-sm font-medium text-neutral-800">
htmlFor={"template"}
className="block text-sm font-medium text-neutral-800"
>
Delay before sending Delay before sending
</label> </label>
<div className={"grid grid-cols-6 gap-4"}> <div className={"grid grid-cols-6 gap-4"}>
@@ -463,9 +400,7 @@ export default function Index() {
<div> <div>
<Toggle <Toggle
title={"Run once"} title={"Run once"}
description={ description={"Toggle this on if you want to run this action only once per contact."}
"Toggle this on if you want to run this action only once per contact."
}
toggled={watch("runOnce")} toggled={watch("runOnce")}
onToggle={() => setValue("runOnce", !watch("runOnce"))} onToggle={() => setValue("runOnce", !watch("runOnce"))}
/> />
@@ -515,11 +450,7 @@ export default function Index() {
.map((r) => { .map((r) => {
return ( return (
<Link href={`/actions/${r.id}`} key={r.id}> <Link href={`/actions/${r.id}`} key={r.id}>
<div <div className={"flex items-center gap-6 rounded border border-solid border-neutral-200 bg-white px-8 py-4"}>
className={
"flex items-center gap-6 rounded border border-solid border-neutral-200 bg-white px-8 py-4"
}
>
<div> <div>
<span className="inline-flex rounded bg-neutral-100 p-4 text-neutral-800 ring-4 ring-white"> <span className="inline-flex rounded bg-neutral-100 p-4 text-neutral-800 ring-4 ring-white">
<svg <svg
@@ -534,48 +465,24 @@ export default function Index() {
strokeWidth={"1.5"} strokeWidth={"1.5"}
d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z" d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z"
/> />
<path <path strokeWidth={"1.5"} d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z" />
strokeWidth={"1.5"} <path strokeWidth={"1.5"} d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4" />
d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z" <path strokeWidth={"1.5"} d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9" />
/>
<path
strokeWidth={"1.5"}
d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4"
/>
<path
strokeWidth={"1.5"}
d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9"
/>
<path strokeWidth={"1.5"} d="M8 7h-4" /> <path strokeWidth={"1.5"} d="M8 7h-4" />
</svg> </svg>
</span> </span>
</div> </div>
<div className={"text-sm"}> <div className={"text-sm"}>
<p <p className={"text-base font-semibold leading-tight text-neutral-800"}>{r.name}</p>
className={
"text-base font-semibold leading-tight text-neutral-800"
}
>
{r.name}
</p>
<p className={"text-neutral-500"}> <p className={"text-neutral-500"}>
Runs after{" "} Runs after{" "}
{r.events {r.events
.filter( .filter((e) => action.events.filter((a: { id: string }) => a.id === e.id).length > 0)
(e) =>
action.events.filter(
(a: { id: string }) => a.id === e.id,
).length > 0,
)
.map((e) => e.name)}{" "} .map((e) => e.name)}{" "}
and{" "} and{" "}
{ {
r.events.filter((e) => { r.events.filter((e) => {
return ( return action.events.filter((a: { id: string }) => a.id === e.id).length === 0;
action.events.filter(
(a: { id: string }) => a.id === e.id,
).length === 0
);
}).length }).length
}{" "} }{" "}
other events other events
@@ -600,10 +507,7 @@ export default function Index() {
}) })
) : ( ) : (
<div className={"sm:col-span-3"}> <div className={"sm:col-span-3"}>
<Empty <Empty title={"No related actions"} description={"Easy access to all actions that share events"} />
title={"No related actions"}
description={"Easy access to all actions that share events"}
/>
</div> </div>
)} )}
</div> </div>
+22 -113
View File
@@ -3,14 +3,7 @@ import { motion } from "framer-motion";
import { Plus, Workflow } from "lucide-react"; import { Plus, Workflow } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { import { Alert, Badge, Card, Empty, FullscreenLoader, Skeleton } from "../../components";
Alert,
Badge,
Card,
Empty,
FullscreenLoader,
Skeleton,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useActions } from "../../lib/hooks/actions"; import { useActions } from "../../lib/hooks/actions";
import { useActiveProject } from "../../lib/hooks/projects"; import { useActiveProject } from "../../lib/hooks/projects";
@@ -33,8 +26,7 @@ export default function Index() {
<Alert type={"info"} title={"Need a hand?"}> <Alert type={"info"} title={"Need a hand?"}>
<div className={"mt-3 grid items-center sm:grid-cols-4"}> <div className={"mt-3 grid items-center sm:grid-cols-4"}>
<p className={"sm:col-span-3"}> <p className={"sm:col-span-3"}>
Want us to help you get started? We can help you build your Want us to help you get started? We can help you build your first action in less than 5 minutes.
first action in less than 5 minutes.
</p> </p>
<Link <Link
@@ -51,9 +43,7 @@ export default function Index() {
<Card <Card
title={"Actions"} title={"Actions"}
description={ description={"Repeatable automations that can be triggered by your applications"}
"Repeatable automations that can be triggered by your applications"
}
actions={ actions={
<> <>
<Link href={"actions/new"} passHref> <Link href={"actions/new"} passHref>
@@ -100,52 +90,25 @@ export default function Index() {
</span> </span>
<div className="flex-1 truncate"> <div className="flex-1 truncate">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<h3 className="truncate text-lg font-bold text-neutral-800"> <h3 className="truncate text-lg font-bold text-neutral-800">{a.name}</h3>
{a.name}
</h3>
</div> </div>
<div className={"mb-6"}> <div className={"mb-6"}>
<h2 <h2 className={"text col-span-2 truncate font-semibold text-neutral-700"}>Quick stats</h2>
className={
"text col-span-2 truncate font-semibold text-neutral-700"
}
>
Quick stats
</h2>
<div className={"grid grid-cols-2 gap-3"}> <div className={"grid grid-cols-2 gap-3"}>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Total triggers</label>
className={ <p className="mt-1 truncate text-sm text-neutral-500">{a.triggers.length}</p>
"text-xs font-medium text-neutral-500"
}
>
Total triggers
</label>
<p className="mt-1 truncate text-sm text-neutral-500">
{a.triggers.length}
</p>
</div> </div>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Last activity</label>
className={
"text-xs font-medium text-neutral-500"
}
>
Last activity
</label>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">
{a.triggers.length > 0 {a.triggers.length > 0 ? "Last triggered" : "Created"}{" "}
? "Last triggered"
: "Created"}{" "}
{dayjs() {dayjs()
.to( .to(
a.triggers.length > 0 a.triggers.length > 0
? a.triggers.sort((a, b) => { ? a.triggers.sort((a, b) => {
return a.createdAt > return a.createdAt > b.createdAt ? -1 : 1;
b.createdAt
? -1
: 1;
})[0].createdAt })[0].createdAt
: a.createdAt, : a.createdAt,
) )
@@ -153,83 +116,37 @@ export default function Index() {
</p> </p>
</div> </div>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Open rate</label>
className={
"text-xs font-medium text-neutral-500"
}
>
Open rate
</label>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">
{a.emails.length > 0 {a.emails.length > 0
? Math.round( ? Math.round((a.emails.filter((e) => e.status === "OPENED").length / a.emails.length) * 100)
(a.emails.filter(
(e) => e.status === "OPENED",
).length /
a.emails.length) *
100,
)
: 0} : 0}
% %
</p> </p>
</div> </div>
{a.delay > 0 && ( {a.delay > 0 && (
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Emails in queue</label>
className={ <p className="mt-1 truncate text-sm text-neutral-500">{a.tasks.length}</p>
"text-xs font-medium text-neutral-500"
}
>
Emails in queue
</label>
<p className="mt-1 truncate text-sm text-neutral-500">
{a.tasks.length}
</p>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className={"my-4"}> <div className={"my-4"}>
<h2 <h2 className={"col-span-2 truncate font-semibold text-neutral-700"}>Properties</h2>
className={
"col-span-2 truncate font-semibold text-neutral-700"
}
>
Properties
</h2>
<div className={"grid grid-cols-2 gap-3"}> <div className={"grid grid-cols-2 gap-3"}>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Repeats</label>
className={
"text-xs font-medium text-neutral-500"
}
>
Repeats
</label>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">
<Badge <Badge type={a.runOnce ? "success" : "info"}>
type={a.runOnce ? "success" : "info"} {a.runOnce ? "Runs once per user" : "Recurring"}
>
{a.runOnce
? "Runs once per user"
: "Recurring"}
</Badge> </Badge>
</p> </p>
</div> </div>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Delay</label>
className={
"text-xs font-medium text-neutral-500"
}
>
Delay
</label>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">
<Badge <Badge type={a.delay === 0 ? "info" : "success"}>
type={
a.delay === 0 ? "info" : "success"
}
>
{a.delay === 0 {a.delay === 0
? "Instant" ? "Instant"
: a.delay % 1440 === 0 : a.delay % 1440 === 0
@@ -252,12 +169,7 @@ export default function Index() {
passHref passHref
className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700" className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -287,10 +199,7 @@ export default function Index() {
</> </>
) : ( ) : (
<> <>
<Empty <Empty title={"No actions here"} description={"Set up a new automation in a few clicks"} />
title={"No actions here"}
description={"Set up a new automation in a few clicks"}
/>
</> </>
) )
) : ( ) : (
+15 -59
View File
@@ -6,14 +6,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { type FieldError, useForm } from "react-hook-form"; import { type FieldError, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Card, Dropdown, FullscreenLoader, Input, MultiselectDropdown, Toggle } from "../../components";
Card,
Dropdown,
FullscreenLoader,
Input,
MultiselectDropdown,
Toggle,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useActions } from "../../lib/hooks/actions"; import { useActions } from "../../lib/hooks/actions";
import { useEvents } from "../../lib/hooks/events"; import { useEvents } from "../../lib/hooks/events";
@@ -84,14 +77,9 @@ export default function Index() {
const create = async (data: ActionValues) => { const create = async (data: ActionValues) => {
toast.promise( toast.promise(
network.mock<Template, typeof ActionSchemas.create>( network.mock<Template, typeof ActionSchemas.create>(project.secret, "POST", "/v1/actions", {
project.secret, ...data,
"POST", }),
"/v1/actions",
{
...data,
},
),
{ {
loading: "Creating new action", loading: "Creating new action",
success: () => { success: () => {
@@ -109,30 +97,17 @@ export default function Index() {
<> <>
<Dashboard> <Dashboard>
<Card title={"Create a new action"}> <Card title={"Create a new action"}>
<form <form onSubmit={handleSubmit(create)} className="mx-auto my-3 max-w-xl space-y-6">
onSubmit={handleSubmit(create)} <Input label={"Name"} placeholder={"Onboarding Flow"} register={register("name")} error={errors.name} />
className="mx-auto my-3 max-w-xl space-y-6"
>
<Input
label={"Name"}
placeholder={"Onboarding Flow"}
register={register("name")}
error={errors.name}
/>
<div> <div>
<label <label htmlFor={"events"} className="block text-sm font-medium text-neutral-700">
htmlFor={"events"}
className="block text-sm font-medium text-neutral-700"
>
Run on triggers Run on triggers
</label> </label>
<MultiselectDropdown <MultiselectDropdown
onChange={(e) => setValue("events", e)} onChange={(e) => setValue("events", e)}
values={events values={events
.filter( .filter((e) => !e.campaignId && !watch("notevents").includes(e.id))
(e) => !e.campaignId && !watch("notevents").includes(e.id),
)
.sort((a, b) => { .sort((a, b) => {
if (a.templateId && !b.templateId) { if (a.templateId && !b.templateId) {
return 1; return 1;
@@ -150,10 +125,7 @@ export default function Index() {
return -1; return -1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -189,18 +161,13 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"events"} className="block text-sm font-medium text-neutral-700">
htmlFor={"events"}
className="block text-sm font-medium text-neutral-700"
>
Exclude contacts with triggers Exclude contacts with triggers
</label> </label>
<MultiselectDropdown <MultiselectDropdown
onChange={(e) => setValue("notevents", e)} onChange={(e) => setValue("notevents", e)}
values={events values={events
.filter( .filter((e) => !e.campaignId && !watch("events").includes(e.id))
(e) => !e.campaignId && !watch("events").includes(e.id),
)
.sort((a, b) => { .sort((a, b) => {
if (a.templateId && !b.templateId) { if (a.templateId && !b.templateId) {
return 1; return 1;
@@ -218,10 +185,7 @@ export default function Index() {
return -1; return -1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -257,10 +221,7 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"template"} className="block text-sm font-medium text-neutral-700">
htmlFor={"template"}
className="block text-sm font-medium text-neutral-700"
>
Template Template
</label> </label>
<Dropdown <Dropdown
@@ -285,10 +246,7 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"template"} className="block text-sm font-medium text-neutral-800">
htmlFor={"template"}
className="block text-sm font-medium text-neutral-800"
>
Delay before sending Delay before sending
</label> </label>
<div className={"grid grid-cols-6 gap-4"}> <div className={"grid grid-cols-6 gap-4"}>
@@ -332,9 +290,7 @@ export default function Index() {
<div> <div>
<Toggle <Toggle
title={"Run once"} title={"Run once"}
description={ description={"Toggle this on if you want to run this action only once per contact."}
"Toggle this on if you want to run this action only once per contact."
}
toggled={watch("runOnce")} toggled={watch("runOnce")}
onToggle={() => setValue("runOnce", !watch("runOnce"))} onToggle={() => setValue("runOnce", !watch("runOnce"))}
/> />
+40 -145
View File
@@ -65,36 +65,23 @@ export default function Index() {
<div> <div>
<p className={"font-medium text-neutral-600"}>Bounce Rate</p> <p className={"font-medium text-neutral-600"}>Bounce Rate</p>
<p className={"text-2xl font-semibold text-neutral-800"}> <p className={"text-2xl font-semibold text-neutral-800"}>
<> <>{((analytics.emails.bounced / analytics.emails.total) * 100).toFixed(2)}%</>
{(
(analytics.emails.bounced / analytics.emails.total) *
100
).toFixed(2)}
%
</>
</p> </p>
</div> </div>
<div className={"flex flex-1 justify-end"}> <div className={"flex flex-1 justify-end"}>
{analytics.emails.bounced / analytics.emails.total > {analytics.emails.bounced / analytics.emails.total >
analytics.emails.bouncedPrev / analytics.emails.totalPrev ? ( analytics.emails.bouncedPrev / analytics.emails.totalPrev ? (
<> <>
<span <span className={"flex items-center gap-1 text-sm font-medium text-neutral-500"}>
className={
"flex items-center gap-1 text-sm font-medium text-neutral-500"
}
>
{Number.isNaN( {Number.isNaN(
(analytics.emails.bounced / analytics.emails.total - (analytics.emails.bounced / analytics.emails.total -
analytics.emails.bouncedPrev / analytics.emails.bouncedPrev / analytics.emails.totalPrev) *
analytics.emails.totalPrev) *
100, 100,
) )
? 0 ? 0
: ( : (
(analytics.emails.bounced / (analytics.emails.bounced / analytics.emails.total -
analytics.emails.total - analytics.emails.bouncedPrev / analytics.emails.totalPrev) *
analytics.emails.bouncedPrev /
analytics.emails.totalPrev) *
100 100
).toFixed(2)} ).toFixed(2)}
% %
@@ -103,23 +90,16 @@ export default function Index() {
</> </>
) : ( ) : (
<> <>
<span <span className={"flex items-center gap-1 text-sm font-medium text-neutral-500"}>
className={
"flex items-center gap-1 text-sm font-medium text-neutral-500"
}
>
{Number.isNaN( {Number.isNaN(
(analytics.emails.bounced / analytics.emails.total - (analytics.emails.bounced / analytics.emails.total -
analytics.emails.bouncedPrev / analytics.emails.bouncedPrev / analytics.emails.totalPrev) *
analytics.emails.totalPrev) *
100, 100,
) )
? 0 ? 0
: ( : (
(analytics.emails.bounced / (analytics.emails.bounced / analytics.emails.total -
analytics.emails.total - analytics.emails.bouncedPrev / analytics.emails.totalPrev) *
analytics.emails.bouncedPrev /
analytics.emails.totalPrev) *
100 100
).toFixed(2)} ).toFixed(2)}
% %
@@ -143,37 +123,23 @@ export default function Index() {
<div> <div>
<p className={"font-medium text-neutral-600"}>Spam Rate</p> <p className={"font-medium text-neutral-600"}>Spam Rate</p>
<p className={"text-2xl font-semibold text-neutral-800"}> <p className={"text-2xl font-semibold text-neutral-800"}>
<> <>{((analytics.emails.complaint / analytics.emails.total) * 100).toFixed(2)}%</>
{(
(analytics.emails.complaint / analytics.emails.total) *
100
).toFixed(2)}
%
</>
</p> </p>
</div> </div>
<div className={"flex flex-1 justify-end"}> <div className={"flex flex-1 justify-end"}>
{analytics.emails.complaint / analytics.emails.total > {analytics.emails.complaint / analytics.emails.total >
analytics.emails.complaintPrev / analytics.emails.complaintPrev / analytics.emails.totalPrev ? (
analytics.emails.totalPrev ? (
<> <>
<span <span className={"flex items-center gap-1 text-sm font-medium text-neutral-500"}>
className={
"flex items-center gap-1 text-sm font-medium text-neutral-500"
}
>
{Number.isNaN( {Number.isNaN(
(analytics.emails.complaint / analytics.emails.total - (analytics.emails.complaint / analytics.emails.total -
analytics.emails.complaintPrev / analytics.emails.complaintPrev / analytics.emails.totalPrev) *
analytics.emails.totalPrev) *
100, 100,
) )
? 0 ? 0
: ( : (
(analytics.emails.complaint / (analytics.emails.complaint / analytics.emails.total -
analytics.emails.total - analytics.emails.complaintPrev / analytics.emails.totalPrev) *
analytics.emails.complaintPrev /
analytics.emails.totalPrev) *
100 100
).toFixed(2)} ).toFixed(2)}
% %
@@ -182,23 +148,16 @@ export default function Index() {
</> </>
) : ( ) : (
<> <>
<span <span className={"flex items-center gap-1 text-sm font-medium text-neutral-500"}>
className={
"flex items-center gap-1 text-sm font-medium text-neutral-500"
}
>
{Number.isNaN( {Number.isNaN(
(analytics.emails.complaint / analytics.emails.total - (analytics.emails.complaint / analytics.emails.total -
analytics.emails.complaintPrev / analytics.emails.complaintPrev / analytics.emails.totalPrev) *
analytics.emails.totalPrev) *
100, 100,
) )
? 0 ? 0
: ( : (
(analytics.emails.complaint / (analytics.emails.complaint / analytics.emails.total -
analytics.emails.total - analytics.emails.complaintPrev / analytics.emails.totalPrev) *
analytics.emails.complaintPrev /
analytics.emails.totalPrev) *
100 100
).toFixed(2)} ).toFixed(2)}
% %
@@ -225,9 +184,7 @@ export default function Index() {
height={300} height={300}
data={analytics.contacts.timeseries data={analytics.contacts.timeseries
.sort((a, b) => { .sort((a, b) => {
return ( return new Date(a.day).getTime() - new Date(b.day).getTime();
new Date(a.day).getTime() - new Date(b.day).getTime()
);
}) })
.map((i) => { .map((i) => {
return { return {
@@ -244,23 +201,9 @@ export default function Index() {
> >
<CartesianGrid strokeDasharray="4 5" /> <CartesianGrid strokeDasharray="4 5" />
<defs> <defs>
<linearGradient <linearGradient id="gradientFill" x1="0" y1="0" x2="0" y2="1">
id="gradientFill" <stop offset="100%" stopColor="#2563eb" stopOpacity={0.4} />
x1="0" <stop offset="100%" stopColor="#93c5fd" stopOpacity={0} />
y1="0"
x2="0"
y2="1"
>
<stop
offset="100%"
stopColor="#2563eb"
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor="#93c5fd"
stopOpacity={0}
/>
</linearGradient> </linearGradient>
</defs> </defs>
@@ -270,9 +213,7 @@ export default function Index() {
0, 0,
analytics.contacts.timeseries.length === 0 analytics.contacts.timeseries.length === 0
? 10 ? 10
: analytics.contacts.timeseries[ : analytics.contacts.timeseries[analytics.contacts.timeseries.length - 1].count * 1.1,
analytics.contacts.timeseries.length - 1
].count * 1.1,
]} ]}
fill={"#fff"} fill={"#fff"}
tickSize={0} tickSize={0}
@@ -311,9 +252,7 @@ export default function Index() {
return ( return (
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm"> <div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
<p className="font-medium text-neutral-800">{`${label}`}</p> <p className="font-medium text-neutral-800">{`${label}`}</p>
<p className="text-neutral-600"> <p className="text-neutral-600">{valueFormatter(dataPoint.value as number)}</p>
{valueFormatter(dataPoint.value as number)}
</p>
</div> </div>
); );
} }
@@ -322,13 +261,7 @@ export default function Index() {
}} }}
/> />
<Area <Area type="basis" dataKey="count" stroke="#2563eb" fill="url(#gradientFill)" strokeWidth={2} />
type="basis"
dataKey="count"
stroke="#2563eb"
fill="url(#gradientFill)"
strokeWidth={2}
/>
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</> </>
@@ -353,9 +286,7 @@ export default function Index() {
return ( return (
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm"> <div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
<p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p> <p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p>
<p className="text-neutral-600"> <p className="text-neutral-600">{valueFormatter(dataPoint.value as number)}</p>
{valueFormatter(dataPoint.value as number)}
</p>
</div> </div>
); );
} }
@@ -378,20 +309,10 @@ export default function Index() {
cx="50%" cx="50%"
cy="50%" cy="50%"
labelLine={false} labelLine={false}
label={({ label={({ cx, cy, midAngle, innerRadius, outerRadius, percent }) => {
cx, const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
cy, const x = cx + radius * Math.cos((-midAngle * Math.PI) / 180);
midAngle, const y = cy + radius * Math.sin((-midAngle * Math.PI) / 180);
innerRadius,
outerRadius,
percent,
}) => {
const radius =
innerRadius + (outerRadius - innerRadius) * 0.5;
const x =
cx + radius * Math.cos((-midAngle * Math.PI) / 180);
const y =
cy + radius * Math.sin((-midAngle * Math.PI) / 180);
if (percent < 0.1) { if (percent < 0.1) {
return null; return null;
@@ -424,11 +345,7 @@ export default function Index() {
value: analytics.contacts.unsubscribed, value: analytics.contacts.unsubscribed,
}, },
].map((entry, index) => ( ].map((entry, index) => (
<Cell <Cell style={{ outline: "none" }} key={`cell-${entry.name}`} fill={["#3b82f6", "#e5e5e5"][index % 2]} />
style={{ outline: "none" }}
key={`cell-${entry.name}`}
fill={["#3b82f6", "#e5e5e5"][index % 2]}
/>
))} ))}
</Pie> </Pie>
</PieChart> </PieChart>
@@ -455,9 +372,7 @@ export default function Index() {
return ( return (
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm"> <div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
<p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p> <p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p>
<p className="text-neutral-600"> <p className="text-neutral-600">{valueFormatter(dataPoint.value as number)}</p>
{valueFormatter(dataPoint.value as number)}
</p>
</div> </div>
); );
} }
@@ -473,29 +388,16 @@ export default function Index() {
{ {
name: "Unopened", name: "Unopened",
value: value:
analytics.emails.total - analytics.emails.total - analytics.emails.opened - analytics.emails.bounced - analytics.emails.complaint,
analytics.emails.opened -
analytics.emails.bounced -
analytics.emails.complaint,
}, },
]} ]}
cx="50%" cx="50%"
cy="50%" cy="50%"
labelLine={false} labelLine={false}
label={({ label={({ cx, cy, midAngle, innerRadius, outerRadius, percent }) => {
cx, const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
cy, const x = cx + radius * Math.cos((-midAngle * Math.PI) / 180);
midAngle, const y = cy + radius * Math.sin((-midAngle * Math.PI) / 180);
innerRadius,
outerRadius,
percent,
}) => {
const radius =
innerRadius + (outerRadius - innerRadius) * 0.5;
const x =
cx + radius * Math.cos((-midAngle * Math.PI) / 180);
const y =
cy + radius * Math.sin((-midAngle * Math.PI) / 180);
if (percent < 0.1) { if (percent < 0.1) {
return null; return null;
@@ -524,17 +426,10 @@ export default function Index() {
{ {
name: "Unopened", name: "Unopened",
value: value:
analytics.emails.total - analytics.emails.total - analytics.emails.opened - analytics.emails.bounced - analytics.emails.complaint,
analytics.emails.opened -
analytics.emails.bounced -
analytics.emails.complaint,
}, },
].map((entry, index) => ( ].map((entry, index) => (
<Cell <Cell style={{ outline: "none" }} key={`cell-${entry.name}`} fill={["#3b82f6", "#e5e5e5"][index % 2]} />
style={{ outline: "none" }}
key={`cell-${entry.name}`}
fill={["#3b82f6", "#e5e5e5"][index % 2]}
/>
))} ))}
</Pie> </Pie>
</PieChart> </PieChart>
+6 -29
View File
@@ -87,26 +87,15 @@ export default function Index() {
<> <>
<div className="bg-off-white flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="bg-off-white flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="flex flex-col items-center sm:mx-auto sm:w-full sm:max-w-md"> <div className="flex flex-col items-center sm:mx-auto sm:w-full sm:max-w-md">
<Image <Image src={logo} placeholder={"blur"} width={35} height={35} alt={"Plunk Logo"} />
src={logo} <h2 className="mt-4 text-center text-3xl font-bold text-neutral-800">Sign in to your account</h2>
placeholder={"blur"}
width={35}
height={35}
alt={"Plunk Logo"}
/>
<h2 className="mt-4 text-center text-3xl font-bold text-neutral-800">
Sign in to your account
</h2>
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="rounded border border-neutral-200 bg-white px-4 py-8 sm:px-10"> <div className="rounded border border-neutral-200 bg-white px-4 py-8 sm:px-10">
<form onSubmit={handleSubmit(login)} className="space-y-6"> <form onSubmit={handleSubmit(login)} className="space-y-6">
<div> <div>
<label <label htmlFor={"email"} className="block text-sm font-medium text-neutral-700">
htmlFor={"email"}
className="block text-sm font-medium text-neutral-700"
>
Email Email
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -135,10 +124,7 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"password"} className="block text-sm font-semibold text-neutral-600">
htmlFor={"password"}
className="block text-sm font-semibold text-neutral-600"
>
Password Password
</label> </label>
<div className="relative mt-1"> <div className="relative mt-1">
@@ -212,14 +198,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
@@ -249,9 +228,7 @@ export default function Index() {
<Link <Link
href={"/auth/signup"} href={"/auth/signup"}
passHref passHref
className={ className={"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-500"}
"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-500"
}
> >
Want to create an account instead? Want to create an account instead?
</Link> </Link>
+6 -28
View File
@@ -33,9 +33,7 @@ export default function Index() {
}); });
const resetPassword = async (data: ResetValues) => { const resetPassword = async (data: ResetValues) => {
const schema = UtilitySchemas.id.merge( const schema = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true }));
UserSchemas.credentials.pick({ password: true }),
);
setSubmitted(true); setSubmitted(true);
await network.fetch< await network.fetch<
@@ -55,13 +53,7 @@ export default function Index() {
<main className={"flex h-screen w-screen items-center justify-center"}> <main className={"flex h-screen w-screen items-center justify-center"}>
<div className={"space-y-6"}> <div className={"space-y-6"}>
<div> <div>
<svg <svg className={"mx-auto h-14 w-14 rounded-full bg-blue-100 p-2 text-blue-900"} fill="none" viewBox="0 0 24 24">
className={
"mx-auto h-14 w-14 rounded-full bg-blue-100 p-2 text-blue-900"
}
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -76,19 +68,12 @@ export default function Index() {
</svg> </svg>
</div> </div>
<div className={"space-y-3 text-center"}> <div className={"space-y-3 text-center"}>
<h1 className={"text-4xl font-bold text-neutral-800"}> <h1 className={"text-4xl font-bold text-neutral-800"}>Reset password</h1>
Reset password <p className={"text-neutral-700"}>Please enter your new password and confirm it.</p>
</h1>
<p className={"text-neutral-700"}>
Please enter your new password and confirm it.
</p>
</div> </div>
<form onSubmit={handleSubmit(resetPassword)} className="space-y-6"> <form onSubmit={handleSubmit(resetPassword)} className="space-y-6">
<div> <div>
<label <label htmlFor={"password"} className="block text-sm font-semibold text-neutral-600">
htmlFor={"password"}
className="block text-sm font-semibold text-neutral-600"
>
New password New password
</label> </label>
<div className="relative mt-1"> <div className="relative mt-1">
@@ -162,14 +147,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
+7 -32
View File
@@ -86,22 +86,12 @@ export default function Index() {
<div className="flex flex-1 flex-col justify-center px-4 py-12 sm:border-r-2 sm:border-neutral-100 sm:px-6 lg:flex-none lg:px-20 xl:px-32"> <div className="flex flex-1 flex-col justify-center px-4 py-12 sm:border-r-2 sm:border-neutral-100 sm:px-6 lg:flex-none lg:px-20 xl:px-32">
<div className="mx-auto w-full max-w-sm"> <div className="mx-auto w-full max-w-sm">
<div> <div>
<Image <Image width={35} height={35} src={logo} alt={"Plunk logo"} placeholder={"blur"} />
width={35} <h2 className="mt-6 text-3xl font-extrabold text-neutral-800">Create a Plunk account</h2>
height={35}
src={logo}
alt={"Plunk logo"}
placeholder={"blur"}
/>
<h2 className="mt-6 text-3xl font-extrabold text-neutral-800">
Create a Plunk account
</h2>
<div> <div>
<Link <Link
href={"/auth/login"} href={"/auth/login"}
className={ className={"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-600"}
"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-600"
}
> >
Already have an account? Already have an account?
</Link> </Link>
@@ -112,10 +102,7 @@ export default function Index() {
<div className="mt-6"> <div className="mt-6">
<form onSubmit={handleSubmit(signup)} className="space-y-6"> <form onSubmit={handleSubmit(signup)} className="space-y-6">
<div> <div>
<label <label htmlFor={"email"} className="block text-sm font-medium text-neutral-700">
htmlFor={"email"}
className="block text-sm font-medium text-neutral-700"
>
Your Email Your Email
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -144,18 +131,13 @@ export default function Index() {
</div> </div>
<div> <div>
<label <label htmlFor={"password"} className="block text-sm font-semibold text-neutral-600">
htmlFor={"password"}
className="block text-sm font-semibold text-neutral-600"
>
A Strong Password A Strong Password
</label> </label>
<div className="relative mt-1"> <div className="relative mt-1">
<input <input
type={hidePassword ? "password" : "text"} type={hidePassword ? "password" : "text"}
placeholder={ placeholder={hidePassword ? "•••••••••••••" : "Password"}
hidePassword ? "•••••••••••••" : "Password"
}
autoComplete={"new-password"} autoComplete={"new-password"}
className={ className={
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm" "block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
@@ -223,14 +205,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
+90 -276
View File
@@ -48,9 +48,7 @@ export default function Index() {
const project = useActiveProject(); const project = useActiveProject();
const { mutate: campaignsMutate } = useCampaigns(); const { mutate: campaignsMutate } = useCampaigns();
const { data: campaign, mutate: campaignMutate } = useCampaign( const { data: campaign, mutate: campaignMutate } = useCampaign(router.query.id as string);
router.query.id as string,
);
const { data: contacts } = useContacts(0); const { data: contacts } = useContacts(0);
const { data: events } = useEventsWithoutTriggers(); const { data: events } = useEventsWithoutTriggers();
@@ -63,7 +61,6 @@ export default function Index() {
notlast?: "day" | "week" | "month"; notlast?: "day" | "week" | "month";
}>({}); }>({});
const [confirmModal, setConfirmModal] = useState(false); const [confirmModal, setConfirmModal] = useState(false);
const [paymentModal, setPaymentModal] = useState(false);
const [advancedSelector, setSelector] = useState(false); const [advancedSelector, setSelector] = useState(false);
const [delay, setDelay] = useState(0); const [delay, setDelay] = useState(0);
@@ -90,12 +87,7 @@ export default function Index() {
}); });
}, [reset, campaign]); }, [reset, campaign]);
if ( if (!project || !campaign || !events || (watch("body") as string | undefined) === undefined) {
!project ||
!campaign ||
!events ||
(watch("body") as string | undefined) === undefined
) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
@@ -108,9 +100,7 @@ export default function Index() {
if (query.events && query.events.length > 0) { if (query.events && query.events.length > 0) {
query.events.map((e) => { query.events.map((e) => {
filteredContacts = filteredContacts.filter((c) => filteredContacts = filteredContacts.filter((c) => c.triggers.some((t) => t.eventId === e));
c.triggers.some((t) => t.eventId === e),
);
}); });
} }
@@ -120,17 +110,13 @@ export default function Index() {
return false; return false;
} }
const lastTrigger = c.triggers.sort((a, b) => const lastTrigger = c.triggers.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
a.createdAt > b.createdAt ? -1 : 1,
);
if (lastTrigger.length === 0) { if (lastTrigger.length === 0) {
return false; return false;
} }
return dayjs(lastTrigger[0].createdAt).isAfter( return dayjs(lastTrigger[0].createdAt).isAfter(dayjs().subtract(1, query.last));
dayjs().subtract(1, query.last),
);
}); });
} }
@@ -141,24 +127,18 @@ export default function Index() {
return true; return true;
} }
const lastTrigger = c.triggers const lastTrigger = c.triggers.filter((t) => t.eventId === e).sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
.filter((t) => t.eventId === e)
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
if (lastTrigger.length === 0) { if (lastTrigger.length === 0) {
return true; return true;
} }
return dayjs(lastTrigger[0].createdAt).isAfter( return dayjs(lastTrigger[0].createdAt).isAfter(dayjs().subtract(1, query.last));
dayjs().subtract(1, query.last),
);
}); });
}); });
} else if (query.notevents && query.notevents.length > 0) { } else if (query.notevents && query.notevents.length > 0) {
query.notevents.map((e) => { query.notevents.map((e) => {
filteredContacts = filteredContacts.filter((c) => filteredContacts = filteredContacts.filter((c) => c.triggers.every((t) => t.eventId !== e));
c.triggers.every((t) => t.eventId !== e),
);
}); });
} else if (query.notlast) { } else if (query.notlast) {
filteredContacts = filteredContacts.filter((c) => { filteredContacts = filteredContacts.filter((c) => {
@@ -166,17 +146,13 @@ export default function Index() {
return true; return true;
} }
const lastTrigger = c.triggers.sort((a, b) => const lastTrigger = c.triggers.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
a.createdAt > b.createdAt ? -1 : 1,
);
if (lastTrigger.length === 0) { if (lastTrigger.length === 0) {
return true; return true;
} }
return !dayjs(lastTrigger[0].createdAt).isAfter( return !dayjs(lastTrigger[0].createdAt).isAfter(dayjs().subtract(1, query.notlast));
dayjs().subtract(1, query.notlast),
);
}); });
} }
@@ -211,17 +187,14 @@ export default function Index() {
const send = async (data: CampaignValues) => { const send = async (data: CampaignValues) => {
setConfirmModal(false); setConfirmModal(false);
toast.success( toast.success("Saved your campaign. Starting delivery now, please hold on!");
"Saved your campaign. Starting delivery now, please hold on!",
);
await network.mock<Campaign, typeof CampaignSchemas.update>( await network.mock<Campaign, typeof CampaignSchemas.update>(
project.secret, project.secret,
"PUT", "PUT",
"/v1/campaigns", "/v1/campaigns",
data.recipients.length === data.recipients.length === contacts?.contacts.filter((c) => c.subscribed).length
contacts?.contacts.filter((c) => c.subscribed).length
? { id: campaign.id, ...data, recipients: ["all"] } ? { id: campaign.id, ...data, recipients: ["all"] }
: { : {
id: campaign.id, id: campaign.id,
@@ -230,16 +203,11 @@ export default function Index() {
); );
toast.promise( toast.promise(
network.mock<Campaign, typeof CampaignSchemas.send>( network.mock<Campaign, typeof CampaignSchemas.send>(project.secret, "POST", "/v1/campaigns/send", {
project.secret, id: campaign.id,
"POST", live: true,
"/v1/campaigns/send", delay,
{ }),
id: campaign.id,
live: true,
delay,
},
),
{ {
loading: "Starting delivery...", loading: "Starting delivery...",
@@ -257,27 +225,17 @@ export default function Index() {
}; };
const sendTest = async (data: CampaignValues) => { const sendTest = async (data: CampaignValues) => {
await network.mock<Campaign, typeof CampaignSchemas.update>( await network.mock<Campaign, typeof CampaignSchemas.update>(project.secret, "PUT", "/v1/campaigns", {
project.secret, id: campaign.id,
"PUT", ...data,
"/v1/campaigns", });
{
id: campaign.id,
...data,
},
);
toast.promise( toast.promise(
network.mock<Campaign, typeof CampaignSchemas.send>( network.mock<Campaign, typeof CampaignSchemas.send>(project.secret, "POST", "/v1/campaigns/send", {
project.secret, id: campaign.id,
"POST", live: false,
"/v1/campaigns/send", delay: 0,
{ }),
id: campaign.id,
live: false,
delay: 0,
},
),
{ {
loading: "Sending you a test campaign", loading: "Sending you a test campaign",
@@ -289,15 +247,10 @@ export default function Index() {
const update = (data: CampaignValues) => { const update = (data: CampaignValues) => {
toast.promise( toast.promise(
network.mock<Campaign, typeof CampaignSchemas.update>( network.mock<Campaign, typeof CampaignSchemas.update>(project.secret, "PUT", "/v1/campaigns", {
project.secret, id: campaign.id,
"PUT", ...data,
"/v1/campaigns", }),
{
id: campaign.id,
...data,
},
),
{ {
loading: "Saving your campaign", loading: "Saving your campaign",
success: () => { success: () => {
@@ -313,14 +266,9 @@ export default function Index() {
const duplicate = async (e: { preventDefault: () => void }) => { const duplicate = async (e: { preventDefault: () => void }) => {
e.preventDefault(); e.preventDefault();
toast.promise( toast.promise(
network.mock<Template, typeof UtilitySchemas.id>( network.mock<Template, typeof UtilitySchemas.id>(project.secret, "POST", "/v1/campaigns/duplicate", {
project.secret, id: campaign.id,
"POST", }),
"/v1/campaigns/duplicate",
{
id: campaign.id,
},
),
{ {
loading: "Duplicating your campaign", loading: "Duplicating your campaign",
success: () => { success: () => {
@@ -338,14 +286,9 @@ export default function Index() {
const remove = async (e: { preventDefault: () => void }) => { const remove = async (e: { preventDefault: () => void }) => {
e.preventDefault(); e.preventDefault();
toast.promise( toast.promise(
network.mock<Template, typeof UtilitySchemas.id>( network.mock<Template, typeof UtilitySchemas.id>(project.secret, "DELETE", "/v1/campaigns", {
project.secret, id: campaign.id,
"DELETE", }),
"/v1/campaigns",
{
id: campaign.id,
},
),
{ {
loading: "Deleting your campaign", loading: "Deleting your campaign",
success: () => { success: () => {
@@ -370,9 +313,7 @@ export default function Index() {
title={"Send campaign"} title={"Send campaign"}
description={`Once you start sending this campaign to ${watch("recipients").length} contacts, you can no longer make changes or undo it.`} description={`Once you start sending this campaign to ${watch("recipients").length} contacts, you can no longer make changes or undo it.`}
> >
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">Delay</label>
Delay
</label>
<Dropdown <Dropdown
inModal={true} inModal={true}
onChange={(val) => setDelay(Number.parseInt(val))} onChange={(val) => setDelay(Number.parseInt(val))}
@@ -403,9 +344,7 @@ export default function Index() {
</Modal> </Modal>
<Dashboard> <Dashboard>
<Card <Card
title={ title={campaign.status !== "DRAFT" ? "View campaign" : "Update campaign"}
campaign.status !== "DRAFT" ? "View campaign" : "Update campaign"
}
options={ options={
<> <>
<button <button
@@ -457,23 +396,14 @@ export default function Index() {
strokeWidth="1.5" strokeWidth="1.5"
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5" d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
/> />
<path <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M5 7.75H19" />
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M5 7.75H19"
/>
</svg> </svg>
Delete Delete
</button> </button>
</> </>
} }
> >
<form <form onSubmit={handleSubmit(update)} className="space-6 grid gap-6 sm:grid-cols-6">
onSubmit={handleSubmit(update)}
className="space-6 grid gap-6 sm:grid-cols-6"
>
<Input <Input
className={"sm:col-span-6"} className={"sm:col-span-6"}
label={"Subject"} label={"Subject"}
@@ -485,10 +415,7 @@ export default function Index() {
{contacts ? ( {contacts ? (
<> <>
<div className={"sm:col-span-3"}> <div className={"sm:col-span-3"}>
<label <label htmlFor={"recipients"} className="block text-sm font-medium text-neutral-700">
htmlFor={"recipients"}
className="block text-sm font-medium text-neutral-700"
>
Recipients Recipients
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -528,23 +455,15 @@ export default function Index() {
setValue( setValue(
"recipients", "recipients",
contacts.contacts contacts.contacts.filter((c) => c.subscribed).map((c) => c.id),
.filter((c) => c.subscribed)
.map((c) => c.id),
); );
}} }}
className={ className={
"mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100" "mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100"
} }
> >
{watch("recipients").length === 0 ? ( {watch("recipients").length === 0 ? <Users2 size={18} /> : <XIcon size={18} />}
<Users2 size={18} /> {watch("recipients").length === 0 ? "All contacts" : "Clear selection"}
) : (
<XIcon size={18} />
)}
{watch("recipients").length === 0
? "All contacts"
: "Clear selection"}
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -555,11 +474,7 @@ export default function Index() {
"mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100" "mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100"
} }
> >
{advancedSelector ? ( {advancedSelector ? <XIcon size={18} /> : <Search size={18} />}
<XIcon size={18} />
) : (
<Search size={18} />
)}
{advancedSelector ? "Close" : "Advanced selector"} {advancedSelector ? "Close" : "Advanced selector"}
</button> </button>
</> </>
@@ -578,10 +493,7 @@ export default function Index() {
} }
> >
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
Has triggers for events Has triggers for events
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -607,10 +519,7 @@ export default function Index() {
return 1; return 1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -621,11 +530,7 @@ export default function Index() {
name: e.name, name: e.name,
value: e.id, value: e.id,
tag: tag:
e.templateId ?? e.campaignId e.templateId ?? e.campaignId ? (e.name.includes("opened") ? "On Open" : "On Delivery") : undefined,
? e.name.includes("opened")
? "On Open"
: "On Delivery"
: undefined,
}; };
}), }),
]} ]}
@@ -636,21 +541,14 @@ export default function Index() {
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
{query.events && query.events.length > 0 && ( {query.events && query.events.length > 0 && (
<> <>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"} Has triggered {query.events.length} selected events
className="block text-sm font-medium text-neutral-700"
>
Has triggered {query.events.length} selected
events
</label> </label>
<Dropdown <Dropdown
onChange={(e) => onChange={(e) =>
setQuery({ setQuery({
...query, ...query,
last: last: (e as "" | "day" | "week" | "month") === "" ? undefined : (e as "day" | "week" | "month"),
(e as "" | "day" | "week" | "month") === ""
? undefined
: (e as "day" | "week" | "month"),
}) })
} }
values={[ values={[
@@ -666,10 +564,7 @@ export default function Index() {
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
No triggers for events No triggers for events
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -695,10 +590,7 @@ export default function Index() {
return 1; return 1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -709,11 +601,7 @@ export default function Index() {
name: e.name, name: e.name,
value: e.id, value: e.id,
tag: tag:
e.templateId ?? e.campaignId e.templateId ?? e.campaignId ? (e.name.includes("opened") ? "On Open" : "On Delivery") : undefined,
? e.name.includes("opened")
? "On Open"
: "On Delivery"
: undefined,
}; };
}), }),
]} ]}
@@ -724,21 +612,14 @@ export default function Index() {
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
{query.notevents && query.notevents.length > 0 && ( {query.notevents && query.notevents.length > 0 && (
<> <>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"} Not triggered {query.notevents.length} selected events
className="block text-sm font-medium text-neutral-700"
>
Not triggered {query.notevents.length} selected
events
</label> </label>
<Dropdown <Dropdown
onChange={(e) => onChange={(e) =>
setQuery({ setQuery({
...query, ...query,
notlast: notlast: (e as "" | "day" | "week" | "month") === "" ? undefined : (e as "day" | "week" | "month"),
(e as "" | "day" | "week" | "month") === ""
? undefined
: (e as "day" | "week" | "month"),
}) })
} }
values={[ values={[
@@ -754,10 +635,7 @@ export default function Index() {
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
All contacts with parameter All contacts with parameter
</label> </label>
<Dropdown <Dropdown
@@ -773,15 +651,11 @@ export default function Index() {
contacts.contacts contacts.contacts
.filter((c) => c.data) .filter((c) => c.data)
.map((c) => { .map((c) => {
return Object.keys( return Object.keys(JSON.parse(c.data ?? "{}"));
JSON.parse(c.data ?? "{}"),
);
}) })
.reduce((acc, val) => acc.concat(val), []), .reduce((acc, val) => acc.concat(val), []),
), ),
].map((k) => ].map((k) => (typeof k === "string" ? { name: k, value: k } : k))}
typeof k === "string" ? { name: k, value: k } : k,
)}
selectedValue={query.data ?? ""} selectedValue={query.data ?? ""}
/> />
</div> </div>
@@ -789,10 +663,7 @@ export default function Index() {
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
{query.data && ( {query.data && (
<> <>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
All contacts where parameter {query.data} is All contacts where parameter {query.data} is
</label> </label>
@@ -807,15 +678,9 @@ export default function Index() {
{ name: "Any value", value: "" }, { name: "Any value", value: "" },
...new Set( ...new Set(
contacts.contacts contacts.contacts
.filter( .filter((c) => c.data && JSON.parse(c.data)[query.data ?? ""])
(c) =>
c.data &&
JSON.parse(c.data)[query.data ?? ""],
)
.map((c) => { .map((c) => {
return JSON.parse(c.data ?? "{}")[ return JSON.parse(c.data ?? "{}")[query.data ?? ""];
query.data ?? ""
];
}) })
.reduce((acc, val) => acc.concat(val), []), .reduce((acc, val) => acc.concat(val), []),
), ),
@@ -848,12 +713,7 @@ export default function Index() {
"ml-auto flex items-center justify-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white" "ml-auto flex items-center justify-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
} }
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -879,20 +739,13 @@ export default function Index() {
) : ( ) : (
campaign.status === "DRAFT" && ( campaign.status === "DRAFT" && (
<> <>
<div <div className={"flex items-center gap-6 rounded border border-neutral-300 px-8 py-3 sm:col-span-6"}>
className={
"flex items-center gap-6 rounded border border-neutral-300 px-8 py-3 sm:col-span-6"
}
>
<Ring size={20} /> <Ring size={20} />
<div> <div>
<h1 className={"text-lg font-semibold text-neutral-800"}> <h1 className={"text-lg font-semibold text-neutral-800"}>Hang on!</h1>
Hang on!
</h1>
<p className={"text-sm text-neutral-600"}> <p className={"text-sm text-neutral-600"}>
We're still loading your contacts. This might take up to We're still loading your contacts. This might take up to a minute. You can already start writing your
a minute. You can already start writing your campaign in campaign in the editor below.
the editor below.
</p> </p>
</div> </div>
</div> </div>
@@ -901,51 +754,35 @@ export default function Index() {
)} )}
<AnimatePresence> <AnimatePresence>
{watch("recipients").length >= 10 && {watch("recipients").length >= 10 && campaign.status !== "DELIVERED" && (
campaign.status !== "DELIVERED" && ( <motion.div
<motion.div initial={{ opacity: 0, height: 0 }}
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }}
animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }}
exit={{ opacity: 0, height: 0 }} className={"relative z-10 sm:col-span-6"}
className={"relative z-10 sm:col-span-6"} >
> <Alert type={"info"} title={"Automatic batching"}>
<Alert type={"info"} title={"Automatic batching"}> Your campaign will be sent out in batches of 80 recipients each. It will be delivered to all contacts{" "}
Your campaign will be sent out in batches of 80 recipients {dayjs().to(dayjs().add(Math.ceil(watch("recipients").length / 80), "minutes"))}
each. It will be delivered to all contacts{" "} </Alert>
{dayjs().to( </motion.div>
dayjs().add( )}
Math.ceil(watch("recipients").length / 80),
"minutes",
),
)}
</Alert>
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
{campaign.status !== "DRAFT" && {campaign.status !== "DRAFT" &&
(campaign.emails.length === 0 ? ( (campaign.emails.length === 0 ? (
<div <div className={"flex items-center gap-6 rounded border border-neutral-300 px-6 py-3 sm:col-span-6"}>
className={
"flex items-center gap-6 rounded border border-neutral-300 px-6 py-3 sm:col-span-6"
}
>
<Ring size={20} /> <Ring size={20} />
<div> <div>
<h1 className={"text-lg font-semibold text-neutral-800"}> <h1 className={"text-lg font-semibold text-neutral-800"}>Hang on!</h1>
Hang on!
</h1>
<p className={"text-sm text-neutral-600"}> <p className={"text-sm text-neutral-600"}>
We are still sending your campaign. Emails will start We are still sending your campaign. Emails will start appearing here once they are sent.
appearing here once they are sent.
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<div <div
className={ className={"max-h-[400px] overflow-x-hidden overflow-y-scroll rounded border border-neutral-200 sm:col-span-6"}
"max-h-[400px] overflow-x-hidden overflow-y-scroll rounded border border-neutral-200 sm:col-span-6"
}
> >
<Table <Table
values={campaign.emails.map( values={campaign.emails.map(
@@ -956,17 +793,8 @@ export default function Index() {
return { return {
Email: e.contact.email, Email: e.contact.email,
Status: ( Status: (
<Badge <Badge type={e.status === "DELIVERED" ? "info" : e.status === "OPENED" ? "success" : "danger"}>
type={ {e.status.at(0)?.toUpperCase() + e.status.slice(1).toLowerCase()}
e.status === "DELIVERED"
? "info"
: e.status === "OPENED"
? "success"
: "danger"
}
>
{e.status.at(0)?.toUpperCase() +
e.status.slice(1).toLowerCase()}
</Badge> </Badge>
), ),
View: ( View: (
@@ -1005,9 +833,7 @@ export default function Index() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<div <div className={"ml-auto mt-6 flex justify-end gap-x-5 sm:col-span-6"}>
className={"ml-auto mt-6 flex justify-end gap-x-5 sm:col-span-6"}
>
{campaign.status === "DRAFT" ? ( {campaign.status === "DRAFT" ? (
<> <>
<motion.button <motion.button
@@ -1018,13 +844,7 @@ export default function Index() {
"ml-auto mt-6 flex items-center gap-x-0.5 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white" "ml-auto mt-6 flex items-center gap-x-0.5 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white"
} }
> >
<svg <svg width="24" height="24" className={"rotate-45 pb-1"} fill="none" viewBox="0 0 24 24">
width="24"
height="24"
className={"rotate-45 pb-1"}
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -1053,13 +873,7 @@ export default function Index() {
"ml-auto mt-6 flex items-center gap-x-0.5 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white" "ml-auto mt-6 flex items-center gap-x-0.5 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white"
} }
> >
<svg <svg width="24" height="24" className={"rotate-45 pb-1"} fill="none" viewBox="0 0 24 24">
width="24"
height="24"
className={"rotate-45 pb-1"}
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -52,38 +52,18 @@ export default function Index() {
</span> </span>
<div className="flex-1 truncate"> <div className="flex-1 truncate">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<h3 className="truncate text-lg font-bold text-neutral-800"> <h3 className="truncate text-lg font-bold text-neutral-800">{c.subject}</h3>
{c.subject}
</h3>
</div> </div>
<div className={"mb-6"}> <div className={"mb-6"}>
<h2 <h2 className={"text col-span-2 truncate font-semibold text-neutral-700"}>Quick Stats</h2>
className={
"text col-span-2 truncate font-semibold text-neutral-700"
}
>
Quick Stats
</h2>
<div className={"grid grid-cols-2 gap-3"}> <div className={"grid grid-cols-2 gap-3"}>
{c.status === "DELIVERED" ? ( {c.status === "DELIVERED" ? (
<> <>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Open rate</label>
className={
"text-xs font-medium text-neutral-500"
}
>
Open rate
</label>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">
{c.emails.length > 0 {c.emails.length > 0
? Math.round( ? Math.round((c.emails.filter((e) => e.status === "OPENED").length / c.emails.length) * 100)
(c.emails.filter(
(e) => e.status === "OPENED",
).length /
c.emails.length) *
100,
)
: 0} : 0}
% %
</p> </p>
@@ -91,81 +71,37 @@ export default function Index() {
{c.tasks.length > 0 && ( {c.tasks.length > 0 && (
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Emails in queue</label>
className={ <p className="mt-1 truncate text-sm text-neutral-500">{c.tasks.length}</p>
"text-xs font-medium text-neutral-500"
}
>
Emails in queue
</label>
<p className="mt-1 truncate text-sm text-neutral-500">
{c.tasks.length}
</p>
</div> </div>
)} )}
</> </>
) : ( ) : (
<> <>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Open rate</label>
className={ <p className="mt-1 truncate text-sm text-neutral-500">Awaiting delivery</p>
"text-xs font-medium text-neutral-500"
}
>
Open rate
</label>
<p className="mt-1 truncate text-sm text-neutral-500">
Awaiting delivery
</p>
</div> </div>
</> </>
)} )}
</div> </div>
</div> </div>
<div className={"my-4"}> <div className={"my-4"}>
<h2 <h2 className={"col-span-2 truncate font-semibold text-neutral-700"}>Properties</h2>
className={
"col-span-2 truncate font-semibold text-neutral-700"
}
>
Properties
</h2>
<div className={"grid grid-cols-2 gap-3"}> <div className={"grid grid-cols-2 gap-3"}>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Recipients</label>
className={ <p className="mt-1 truncate text-sm text-neutral-500">{c.recipients.length}</p>
"text-xs font-medium text-neutral-500"
}
>
Recipients
</label>
<p className="mt-1 truncate text-sm text-neutral-500">
{c.recipients.length}
</p>
</div> </div>
<div> <div>
<label <label className={"text-xs font-medium text-neutral-500"}>Status</label>
className={
"text-xs font-medium text-neutral-500"
}
>
Status
</label>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">
{c.status === "DRAFT" ? ( {c.status === "DRAFT" ? (
<Badge type={"info"}>Draft</Badge> <Badge type={"info"}>Draft</Badge>
) : ( ) : (
<Badge <Badge type={c.tasks.length > 0 ? "info" : "success"}>
type={ {c.tasks.length > 0 ? "Sending" : "Delivered"}
c.tasks.length > 0
? "info"
: "success"
}
>
{c.tasks.length > 0
? "Sending"
: "Delivered"}
</Badge> </Badge>
)} )}
</p> </p>
@@ -181,12 +117,7 @@ export default function Index() {
href={`/campaigns/${c.id}`} href={`/campaigns/${c.id}`}
className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700" className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
{c.status === "DELIVERED" ? ( {c.status === "DELIVERED" ? (
<> <>
<path <path
@@ -226,9 +157,7 @@ export default function Index() {
)} )}
</svg> </svg>
<span className="ml-3"> <span className="ml-3">{c.status === "DELIVERED" ? "View" : "Edit"}</span>
{c.status === "DELIVERED" ? "View" : "Edit"}
</span>
</Link> </Link>
</div> </div>
</div> </div>
@@ -240,12 +169,7 @@ export default function Index() {
</div> </div>
</> </>
) : ( ) : (
<Empty <Empty title={"No campaigns found"} description={"Send your contacts emails in bulk with a few clicks"} />
title={"No campaigns found"}
description={
"Send your contacts emails in bulk with a few clicks"
}
/>
) )
) : ( ) : (
<Skeleton type={"table"} /> <Skeleton type={"table"} />
+42 -156
View File
@@ -9,15 +9,7 @@ import { useRouter } from "next/router";
import React, { useState } from "react"; import React, { useState } from "react";
import { type FieldError, useForm } from "react-hook-form"; import { type FieldError, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Alert, Card, Dropdown, Editor, FullscreenLoader, Input, MultiselectDropdown } from "../../components";
Alert,
Card,
Dropdown,
Editor,
FullscreenLoader,
Input,
MultiselectDropdown,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useCampaigns } from "../../lib/hooks/campaigns"; import { useCampaigns } from "../../lib/hooks/campaigns";
import { useContacts } from "../../lib/hooks/contacts"; import { useContacts } from "../../lib/hooks/contacts";
@@ -58,7 +50,6 @@ export default function Index() {
notevents?: string[]; notevents?: string[];
notlast?: "day" | "week" | "month"; notlast?: "day" | "week" | "month";
}>({}); }>({});
const [paymentModal, setPaymentModal] = useState(false);
const [advancedSelector, setSelector] = useState(false); const [advancedSelector, setSelector] = useState(false);
const { const {
@@ -89,9 +80,7 @@ export default function Index() {
if (query.events && query.events.length > 0) { if (query.events && query.events.length > 0) {
query.events.map((e) => { query.events.map((e) => {
filteredContacts = filteredContacts.filter((c) => filteredContacts = filteredContacts.filter((c) => c.triggers.some((t) => t.eventId === e));
c.triggers.some((t) => t.eventId === e),
);
}); });
} }
@@ -101,17 +90,13 @@ export default function Index() {
return false; return false;
} }
const lastTrigger = c.triggers.sort((a, b) => const lastTrigger = c.triggers.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
a.createdAt > b.createdAt ? -1 : 1,
);
if (lastTrigger.length === 0) { if (lastTrigger.length === 0) {
return false; return false;
} }
return dayjs(lastTrigger[0].createdAt).isAfter( return dayjs(lastTrigger[0].createdAt).isAfter(dayjs().subtract(1, query.last));
dayjs().subtract(1, query.last),
);
}); });
} }
@@ -122,24 +107,18 @@ export default function Index() {
return true; return true;
} }
const lastTrigger = c.triggers const lastTrigger = c.triggers.filter((t) => t.eventId === e).sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
.filter((t) => t.eventId === e)
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
if (lastTrigger.length === 0) { if (lastTrigger.length === 0) {
return true; return true;
} }
return dayjs(lastTrigger[0].createdAt).isAfter( return dayjs(lastTrigger[0].createdAt).isAfter(dayjs().subtract(1, query.last));
dayjs().subtract(1, query.last),
);
}); });
}); });
} else if (query.notevents && query.notevents.length > 0) { } else if (query.notevents && query.notevents.length > 0) {
query.notevents.map((e) => { query.notevents.map((e) => {
filteredContacts = filteredContacts.filter((c) => filteredContacts = filteredContacts.filter((c) => c.triggers.every((t) => t.eventId !== e));
c.triggers.every((t) => t.eventId !== e),
);
}); });
} else if (query.notlast) { } else if (query.notlast) {
filteredContacts = filteredContacts.filter((c) => { filteredContacts = filteredContacts.filter((c) => {
@@ -147,17 +126,13 @@ export default function Index() {
return true; return true;
} }
const lastTrigger = c.triggers.sort((a, b) => const lastTrigger = c.triggers.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
a.createdAt > b.createdAt ? -1 : 1,
);
if (lastTrigger.length === 0) { if (lastTrigger.length === 0) {
return true; return true;
} }
return !dayjs(lastTrigger[0].createdAt).isAfter( return !dayjs(lastTrigger[0].createdAt).isAfter(dayjs().subtract(1, query.notlast));
dayjs().subtract(1, query.notlast),
);
}); });
} }
@@ -195,8 +170,7 @@ export default function Index() {
project.secret, project.secret,
"POST", "POST",
"/v1/campaigns", "/v1/campaigns",
data.recipients.length === data.recipients.length === contacts?.contacts.filter((c) => c.subscribed).length
contacts?.contacts.filter((c) => c.subscribed).length
? { ...data, recipients: ["all"] } ? { ...data, recipients: ["all"] }
: { : {
...data, ...data,
@@ -219,10 +193,7 @@ export default function Index() {
<> <>
<Dashboard> <Dashboard>
<Card title={"Create a new campaign"}> <Card title={"Create a new campaign"}>
<form <form onSubmit={handleSubmit(create)} className="space-6 grid gap-6 sm:grid-cols-6">
onSubmit={handleSubmit(create)}
className="space-6 grid gap-6 sm:grid-cols-6"
>
<Input <Input
className={"sm:col-span-6"} className={"sm:col-span-6"}
label={"Subject"} label={"Subject"}
@@ -233,10 +204,7 @@ export default function Index() {
{contacts ? ( {contacts ? (
<> <>
<div className={"sm:col-span-3"}> <div className={"sm:col-span-3"}>
<label <label htmlFor={"recipients"} className="block text-sm font-medium text-neutral-700">
htmlFor={"recipients"}
className="block text-sm font-medium text-neutral-700"
>
Recipients Recipients
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -273,23 +241,15 @@ export default function Index() {
setValue( setValue(
"recipients", "recipients",
contacts.contacts contacts.contacts.filter((c) => c.subscribed).map((c) => c.id),
.filter((c) => c.subscribed)
.map((c) => c.id),
); );
}} }}
className={ className={
"mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100" "mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100"
} }
> >
{watch("recipients").length === 0 ? ( {watch("recipients").length === 0 ? <Users2 size={18} /> : <XIcon size={18} />}
<Users2 size={18} /> {watch("recipients").length === 0 ? "All contacts" : "Clear selection"}
) : (
<XIcon size={18} />
)}
{watch("recipients").length === 0
? "All contacts"
: "Clear selection"}
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -300,11 +260,7 @@ export default function Index() {
"mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100" "mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100"
} }
> >
{advancedSelector ? ( {advancedSelector ? <XIcon size={18} /> : <Search size={18} />}
<XIcon size={18} />
) : (
<Search size={18} />
)}
{advancedSelector ? "Close" : "Advanced selector"} {advancedSelector ? "Close" : "Advanced selector"}
</button> </button>
</div> </div>
@@ -321,10 +277,7 @@ export default function Index() {
} }
> >
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
Has triggers for events Has triggers for events
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -350,10 +303,7 @@ export default function Index() {
return 1; return 1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -364,11 +314,7 @@ export default function Index() {
name: e.name, name: e.name,
value: e.id, value: e.id,
tag: tag:
e.templateId ?? e.campaignId e.templateId ?? e.campaignId ? (e.name.includes("opened") ? "On Open" : "On Delivery") : undefined,
? e.name.includes("opened")
? "On Open"
: "On Delivery"
: undefined,
}; };
}), }),
]} ]}
@@ -379,21 +325,14 @@ export default function Index() {
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
{query.events && query.events.length > 0 && ( {query.events && query.events.length > 0 && (
<> <>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"} Has triggered {query.events.length} selected events
className="block text-sm font-medium text-neutral-700"
>
Has triggered {query.events.length} selected
events
</label> </label>
<Dropdown <Dropdown
onChange={(e) => onChange={(e) =>
setQuery({ setQuery({
...query, ...query,
last: last: (e as "" | "day" | "week" | "month") === "" ? undefined : (e as "day" | "week" | "month"),
(e as "" | "day" | "week" | "month") === ""
? undefined
: (e as "day" | "week" | "month"),
}) })
} }
values={[ values={[
@@ -409,10 +348,7 @@ export default function Index() {
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
No triggers for events No triggers for events
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -438,10 +374,7 @@ export default function Index() {
return 1; return 1;
} }
if ( if (a.name.includes("delivered") && !b.name.includes("delivered")) {
a.name.includes("delivered") &&
!b.name.includes("delivered")
) {
return -1; return -1;
} }
@@ -452,11 +385,7 @@ export default function Index() {
name: e.name, name: e.name,
value: e.id, value: e.id,
tag: tag:
e.templateId ?? e.campaignId e.templateId ?? e.campaignId ? (e.name.includes("opened") ? "On Open" : "On Delivery") : undefined,
? e.name.includes("opened")
? "On Open"
: "On Delivery"
: undefined,
}; };
}), }),
]} ]}
@@ -467,21 +396,14 @@ export default function Index() {
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
{query.notevents && query.notevents.length > 0 && ( {query.notevents && query.notevents.length > 0 && (
<> <>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"} Not triggered {query.notevents.length} selected events
className="block text-sm font-medium text-neutral-700"
>
Not triggered {query.notevents.length} selected
events
</label> </label>
<Dropdown <Dropdown
onChange={(e) => onChange={(e) =>
setQuery({ setQuery({
...query, ...query,
notlast: notlast: (e as "" | "day" | "week" | "month") === "" ? undefined : (e as "day" | "week" | "month"),
(e as "" | "day" | "week" | "month") === ""
? undefined
: (e as "day" | "week" | "month"),
}) })
} }
values={[ values={[
@@ -497,10 +419,7 @@ export default function Index() {
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
All contacts with parameter All contacts with parameter
</label> </label>
<Dropdown <Dropdown
@@ -516,15 +435,11 @@ export default function Index() {
contacts.contacts contacts.contacts
.filter((c) => c.data) .filter((c) => c.data)
.map((c) => { .map((c) => {
return Object.keys( return Object.keys(JSON.parse(c.data ?? "{}"));
JSON.parse(c.data ?? "{}"),
);
}) })
.reduce((acc, val) => acc.concat(val), []), .reduce((acc, val) => acc.concat(val), []),
), ),
].map((k) => ].map((k) => (typeof k === "string" ? { name: k, value: k } : k))}
typeof k === "string" ? { name: k, value: k } : k,
)}
selectedValue={query.data ?? ""} selectedValue={query.data ?? ""}
/> />
</div> </div>
@@ -532,10 +447,7 @@ export default function Index() {
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
{query.data && ( {query.data && (
<> <>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
All contacts where parameter {query.data} is All contacts where parameter {query.data} is
</label> </label>
@@ -550,15 +462,9 @@ export default function Index() {
{ name: "Any value", value: "" }, { name: "Any value", value: "" },
...new Set( ...new Set(
contacts.contacts contacts.contacts
.filter( .filter((c) => c.data && JSON.parse(c.data)[query.data ?? ""])
(c) =>
c.data &&
JSON.parse(c.data)[query.data ?? ""],
)
.map((c) => { .map((c) => {
return JSON.parse(c.data ?? "{}")[ return JSON.parse(c.data ?? "{}")[query.data ?? ""];
query.data ?? ""
];
}) })
.reduce((acc, val) => acc.concat(val), []), .reduce((acc, val) => acc.concat(val), []),
), ),
@@ -591,12 +497,7 @@ export default function Index() {
"ml-auto flex items-center justify-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white" "ml-auto flex items-center justify-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
} }
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -621,20 +522,13 @@ export default function Index() {
</> </>
) : ( ) : (
<> <>
<div <div className={"flex items-center gap-6 rounded border border-neutral-300 px-8 py-3 sm:col-span-6"}>
className={
"flex items-center gap-6 rounded border border-neutral-300 px-8 py-3 sm:col-span-6"
}
>
<Ring size={20} /> <Ring size={20} />
<div> <div>
<h1 className={"text-lg font-semibold text-neutral-800"}> <h1 className={"text-lg font-semibold text-neutral-800"}>Hang on!</h1>
Hang on!
</h1>
<p className={"text-sm text-neutral-600"}> <p className={"text-sm text-neutral-600"}>
We're still loading your contacts. This might take up to a We're still loading your contacts. This might take up to a minute. You can already start writing your
minute. You can already start writing your campaign in the campaign in the editor below.
editor below.
</p> </p>
</div> </div>
</div> </div>
@@ -650,14 +544,8 @@ export default function Index() {
className={"relative z-10 sm:col-span-6"} className={"relative z-10 sm:col-span-6"}
> >
<Alert type={"info"} title={"Automatic batching"}> <Alert type={"info"} title={"Automatic batching"}>
Your campaign will be sent out in batches of 80 recipients Your campaign will be sent out in batches of 80 recipients each. It will be delivered to all contacts{" "}
each. It will be delivered to all contacts{" "} {dayjs().to(dayjs().add(Math.ceil(watch("recipients").length / 80), "minutes"))}
{dayjs().to(
dayjs().add(
Math.ceil(watch("recipients").length / 80),
"minutes",
),
)}
</Alert> </Alert>
</motion.div> </motion.div>
)} )}
@@ -687,9 +575,7 @@ export default function Index() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<div <div className={"ml-auto mt-6 flex justify-end gap-3 sm:col-span-6"}>
className={"ml-auto mt-6 flex justify-end gap-3 sm:col-span-6"}
>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
+54 -227
View File
@@ -2,11 +2,7 @@
// React Hook Form messes up our types, ignore the entire file // React Hook Form messes up our types, ignore the entire file
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import { ContactSchemas, EventSchemas, type UtilitySchemas } from "@plunk/shared";
ContactSchemas,
EventSchemas,
type UtilitySchemas,
} from "@plunk/shared";
import type { Contact, Email, Template } from "@prisma/client"; import type { Contact, Email, Template } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@@ -16,14 +12,7 @@ import React, { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { import { Card, Empty, FullscreenLoader, Input, Modal, Toggle } from "../../components";
Card,
Empty,
FullscreenLoader,
Input,
Modal,
Toggle,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useContact } from "../../lib/hooks/contacts"; import { useContact } from "../../lib/hooks/contacts";
import { useActiveProject } from "../../lib/hooks/projects"; import { useActiveProject } from "../../lib/hooks/projects";
@@ -67,11 +56,9 @@ export default function Index() {
reset: dataReset, reset: dataReset,
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map( data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map(([key]) => ({
([key]) => ({ value: { key },
value: { key }, })),
}),
),
}, },
resolver: zodResolver( resolver: zodResolver(
z.object({ z.object({
@@ -86,11 +73,7 @@ export default function Index() {
), ),
}); });
const { const { fields, append: fieldAppend, remove: fieldRemove } = useFieldArray({ control, name: "data" });
fields,
append: fieldAppend,
remove: fieldRemove,
} = useFieldArray({ control, name: "data" });
const { const {
register: eventRegister, register: eventRegister,
@@ -108,11 +91,9 @@ export default function Index() {
reset(contact); reset(contact);
dataReset({ dataReset({
data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map( data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map(([key, value]) => ({
([key, value]) => ({ value: { key, value },
value: { key, value }, })),
}),
),
}); });
}, [dataReset, reset, contact]); }, [dataReset, reset, contact]);
@@ -122,15 +103,10 @@ export default function Index() {
const create = (data: EventValues) => { const create = (data: EventValues) => {
toast.promise( toast.promise(
network.mock<Template, typeof EventSchemas.post>( network.mock<Template, typeof EventSchemas.post>(project.secret, "POST", "/v1", {
project.secret, ...data,
"POST", email: contact.email,
"/v1", }),
{
...data,
email: contact.email,
},
),
{ {
loading: "Creating new event", loading: "Creating new event",
success: () => { success: () => {
@@ -146,31 +122,21 @@ export default function Index() {
}; };
const update = (data: ContactValues) => { const update = (data: ContactValues) => {
const entries = getDataValues().data.map(({ value }) => [ const entries = getDataValues().data.map(({ value }) => [value.key, value.value]);
value.key,
value.value,
]);
let dataObject = {}; let dataObject = {};
entries.forEach(([key, value]) => { entries.forEach(([key, value]) => {
Object.assign(dataObject, { [key]: value }); Object.assign(dataObject, { [key]: value });
}); });
dataObject = Object.fromEntries( dataObject = Object.fromEntries(Object.entries(dataObject).filter(([, value]) => value !== ""));
Object.entries(dataObject).filter(([, value]) => value !== ""),
);
toast.promise( toast.promise(
network.mock<Contact, typeof ContactSchemas.update>( network.mock<Contact, typeof ContactSchemas.update>(project.secret, "PUT", "/v1/contacts", {
project.secret, id: contact.id,
"PUT", ...data,
"/v1/contacts", data: dataObject,
{ }),
id: contact.id,
...data,
data: dataObject,
},
),
{ {
loading: "Saving your changes", loading: "Saving your changes",
success: () => { success: () => {
@@ -185,14 +151,9 @@ export default function Index() {
const remove = async (e: { preventDefault: () => void }) => { const remove = async (e: { preventDefault: () => void }) => {
e.preventDefault(); e.preventDefault();
toast.promise( toast.promise(
network.mock<Contact, typeof UtilitySchemas.id>( network.mock<Contact, typeof UtilitySchemas.id>(project.secret, "DELETE", "/v1/contacts", {
project.secret, id: contact.id,
"DELETE", }),
"/v1/contacts",
{
id: contact.id,
},
),
{ {
loading: "Deleting contact", loading: "Deleting contact",
success: "Deleted contact", success: "Deleted contact",
@@ -215,24 +176,12 @@ export default function Index() {
description={`Trigger an event for ${contact.email}`} description={`Trigger an event for ${contact.email}`}
icon={ icon={
<> <>
<rect <rect strokeWidth={2} width="14.5" height="14.5" x="4.75" y="4.75" rx="2" />
strokeWidth={2}
width="14.5"
height="14.5"
x="4.75"
y="4.75"
rx="2"
/>
<path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" /> <path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" />
</> </>
} }
> >
<Input <Input register={eventRegister("event")} label={"Event"} placeholder={"signup"} error={eventErrors.event} />
register={eventRegister("event")}
label={"Event"}
placeholder={"signup"}
error={eventErrors.event}
/>
</Modal> </Modal>
<Dashboard> <Dashboard>
<Card <Card
@@ -288,28 +237,17 @@ export default function Index() {
strokeWidth="1.5" strokeWidth="1.5"
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5" d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
/> />
<path <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M5 7.75H19" />
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M5 7.75H19"
/>
</svg> </svg>
Delete Delete
</button> </button>
</> </>
} }
> >
<form <form onSubmit={handleSubmit(update)} className="grid gap-x-5 space-y-9 sm:grid-cols-2">
onSubmit={handleSubmit(update)}
className="grid gap-x-5 space-y-9 sm:grid-cols-2"
>
<div className={"col-span-2 flex items-center gap-6"}> <div className={"col-span-2 flex items-center gap-6"}>
<span className="inline-flex h-20 w-20 items-center justify-center rounded-full bg-neutral-100"> <span className="inline-flex h-20 w-20 items-center justify-center rounded-full bg-neutral-100">
<span className="text-xl font-semibold leading-none text-neutral-800"> <span className="text-xl font-semibold leading-none text-neutral-800">{contact.email[0].toUpperCase()}</span>
{contact.email[0].toUpperCase()}
</span>
</span> </span>
<h1 className={"text-2xl font-semibold text-neutral-800"}> <h1 className={"text-2xl font-semibold text-neutral-800"}>
{contact.email[0].toUpperCase()} {contact.email[0].toUpperCase()}
@@ -319,10 +257,7 @@ export default function Index() {
<div className={"grid sm:col-span-2"}> <div className={"grid sm:col-span-2"}>
<div className={"grid items-center gap-3 sm:grid-cols-9"}> <div className={"grid items-center gap-3 sm:grid-cols-9"}>
<label <label htmlFor={"data"} className="block text-sm font-medium text-neutral-700 sm:col-span-8">
htmlFor={"data"}
className="block text-sm font-medium text-neutral-700 sm:col-span-8"
>
Metadata Metadata
</label> </label>
<button <button
@@ -361,10 +296,7 @@ export default function Index() {
<div key={field.id}> <div key={field.id}>
<div className="grid w-full grid-cols-9 items-end gap-3"> <div className="grid w-full grid-cols-9 items-end gap-3">
<div className={"col-span-4"}> <div className={"col-span-4"}>
<label <label htmlFor={"data"} className="text-xs font-light">
htmlFor={"data"}
className="text-xs font-light"
>
Key Key
</label> </label>
<input <input
@@ -379,10 +311,7 @@ export default function Index() {
</div> </div>
<div className={"col-span-4"}> <div className={"col-span-4"}>
<label <label htmlFor={"data"} className="text-xs font-light">
htmlFor={"data"}
className="text-xs font-light"
>
Value Value
</label> </label>
<input <input
@@ -404,12 +333,7 @@ export default function Index() {
fieldRemove(index); fieldRemove(index);
}} }}
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -475,11 +399,7 @@ export default function Index() {
<div className="scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-neutral-100 scrollbar-thumb-rounded-full scrollbar-track-rounded-full flow-root h-96 max-h-96 overflow-y-auto pr-6"> <div className="scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-neutral-100 scrollbar-thumb-rounded-full scrollbar-track-rounded-full flow-root h-96 max-h-96 overflow-y-auto pr-6">
<ul className="-mb-8"> <ul className="-mb-8">
{[...contact.triggers, ...contact.emails] {[...contact.triggers, ...contact.emails]
.sort( .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)
.map((t, index) => { .map((t, index) => {
if (t.messageId) { if (t.messageId) {
const email = t as Email; const email = t as Email;
@@ -487,14 +407,8 @@ export default function Index() {
return ( return (
<li> <li>
<div className="relative pb-8"> <div className="relative pb-8">
{contact.triggers.length + {contact.triggers.length + contact.emails.length - 1 !== index && (
contact.emails.length - <span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200" aria-hidden="true" />
1 !==
index && (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
aria-hidden="true"
/>
)} )}
<div className="relative flex space-x-3"> <div className="relative flex space-x-3">
@@ -511,18 +425,8 @@ export default function Index() {
strokeLinejoin="round" strokeLinejoin="round"
> >
<> <>
<path <path stroke="none" d="M0 0h24v24H0z" fill="none" />
stroke="none" <rect x="3" y="5" width="18" height="14" rx="2" />
d="M0 0h24v24H0z"
fill="none"
/>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
/>
<polyline points="3 7 12 13 21 7" /> <polyline points="3 7 12 13 21 7" />
</> </>
</svg> </svg>
@@ -530,19 +434,10 @@ export default function Index() {
</div> </div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div> <div>
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">Transactional email {email.subject} delivered</p>
Transactional email {email.subject}{" "}
delivered
</p>
</div> </div>
<div className="whitespace-nowrap text-right text-sm text-neutral-500"> <div className="whitespace-nowrap text-right text-sm text-neutral-500">
<time <time dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time>
dateTime={dayjs(t.createdAt).format(
"YYYY-MM-DD",
)}
>
{dayjs().to(t.createdAt)}
</time>
</div> </div>
</div> </div>
</div> </div>
@@ -555,14 +450,8 @@ export default function Index() {
return ( return (
<li> <li>
<div className="relative pb-8"> <div className="relative pb-8">
{contact.triggers.length + {contact.triggers.length + contact.emails.length - 1 !== index && (
contact.emails.length - <span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200" aria-hidden="true" />
1 !==
index && (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
aria-hidden="true"
/>
)} )}
<div className="relative flex space-x-3"> <div className="relative flex space-x-3">
@@ -588,18 +477,10 @@ export default function Index() {
</div> </div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div> <div>
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">{t.action.name} triggered</p>
{t.action.name} triggered
</p>
</div> </div>
<div className="whitespace-nowrap text-right text-sm text-neutral-500"> <div className="whitespace-nowrap text-right text-sm text-neutral-500">
<time <time dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time>
dateTime={dayjs(t.createdAt).format(
"YYYY-MM-DD",
)}
>
{dayjs().to(t.createdAt)}
</time>
</div> </div>
</div> </div>
</div> </div>
@@ -612,14 +493,8 @@ export default function Index() {
return ( return (
<li> <li>
<div className="relative pb-8"> <div className="relative pb-8">
{contact.triggers.length + {contact.triggers.length + contact.emails.length - 1 !== index && (
contact.emails.length - <span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200" aria-hidden="true" />
1 !==
index && (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
aria-hidden="true"
/>
)} )}
<div className="relative flex space-x-3"> <div className="relative flex space-x-3">
<div> <div>
@@ -637,36 +512,17 @@ export default function Index() {
> >
{t.event.name.includes("delivered") ? ( {t.event.name.includes("delivered") ? (
<> <>
<path <path stroke="none" d="M0 0h24v24H0z" fill="none" />
stroke="none" <rect x="3" y="5" width="18" height="14" rx="2" />
d="M0 0h24v24H0z"
fill="none"
/>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
/>
<polyline points="3 7 12 13 21 7" /> <polyline points="3 7 12 13 21 7" />
</> </>
) : ( ) : (
<> <>
<path <path stroke="none" d="M0 0h24v24H0z" fill="none" />
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<polyline points="3 9 12 15 21 9 12 3 3 9" /> <polyline points="3 9 12 15 21 9 12 3 3 9" />
<path d="M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10" /> <path d="M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10" />
<line x1="3" y1="19" x2="9" y2="13" /> <line x1="3" y1="19" x2="9" y2="13" />
<line <line x1="15" y1="13" x2="21" y2="19" />
x1="15"
y1="13"
x2="21"
y2="19"
/>
</> </>
)} )}
</svg> </svg>
@@ -685,20 +541,8 @@ export default function Index() {
<path d="M13 9h5" /> <path d="M13 9h5" />
<path d="M13 15h8" /> <path d="M13 15h8" />
<path d="M13 19h5" /> <path d="M13 19h5" />
<rect <rect x="3" y="4" width="6" height="6" rx="1" />
x="3" <rect x="3" y="14" width="6" height="6" rx="1" />
y="4"
width="6"
height="6"
rx="1"
/>
<rect
x="3"
y="14"
width="6"
height="6"
rx="1"
/>
</svg> </svg>
) : ( ) : (
<svg <svg
@@ -713,13 +557,7 @@ export default function Index() {
> >
<path d="M8 9l3 3l-3 3" /> <path d="M8 9l3 3l-3 3" />
<line x1="13" y1="15" x2="16" y2="15" /> <line x1="13" y1="15" x2="16" y2="15" />
<rect <rect x="3" y="4" width="18" height="16" rx="2" />
x="3"
y="4"
width="18"
height="16"
rx="2"
/>
</svg> </svg>
)} )}
</span> </span>
@@ -745,13 +583,7 @@ export default function Index() {
</p> </p>
</div> </div>
<div className="whitespace-nowrap text-right text-sm text-neutral-500"> <div className="whitespace-nowrap text-right text-sm text-neutral-500">
<time <time dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time>
dateTime={dayjs(t.createdAt).format(
"YYYY-MM-DD",
)}
>
{dayjs().to(t.createdAt)}
</time>
</div> </div>
</div> </div>
</div> </div>
@@ -763,12 +595,7 @@ export default function Index() {
</ul> </ul>
</div> </div>
) : ( ) : (
<Empty <Empty title={"No triggers"} description={"This contact has not yet triggered any events or actions"} />
title={"No triggers"}
description={
"This contact has not yet triggered any events or actions"
}
/>
)} )}
</Card> </Card>
</Dashboard> </Dashboard>
+25 -91
View File
@@ -9,15 +9,7 @@ import React, { useState } from "react";
import { type FieldError, useFieldArray, useForm } from "react-hook-form"; import { type FieldError, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { import { Card, Empty, FullscreenLoader, Modal, Skeleton, Table, Toggle } from "../../components";
Card,
Empty,
FullscreenLoader,
Modal,
Skeleton,
Table,
Toggle,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { searchContacts, useContacts } from "../../lib/hooks/contacts"; import { searchContacts, useContacts } from "../../lib/hooks/contacts";
import { useActiveProject } from "../../lib/hooks/projects"; import { useActiveProject } from "../../lib/hooks/projects";
@@ -27,7 +19,6 @@ import { network } from "../../lib/network";
interface ContactValues { interface ContactValues {
email: string; email: string;
data?: data?:
| undefined
| { | {
[x: string]: string | string[]; [x: string]: string | string[];
} }
@@ -86,42 +77,28 @@ export default function Index() {
}, },
}); });
const { const { fields, append: fieldAppend, remove: fieldRemove } = useFieldArray({ control, name: "data" });
fields,
append: fieldAppend,
remove: fieldRemove,
} = useFieldArray({ control, name: "data" });
if (!project || !user) { if (!project || !user) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
const create = (data: ContactValues) => { const create = (data: ContactValues) => {
const entries = getDataValues().data.map(({ value }) => [ const entries = getDataValues().data.map(({ value }) => [value.key, value.value]);
value.key,
value.value,
]);
let dataObject = {}; let dataObject = {};
entries.forEach(([key, value]) => { entries.forEach(([key, value]) => {
Object.assign(dataObject, { [key]: value }); Object.assign(dataObject, { [key]: value });
}); });
dataObject = Object.fromEntries( dataObject = Object.fromEntries(Object.entries(dataObject).filter(([, value]) => value !== ""));
Object.entries(dataObject).filter(([, value]) => value !== ""),
);
toast.promise( toast.promise(
network.mock<Template, typeof ContactSchemas.create>( network.mock<Template, typeof ContactSchemas.create>(project.secret, "POST", "/v1/contacts", {
project.secret, ...data,
"POST", subscribed: true,
"/v1/contacts", data: dataObject,
{ }),
...data,
subscribed: true,
data: dataObject,
},
),
{ {
loading: "Creating new contact", loading: "Creating new contact",
success: () => { success: () => {
@@ -155,15 +132,9 @@ export default function Index() {
<Table <Table
values={search.contacts values={search.contacts
.sort((a, b) => { .sort((a, b) => {
const aTrigger = const aTrigger = a.triggers.length > 0 ? a.triggers.sort()[0].createdAt : a.createdAt;
a.triggers.length > 0
? a.triggers.sort()[0].createdAt
: a.createdAt;
const bTrigger = const bTrigger = b.triggers.length > 0 ? b.triggers.sort()[0].createdAt : b.createdAt;
b.triggers.length > 0
? b.triggers.sort()[0].createdAt
: b.createdAt;
return bTrigger > aTrigger ? 1 : -1; return bTrigger > aTrigger ? 1 : -1;
}) })
@@ -181,10 +152,7 @@ export default function Index() {
.toString(), .toString(),
Subscribed: u.subscribed, Subscribed: u.subscribed,
Edit: ( Edit: (
<Link <Link href={`/contacts/${u.id}`} className={"transition hover:text-neutral-800"}>
href={`/contacts/${u.id}`}
className={"transition hover:text-neutral-800"}
>
<Edit2 size={18} /> <Edit2 size={18} />
</Link> </Link>
), ),
@@ -222,15 +190,9 @@ export default function Index() {
<Table <Table
values={contacts.contacts values={contacts.contacts
.sort((a, b) => { .sort((a, b) => {
const aTrigger = const aTrigger = a.triggers.length > 0 ? a.triggers.sort()[0].createdAt : a.createdAt;
a.triggers.length > 0
? a.triggers.sort()[0].createdAt
: a.createdAt;
const bTrigger = const bTrigger = b.triggers.length > 0 ? b.triggers.sort()[0].createdAt : b.createdAt;
b.triggers.length > 0
? b.triggers.sort()[0].createdAt
: b.createdAt;
return bTrigger > aTrigger ? 1 : -1; return bTrigger > aTrigger ? 1 : -1;
}) })
@@ -248,25 +210,19 @@ export default function Index() {
.toString(), .toString(),
Subscribed: u.subscribed, Subscribed: u.subscribed,
Edit: ( Edit: (
<Link <Link href={`/contacts/${u.id}`} className={"transition hover:text-neutral-800"}>
href={`/contacts/${u.id}`}
className={"transition hover:text-neutral-800"}
>
<Edit2 size={18} /> <Edit2 size={18} />
</Link> </Link>
), ),
}; };
})} })}
/> />
<nav <nav className="flex items-center justify-between py-3" aria-label="Pagination">
className="flex items-center justify-between py-3"
aria-label="Pagination"
>
<div className="hidden sm:block"> <div className="hidden sm:block">
<p className="text-sm text-neutral-700"> <p className="text-sm text-neutral-700">
Showing <span className="font-medium">{(page - 1) * 20}</span>{" "} Showing <span className="font-medium">{(page - 1) * 20}</span> to{" "}
to <span className="font-medium">{page * 20}</span> of{" "} <span className="font-medium">{page * 20}</span> of <span className="font-medium">{contacts.count}</span>{" "}
<span className="font-medium">{contacts.count}</span> contacts contacts
</p> </p>
</div> </div>
<div className="flex flex-1 justify-between gap-1 sm:justify-end"> <div className="flex flex-1 justify-between gap-1 sm:justify-end">
@@ -297,12 +253,7 @@ export default function Index() {
} }
return ( return (
<> <>
<Empty <Empty title={"No contacts"} description={"New contacts will automatically be added when they trigger an event"} />
title={"No contacts"}
description={
"New contacts will automatically be added when they trigger an event"
}
/>
</> </>
); );
} }
@@ -319,10 +270,7 @@ export default function Index() {
title={"Create new contact"} title={"Create new contact"}
> >
<div> <div>
<label <label htmlFor={"email"} className="block text-sm font-medium text-neutral-700">
htmlFor={"email"}
className="block text-sm font-medium text-neutral-700"
>
Email Email
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -353,10 +301,7 @@ export default function Index() {
<div className={"my-6"}> <div className={"my-6"}>
<div className={"grid sm:col-span-2"}> <div className={"grid sm:col-span-2"}>
<div className={"grid items-center gap-3 sm:grid-cols-9"}> <div className={"grid items-center gap-3 sm:grid-cols-9"}>
<label <label htmlFor={"data"} className="block text-sm font-medium text-neutral-700 sm:col-span-8">
htmlFor={"data"}
className="block text-sm font-medium text-neutral-700 sm:col-span-8"
>
Metadata Metadata
</label> </label>
<button <button
@@ -395,10 +340,7 @@ export default function Index() {
<div> <div>
<div className="grid w-full grid-cols-9 items-end gap-3"> <div className="grid w-full grid-cols-9 items-end gap-3">
<div className={"col-span-4"}> <div className={"col-span-4"}>
<label <label htmlFor={"data"} className="text-xs font-light">
htmlFor={"data"}
className="text-xs font-light"
>
Key Key
</label> </label>
<input <input
@@ -412,10 +354,7 @@ export default function Index() {
/> />
</div> </div>
<div className={"col-span-4"}> <div className={"col-span-4"}>
<label <label htmlFor={"data"} className="text-xs font-light">
htmlFor={"data"}
className="text-xs font-light"
>
Value Value
</label> </label>
<input <input
@@ -437,12 +376,7 @@ export default function Index() {
fieldRemove(index); fieldRemove(index);
}} }}
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
+35 -145
View File
@@ -9,17 +9,7 @@ import React, { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Alert, Badge, Card, Empty, FullscreenLoader, Input, Modal, Skeleton, Table } from "../../components";
Alert,
Badge,
Card,
Empty,
FullscreenLoader,
Input,
Modal,
Skeleton,
Table,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useContactsCount } from "../../lib/hooks/contacts"; import { useContactsCount } from "../../lib/hooks/contacts";
import { useEvents } from "../../lib/hooks/events"; import { useEvents } from "../../lib/hooks/events";
@@ -57,16 +47,11 @@ export default function Index() {
const create = (data: EventValues) => { const create = (data: EventValues) => {
toast.promise( toast.promise(
network.mock<Template, typeof EventSchemas.post>( network.mock<Template, typeof EventSchemas.post>(project.secret, "POST", "/v1", {
project.secret, ...data,
"POST", email: user.email,
"/v1", subscribed: true,
{ }),
...data,
email: user.email,
subscribed: true,
},
),
{ {
loading: "Creating new event", loading: "Creating new event",
success: () => { success: () => {
@@ -83,14 +68,9 @@ export default function Index() {
const remove = (id: string) => { const remove = (id: string) => {
toast.promise( toast.promise(
network.mock<Event, typeof UtilitySchemas.id>( network.mock<Event, typeof UtilitySchemas.id>(project.secret, "DELETE", "/v1/events", {
project.secret, id,
"DELETE", }),
"/v1/events",
{
id,
},
),
{ {
loading: "Deleting your event", loading: "Deleting your event",
success: () => { success: () => {
@@ -114,24 +94,12 @@ export default function Index() {
description={"Trigger a new event to send out emails to your contacts"} description={"Trigger a new event to send out emails to your contacts"}
icon={ icon={
<> <>
<rect <rect strokeWidth={2} width="14.5" height="14.5" x="4.75" y="4.75" rx="2" />
strokeWidth={2}
width="14.5"
height="14.5"
x="4.75"
y="4.75"
rx="2"
/>
<path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" /> <path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" />
</> </>
} }
> >
<Input <Input register={register("event")} label={"Event"} placeholder={"user-signup"} error={errors.event} />
register={register("event")}
label={"Event"}
placeholder={"user-signup"}
error={errors.event}
/>
</Modal> </Modal>
<Dashboard> <Dashboard>
@@ -139,8 +107,7 @@ export default function Index() {
<Alert type={"info"} title={"Need a hand?"}> <Alert type={"info"} title={"Need a hand?"}>
<div className={"mt-3 grid items-center sm:grid-cols-4"}> <div className={"mt-3 grid items-center sm:grid-cols-4"}>
<p className={"sm:col-span-3"}> <p className={"sm:col-span-3"}>
Want us to help you get started? We can help you build your Want us to help you get started? We can help you build your first action in less than 5 minutes.
first action in less than 5 minutes.
</p> </p>
<Link <Link
@@ -175,21 +142,14 @@ export default function Index() {
} }
> >
{events && contacts ? ( {events && contacts ? (
events.filter((event) => !event.templateId && !event.campaignId) events.filter((event) => !event.templateId && !event.campaignId).length > 0 ? (
.length > 0 ? (
<Table <Table
values={events values={events
.filter((event) => !event.templateId && !event.campaignId) .filter((event) => !event.templateId && !event.campaignId)
.sort((a, b) => { .sort((a, b) => {
const aTrigger = const aTrigger = a.triggers.length > 0 ? a.triggers.sort()[0].createdAt : a.createdAt;
a.triggers.length > 0
? a.triggers.sort()[0].createdAt
: a.createdAt;
const bTrigger = const bTrigger = b.triggers.length > 0 ? b.triggers.sort()[0].createdAt : b.createdAt;
b.triggers.length > 0
? b.triggers.sort()[0].createdAt
: b.createdAt;
return bTrigger > aTrigger ? 1 : -1; return bTrigger > aTrigger ? 1 : -1;
}) })
@@ -199,15 +159,7 @@ export default function Index() {
"Triggered by users": ( "Triggered by users": (
<Badge type={"info"}>{`${ <Badge type={"info"}>{`${
e.triggers.length > 0 e.triggers.length > 0
? Math.round( ? Math.round(([...new Map(e.triggers.map((t) => [t.contactId, t])).values()].length / contacts) * 100)
([
...new Map(
e.triggers.map((t) => [t.contactId, t]),
).values(),
].length /
contacts) *
100,
)
: 0 : 0
}%`}</Badge> }%`}</Badge>
), ),
@@ -221,9 +173,7 @@ export default function Index() {
data={Object.entries( data={Object.entries(
e.triggers.reduce( e.triggers.reduce(
(acc, cur) => { (acc, cur) => {
const date = dayjs(cur.createdAt).format( const date = dayjs(cur.createdAt).format("MM/YYYY");
"MM/YYYY",
);
if (acc[date]) { if (acc[date]) {
acc[date] += 1; acc[date] += 1;
@@ -256,41 +206,15 @@ export default function Index() {
}} }}
> >
<defs> <defs>
<linearGradient <linearGradient id="gradientFill" x1="0" y1="0" x2="0" y2="1">
id="gradientFill" <stop offset="100%" stopColor="#2563eb" stopOpacity={0.4} />
x1="0" <stop offset="100%" stopColor="#93c5fd" stopOpacity={0} />
y1="0"
x2="0"
y2="1"
>
<stop
offset="100%"
stopColor="#2563eb"
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor="#93c5fd"
stopOpacity={0}
/>
</linearGradient> </linearGradient>
</defs> </defs>
<YAxis <YAxis axisLine={false} fill={"#fff"} tickSize={0} width={5} interval={0} />
axisLine={false}
fill={"#fff"}
tickSize={0}
width={5}
interval={0}
/>
<Area <Area type="monotone" dataKey="count" stroke="#2563eb" fill="url(#gradientFill)" strokeWidth={2} />
type="monotone"
dataKey="count"
stroke="#2563eb"
fill="url(#gradientFill)"
strokeWidth={2}
/>
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</> </>
@@ -308,16 +232,11 @@ export default function Index() {
<button <button
onClick={() => { onClick={() => {
toast.promise( toast.promise(
network.mock<true, typeof EventSchemas.post>( network.mock<true, typeof EventSchemas.post>(project.secret, "POST", "/v1", {
project.secret, email: user.email,
"POST", event: e.name,
"/v1", subscribed: true,
{ }),
email: user.email,
event: e.name,
subscribed: true,
},
),
{ {
loading: "Creating new trigger", loading: "Creating new trigger",
success: () => { success: () => {
@@ -328,9 +247,7 @@ export default function Index() {
}, },
); );
}} }}
className={ className={"flex items-center text-center text-sm font-medium transition hover:text-neutral-800"}
"flex items-center text-center text-sm font-medium transition hover:text-neutral-800"
}
> >
<TerminalSquare size={18} /> <TerminalSquare size={18} />
</button> </button>
@@ -339,9 +256,7 @@ export default function Index() {
Remove: !e.templateId ? ( Remove: !e.templateId ? (
<button <button
onClick={() => remove(e.id)} onClick={() => remove(e.id)}
className={ className={"flex items-center text-center text-sm font-medium transition hover:text-neutral-800"}
"flex items-center text-center text-sm font-medium transition hover:text-neutral-800"
}
> >
<Trash size={18} /> <Trash size={18} />
</button> </button>
@@ -352,34 +267,22 @@ export default function Index() {
})} })}
/> />
) : ( ) : (
<Empty <Empty title={"No events"} description={"You have not yet posted an event to Plunk"} />
title={"No events"}
description={"You have not yet posted an event to Plunk"}
/>
) )
) : ( ) : (
<Skeleton type={"table"} /> <Skeleton type={"table"} />
)} )}
</Card> </Card>
<Card <Card title={"Template events"} description={"Events linked to your templates"}>
title={"Template events"}
description={"Events linked to your templates"}
>
{events && contacts ? ( {events && contacts ? (
events.filter((event) => event.templateId).length > 0 ? ( events.filter((event) => event.templateId).length > 0 ? (
<Table <Table
values={events values={events
.filter((event) => event.templateId) .filter((event) => event.templateId)
.sort((a, b) => { .sort((a, b) => {
const aTrigger = const aTrigger = a.triggers.length > 0 ? a.triggers.sort()[0].createdAt : a.createdAt;
a.triggers.length > 0
? a.triggers.sort()[0].createdAt
: a.createdAt;
const bTrigger = const bTrigger = b.triggers.length > 0 ? b.triggers.sort()[0].createdAt : b.createdAt;
b.triggers.length > 0
? b.triggers.sort()[0].createdAt
: b.createdAt;
return bTrigger > aTrigger ? 1 : -1; return bTrigger > aTrigger ? 1 : -1;
}) })
@@ -389,15 +292,7 @@ export default function Index() {
"Triggered by users": ( "Triggered by users": (
<Badge type={"info"}>{`${ <Badge type={"info"}>{`${
e.triggers.length > 0 e.triggers.length > 0
? Math.round( ? Math.round(([...new Map(e.triggers.map((t) => [t.contactId, t])).values()].length / contacts) * 100)
([
...new Map(
e.triggers.map((t) => [t.contactId, t]),
).values(),
].length /
contacts) *
100,
)
: 0 : 0
}%`}</Badge> }%`}</Badge>
), ),
@@ -415,12 +310,7 @@ export default function Index() {
})} })}
/> />
) : ( ) : (
<Empty <Empty title={"No template events"} description={"All delivery tracking for templates can be found here"} />
title={"No template events"}
description={
"All delivery tracking for templates can be found here"
}
/>
) )
) : ( ) : (
<Skeleton type={"table"} /> <Skeleton type={"table"} />
+16 -72
View File
@@ -2,21 +2,9 @@ import dayjs from "dayjs";
import { Book, Eye, Frown, LineChart, Send } from "lucide-react"; import { Book, Eye, Frown, LineChart, Send } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import React, { useState } from "react"; import React, { useState } from "react";
import { import { Badge, Card, Empty, FullscreenLoader, Redirect, Skeleton, Table } from "../components";
Badge,
Card,
Empty,
FullscreenLoader,
Redirect,
Skeleton,
Table,
} from "../components";
import { Dashboard } from "../layouts"; import { Dashboard } from "../layouts";
import { import { useActiveProject, useActiveProjectFeed, useProjects } from "../lib/hooks/projects";
useActiveProject,
useActiveProjectFeed,
useProjects,
} from "../lib/hooks/projects";
/** /**
* *
@@ -51,17 +39,12 @@ export default function Index() {
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
<Link <Link href={"/campaigns/new"} className="focus:outline-none">
href={"/campaigns/new"}
className="focus:outline-none"
>
<span className="absolute inset-0" aria-hidden="true" /> <span className="absolute inset-0" aria-hidden="true" />
Send a campaign Send a campaign
</Link> </Link>
</h3> </h3>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">Send a broadcast to your contacts</p>
Send a broadcast to your contacts
</p>
</div> </div>
</> </>
) : ( ) : (
@@ -105,17 +88,12 @@ export default function Index() {
<div className="mt-8"> <div className="mt-8">
<Badge type={"danger"}>Important</Badge> <Badge type={"danger"}>Important</Badge>
<h3 className="mt-3 text-lg font-medium"> <h3 className="mt-3 text-lg font-medium">
<Link <Link href={"/settings/identity"} className="focus:outline-none">
href={"/settings/identity"}
className="focus:outline-none"
>
<span className="absolute inset-0" aria-hidden="true" /> <span className="absolute inset-0" aria-hidden="true" />
Verify your domain Verify your domain
</Link> </Link>
</h3> </h3>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">Verify your domain before you send emails</p>
Verify your domain before you send emails
</p>
</div> </div>
</> </>
)} )}
@@ -124,12 +102,7 @@ export default function Index() {
className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400" className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400"
aria-hidden="true" aria-hidden="true"
> >
<svg <svg className="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" /> <path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" />
</svg> </svg>
</span> </span>
@@ -143,29 +116,18 @@ export default function Index() {
</div> </div>
<div className="mt-2 flex h-4/6 flex-col justify-end"> <div className="mt-2 flex h-4/6 flex-col justify-end">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
<Link <Link href={"/analytics"} passHref className="focus:outline-none">
href={"/analytics"}
passHref
className="focus:outline-none"
>
<span className="absolute inset-0" aria-hidden="true" /> <span className="absolute inset-0" aria-hidden="true" />
Analytics Analytics
</Link> </Link>
</h3> </h3>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">Discover insights about your emails</p>
Discover insights about your emails
</p>
</div> </div>
<span <span
className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400" className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400"
aria-hidden="true" aria-hidden="true"
> >
<svg <svg className="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" /> <path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" />
</svg> </svg>
</span> </span>
@@ -179,30 +141,18 @@ export default function Index() {
</div> </div>
<div className="mt-2 flex h-4/6 flex-col justify-end"> <div className="mt-2 flex h-4/6 flex-col justify-end">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
<a <a href={"https://docs.useplunk.com"} target={"_blank"} className="focus:outline-none" rel="noreferrer">
href={"https://docs.useplunk.com"}
target={"_blank"}
className="focus:outline-none"
rel="noreferrer"
>
<span className="absolute inset-0" aria-hidden="true" /> <span className="absolute inset-0" aria-hidden="true" />
Documentation Documentation
</a> </a>
</h3> </h3>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">Discover how to use Plunk</p>
Discover how to use Plunk
</p>
</div> </div>
<span <span
className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400" className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400"
aria-hidden="true" aria-hidden="true"
> >
<svg <svg className="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" /> <path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" />
</svg> </svg>
</span> </span>
@@ -216,9 +166,7 @@ export default function Index() {
<Empty <Empty
icon={<Frown size={24} />} icon={<Frown size={24} />}
title={"No feed yet"} title={"No feed yet"}
description={ description={"Send an email or track an event to see it here"}
"Send an email or track an event to see it here"
}
/> />
</> </>
) : ( ) : (
@@ -230,9 +178,7 @@ export default function Index() {
Email: f.contact.email, Email: f.contact.email,
Activity: ( Activity: (
<Badge type={"info"}> <Badge type={"info"}>
{f.createdAt === f.updatedAt {f.createdAt === f.updatedAt ? "Email delivered" : `Email ${f.status.toLowerCase()}`}
? "Email delivered"
: `Email ${f.status.toLowerCase()}`}
</Badge> </Badge>
), ),
Type: <Badge type={"success"}>Email</Badge>, Type: <Badge type={"success"}>Email</Badge>,
@@ -247,9 +193,7 @@ export default function Index() {
if (f.action) { if (f.action) {
return { return {
Email: f.contact.email, Email: f.contact.email,
Activity: ( Activity: <Badge type={"info"}>{f.action.name}</Badge>,
<Badge type={"info"}>{f.action.name}</Badge>
),
Type: <Badge type={"info"}>Action</Badge>, Type: <Badge type={"info"}>Action</Badge>,
Time: dayjs().to(dayjs(f.createdAt)), Time: dayjs().to(dayjs(f.createdAt)),
View: ( View: (
+6 -30
View File
@@ -19,11 +19,7 @@ export default function Index() {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
const { const { data: contact, error, mutate } = useContact({ id: router.query.id as string, withProject: true });
data: contact,
error,
mutate,
} = useContact({ id: router.query.id as string, withProject: true });
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
if (error) { if (error) {
@@ -73,23 +69,10 @@ export default function Index() {
}, },
]} ]}
/> />
<div <div className={"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"}>
className={ <div className={"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"}>
"flex h-screen w-full flex-col items-center justify-center bg-neutral-50" <h1 className={"text-center text-2xl font-bold leading-tight text-neutral-800"}>
} {contact.subscribed ? "Unsubscribe from" : "Subscribe to"} {contact.project.name}
>
<div
className={
"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"
}
>
<h1
className={
"text-center text-2xl font-bold leading-tight text-neutral-800"
}
>
{contact.subscribed ? "Unsubscribe from" : "Subscribe to"}{" "}
{contact.project.name}
</h1> </h1>
<p className={"mt-4 text-center text-sm text-neutral-500"}> <p className={"mt-4 text-center text-sm text-neutral-500"}>
{contact.subscribed {contact.subscribed
@@ -112,14 +95,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
+7 -29
View File
@@ -91,20 +91,13 @@ export default function Index() {
<div className="flex flex-1 flex-col justify-center px-4 py-12 sm:border-r-2 sm:border-neutral-100 sm:px-6 lg:flex-none lg:px-20 xl:px-24"> <div className="flex flex-1 flex-col justify-center px-4 py-12 sm:border-r-2 sm:border-neutral-100 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
<div className="mx-auto w-full max-w-sm lg:w-96"> <div className="mx-auto w-full max-w-sm lg:w-96">
<div> <div>
<h2 className="mt-6 text-3xl font-extrabold text-neutral-800"> <h2 className="mt-6 text-3xl font-extrabold text-neutral-800">Create a new project</h2>
Create a new project <p className={"text-sm text-neutral-500"}>Get ready to take your emails to the next level.</p>
</h2>
<p className={"text-sm text-neutral-500"}>
Get ready to take your emails to the next level.
</p>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<div className="mt-6"> <div className="mt-6">
<form <form onSubmit={handleSubmit(create)} className="relative mt-2 w-full">
onSubmit={handleSubmit(create)}
className="relative mt-2 w-full"
>
<div className="mt-4 flex flex-col"> <div className="mt-4 flex flex-col">
<label htmlFor="name" className="text-xs font-light"> <label htmlFor="name" className="text-xs font-light">
Project name Project name
@@ -182,9 +175,7 @@ export default function Index() {
type="submit" type="submit"
disabled={!isValid || submitted} disabled={!isValid || submitted}
className={` ${ className={` ${
isValid isValid ? "bg-neutral-800 text-white" : "bg-neutral-200 text-white"
? "bg-neutral-800 text-white"
: "bg-neutral-200 text-white"
} mt-5 flex w-full items-center justify-center rounded py-2.5 text-sm font-medium transition`} } mt-5 flex w-full items-center justify-center rounded py-2.5 text-sm font-medium transition`}
> >
{submitted ? ( {submitted ? (
@@ -194,14 +185,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
@@ -209,9 +193,7 @@ export default function Index() {
/> />
</svg> </svg>
) : ( ) : (
<span <span className={"flex items-center justify-center gap-x-2"}>
className={"flex items-center justify-center gap-x-2"}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
@@ -250,11 +232,7 @@ export default function Index() {
</div> </div>
</div> </div>
<div className="relative hidden w-0 flex-1 items-center justify-center bg-gradient-to-br from-blue-50 to-white lg:flex"> <div className="relative hidden w-0 flex-1 items-center justify-center bg-gradient-to-br from-blue-50 to-white lg:flex">
<div <div className={"w-full max-w-lg rounded-2xl border border-neutral-200 bg-white p-9"}>
className={
"w-full max-w-lg rounded-2xl border border-neutral-200 bg-white p-9"
}
>
<Shared /> <Shared />
</div> </div>
</div> </div>
@@ -60,9 +60,7 @@ export default function Index() {
const [advancedSettings, setAdvancedSettings] = useState(false); const [advancedSettings, setAdvancedSettings] = useState(false);
const [step, setStep] = useState<0 | 1 | 2 | 3>(0); const [step, setStep] = useState<0 | 1 | 2 | 3>(0);
const [language, setLanguage] = useState< const [language, setLanguage] = useState<"javascript" | "python" | "curl" | "PHP" | "ruby">("curl");
"javascript" | "python" | "curl" | "PHP" | "ruby"
>("curl");
const [delay, setDelay] = useState<{ const [delay, setDelay] = useState<{
delay: number; delay: number;
unit: "MINUTES" | "HOURS" | "DAYS"; unit: "MINUTES" | "HOURS" | "DAYS";
@@ -135,16 +133,11 @@ export default function Index() {
const triggerEvent = (data: EventValues) => { const triggerEvent = (data: EventValues) => {
toast.promise( toast.promise(
network.mock<boolean, typeof EventSchemas.post>( network.mock<boolean, typeof EventSchemas.post>(activeProject.secret, "POST", "/v1/track", {
activeProject.secret, event: data.event,
"POST", email: user.email,
"/v1/track", subscribed: true,
{ }),
event: data.event,
email: user.email,
subscribed: true,
},
),
{ {
loading: "Sending your event", loading: "Sending your event",
success: () => { success: () => {
@@ -160,14 +153,9 @@ export default function Index() {
const createTemplate = (data: TemplateValues) => { const createTemplate = (data: TemplateValues) => {
toast.promise( toast.promise(
network.mock<Template, typeof TemplateSchemas.create>( network.mock<Template, typeof TemplateSchemas.create>(activeProject.secret, "POST", "/v1/templates", {
activeProject.secret, ...data,
"POST", }),
"/v1/templates",
{
...data,
},
),
{ {
loading: "Creating new template", loading: "Creating new template",
success: () => { success: () => {
@@ -192,23 +180,14 @@ export default function Index() {
email: user.email, email: user.email,
data: { data: {
project: activeProject.name, project: activeProject.name,
firstEvent: events.sort( firstEvent: events.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0].name,
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime(),
)[0].name,
}, },
}), }),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}), }),
network.mock<Template, typeof ActionSchemas.create>( network.mock<Template, typeof ActionSchemas.create>(activeProject.secret, "POST", "/v1/actions", {
activeProject.secret, ...data,
"POST", }),
"/v1/actions",
{
...data,
},
),
]), ]),
{ {
loading: "Creating new action", loading: "Creating new action",
@@ -231,9 +210,7 @@ export default function Index() {
initial={{ opacity: 0, x: 100 }} initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }} exit={{ opacity: 0, x: -100 }}
className={ className={"flex h-96 flex-col items-center justify-center text-center"}
"flex h-96 flex-col items-center justify-center text-center"
}
> >
<motion.span <motion.span
animate={{ animate={{
@@ -249,9 +226,8 @@ export default function Index() {
<p>Are you ready to give Plunk Actions a spin?</p> <p>Are you ready to give Plunk Actions a spin?</p>
<p> <p>
In this 3 step tutorial, we'll help you set up your first In this 3 step tutorial, we'll help you set up your first email action so that you have an example on hand when
email action so that you have an example on hand when you are you are ready to start building your own.
ready to start building your own.
</p> </p>
</div> </div>
@@ -259,9 +235,7 @@ export default function Index() {
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
onClick={() => setStep(1)} onClick={() => setStep(1)}
className={ className={"mt-6 rounded bg-neutral-800 px-12 py-4 text-sm font-medium text-white"}
"mt-6 rounded bg-neutral-800 px-12 py-4 text-sm font-medium text-white"
}
> >
Let's get started! Let's get started!
</motion.button> </motion.button>
@@ -276,9 +250,7 @@ export default function Index() {
initial={{ opacity: 0, x: 100 }} initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }} exit={{ opacity: 0, x: -100 }}
className={ className={"flex h-96 flex-col items-center justify-center text-center"}
"flex h-96 flex-col items-center justify-center text-center"
}
> >
<motion.span <motion.span
animate={{ animate={{
@@ -289,23 +261,11 @@ export default function Index() {
> >
🎉 🎉
</motion.span> </motion.span>
<h2 className={"my-4 text-2xl font-bold"}> <h2 className={"my-4 text-2xl font-bold"}>Your event has successfully arrived</h2>
Your event has successfully arrived
</h2>
<p className={"font-medium text-neutral-500 sm:w-1/2"}> <p className={"font-medium text-neutral-500 sm:w-1/2"}>
We have received your event{" "} We have received your event{" "}
<span <span className={"rounded bg-neutral-50 px-2 py-0.5 font-mono text-neutral-600"}>
className={ {events.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0].name}
"rounded bg-neutral-50 px-2 py-0.5 font-mono text-neutral-600"
}
>
{
events.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime(),
)[0].name
}
</span> </span>
, you are now ready to create your first email template! , you are now ready to create your first email template!
</p> </p>
@@ -313,9 +273,7 @@ export default function Index() {
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
onClick={() => setStep(2)} onClick={() => setStep(2)}
className={ className={"mt-4 rounded-md bg-neutral-800 px-10 py-3 text-sm font-medium text-white"}
"mt-4 rounded-md bg-neutral-800 px-10 py-3 text-sm font-medium text-white"
}
> >
Design an email Design an email
</motion.button> </motion.button>
@@ -330,29 +288,18 @@ export default function Index() {
exit={{ opacity: 0, x: -100 }} exit={{ opacity: 0, x: -100 }}
> >
<div className={"mx-auto my-6 max-w-xl text-center"}> <div className={"mx-auto my-6 max-w-xl text-center"}>
<h2 className={"my-2 text-2xl font-bold"}> <h2 className={"my-2 text-2xl font-bold"}>Track your first event</h2>
Track your first event
</h2>
<p className={"font-medium text-neutral-500"}> <p className={"font-medium text-neutral-500"}>
Actions start from events. You can call them whatever you want Actions start from events. You can call them whatever you want and send them from anywhere using an API call.
and send them from anywhere using an API call.
</p> </p>
</div> </div>
<div className={"mt-8 grid gap-6 sm:grid-cols-3"}> <div className={"mt-8 grid gap-6 sm:grid-cols-3"}>
<div <div className={"border-b border-neutral-100 p-4 sm:col-span-2 sm:border-b-0 sm:border-r-2"}>
className={ <h3 className={"text-center font-semibold text-neutral-800"}>From your application</h3>
"border-b border-neutral-100 p-4 sm:col-span-2 sm:border-b-0 sm:border-r-2"
}
>
<h3 className={"text-center font-semibold text-neutral-800"}>
From your application
</h3>
<div className={"mt-3 space-y-6"}> <div className={"mt-3 space-y-6"}>
<div> <div>
<Dropdown <Dropdown
onChange={(e) => onChange={(e) => setLanguage(e as "javascript" | "python" | "curl")}
setLanguage(e as "javascript" | "python" | "curl")
}
values={[ values={[
{ value: "curl", name: "cURL" }, { value: "curl", name: "cURL" },
{ name: "JavaScript", value: "javascript" }, { name: "JavaScript", value: "javascript" },
@@ -430,24 +377,14 @@ response = https.request(request)`,
</div> </div>
</div> </div>
<div <div className={"flex flex-col items-center justify-center p-4"}>
className={"flex flex-col items-center justify-center p-4"} <h3 className={"text-center font-semibold text-neutral-800"}>From Plunk</h3>
> <div className={"flex flex-1 flex-col items-center justify-center"}>
<h3 className={"text-center font-semibold text-neutral-800"}>
From Plunk
</h3>
<div
className={
"flex flex-1 flex-col items-center justify-center"
}
>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
onClick={() => setEventModal(true)} onClick={() => setEventModal(true)}
className={ className={"mt-6 rounded-md bg-neutral-800 px-10 py-4 text-sm font-medium text-white"}
"mt-6 rounded-md bg-neutral-800 px-10 py-4 text-sm font-medium text-white"
}
> >
Trigger a demo event Trigger a demo event
</motion.button> </motion.button>
@@ -469,20 +406,13 @@ response = https.request(request)`,
<div className={"mx-auto my-6 max-w-4xl text-center"}> <div className={"mx-auto my-6 max-w-4xl text-center"}>
<h2 className={"my-2 text-2xl font-bold"}>Design an email</h2> <h2 className={"my-2 text-2xl font-bold"}>Design an email</h2>
<p className={"font-medium text-neutral-500"}> <p className={"font-medium text-neutral-500"}>
Our templates are easy to write and automatically transformed Our templates are easy to write and automatically transformed into HTML that email clients understand.
into HTML that email clients understand.
</p> </p>
</div> </div>
<form <form onSubmit={templateHandleSubmit(createTemplate)} className={"grid gap-6 sm:grid-cols-6"}>
onSubmit={templateHandleSubmit(createTemplate)}
className={"grid gap-6 sm:grid-cols-6"}
>
<div className={"sm:col-span-4"}> <div className={"sm:col-span-4"}>
<label <label htmlFor={"subject"} className="block text-sm font-medium text-neutral-700">
htmlFor={"subject"}
className="block text-sm font-medium text-neutral-700"
>
Subject Subject
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -511,38 +441,25 @@ response = https.request(request)`,
</div> </div>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"type"} className="block flex items-center text-sm font-medium text-neutral-700">
htmlFor={"type"}
className="block flex items-center text-sm font-medium text-neutral-700"
>
Type Type
<Tooltip <Tooltip
content={ content={
<> <>
<p className={"mb-2 text-base font-semibold"}> <p className={"mb-2 text-base font-semibold"}>What type of email is this?</p>
What type of email is this?
</p>
<ul className={"list-inside"}> <ul className={"list-inside"}>
<li className={"mb-6"}> <li className={"mb-6"}>
<span className={"font-semibold"}>Marketing</span> <span className={"font-semibold"}>Marketing</span>
<br /> <br />
Promotional emails with a Plunk-hosted unsubscribe Promotional emails with a Plunk-hosted unsubscribe link
link
<br /> <br />
<span className={"text-neutral-400"}> <span className={"text-neutral-400"}>(e.g. welcome emails, promotions)</span>
(e.g. welcome emails, promotions)
</span>
</li> </li>
<li> <li>
<span className={"font-semibold"}> <span className={"font-semibold"}>Transactional</span>
Transactional
</span>
<br /> <br />
Mission critical emails <br /> Mission critical emails <br />
<span className={"text-neutral-400"}> <span className={"text-neutral-400"}> (e.g. email verification, password reset)</span>
{" "}
(e.g. email verification, password reset)
</span>
</li> </li>
</ul> </ul>
</> </>
@@ -557,12 +474,7 @@ response = https.request(request)`,
/> />
</label> </label>
<Dropdown <Dropdown
onChange={(t) => onChange={(t) => templateSetValue("type", t as "MARKETING" | "TRANSACTIONAL")}
templateSetValue(
"type",
t as "MARKETING" | "TRANSACTIONAL",
)
}
values={[ values={[
{ name: "Marketing", value: "MARKETING" }, { name: "Marketing", value: "MARKETING" },
{ name: "Transactional", value: "TRANSACTIONAL" }, { name: "Transactional", value: "TRANSACTIONAL" },
@@ -584,11 +496,7 @@ response = https.request(request)`,
</div> </div>
<div className={"sm:col-span-6"}> <div className={"sm:col-span-6"}>
<Editor <Editor value={templateWatch("body")} mode={"PLUNK"} onChange={(val) => templateSetValue("body", val)} />
value={templateWatch("body")}
mode={"PLUNK"}
onChange={(val) => templateSetValue("body", val)}
/>
<AnimatePresence> <AnimatePresence>
{templateErrors.body?.message && ( {templateErrors.body?.message && (
<motion.p <motion.p
@@ -640,9 +548,7 @@ response = https.request(request)`,
initial={{ opacity: 0, x: 100 }} initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }} exit={{ opacity: 0, x: -100 }}
className={ className={"flex h-96 flex-col items-center justify-center text-center"}
"flex h-96 flex-col items-center justify-center text-center"
}
> >
<motion.span <motion.span
animate={{ animate={{
@@ -653,45 +559,24 @@ response = https.request(request)`,
> >
🏎 🏎
</motion.span> </motion.span>
<h2 className={"my-4 text-2xl font-bold"}> <h2 className={"my-4 text-2xl font-bold"}>Your action has been created</h2>
Your action has been created
</h2>
<p className={"w-1/2 font-medium text-neutral-500"}> <p className={"w-1/2 font-medium text-neutral-500"}>
Users will now automatically start to receive emails when they Users will now automatically start to receive emails when they complete your event{" "}
complete your event{" "} <span className={"rounded-md bg-neutral-50 px-2 py-0.5 font-mono text-neutral-500"}>
<span {events.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0].name}
className={
"rounded-md bg-neutral-50 px-2 py-0.5 font-mono text-neutral-500"
}
>
{
events.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime(),
)[0].name
}
</span> </span>
. There is loads more to discover in Plunk but let's try out . There is loads more to discover in Plunk but let's try out your action first!
your action first!
</p> </p>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
className={ className={"mt-9 rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"}
"mt-9 rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"
}
onClick={async () => { onClick={async () => {
toast.promise( toast.promise(
network.mock<boolean, typeof EventSchemas.post>( network.mock<boolean, typeof EventSchemas.post>(activeProject.secret, "POST", "/v1/track", {
activeProject.secret, event: eventGetValues("event"),
"POST", email: user.email,
"/v1/track", }),
{
event: eventGetValues("event"),
email: user.email,
},
),
{ {
loading: "Sending your event", loading: "Sending your event",
success: `${eventGetValues("event")} delivered`, success: `${eventGetValues("event")} delivered`,
@@ -715,23 +600,14 @@ response = https.request(request)`,
key={"action"} key={"action"}
> >
<div className={"mx-auto my-6 max-w-2xl text-center"}> <div className={"mx-auto my-6 max-w-2xl text-center"}>
<h2 className={"my-2 text-2xl font-bold"}> <h2 className={"my-2 text-2xl font-bold"}>Creating your first action</h2>
Creating your first action
</h2>
<p className={"font-medium text-neutral-500"}> <p className={"font-medium text-neutral-500"}>
Actions tie together events and templates, they automate your Actions tie together events and templates, they automate your email workflows.
email workflows.
</p> </p>
</div> </div>
<form <form onSubmit={actionHandleSubmit(createAction)} className="grid gap-4 space-y-6 pb-6 sm:grid-cols-2">
onSubmit={actionHandleSubmit(createAction)}
className="grid gap-4 space-y-6 pb-6 sm:grid-cols-2"
>
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"name"} className="block text-sm font-medium text-neutral-700">
htmlFor={"name"}
className="block text-sm font-medium text-neutral-700"
>
Name Name
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -760,10 +636,7 @@ response = https.request(request)`,
</div> </div>
<div> <div>
<label <label htmlFor={"events"} className="block text-sm font-medium text-neutral-700">
htmlFor={"events"}
className="block text-sm font-medium text-neutral-700"
>
Events that need to be triggered Events that need to be triggered
</label> </label>
<MultiselectDropdown <MultiselectDropdown
@@ -774,28 +647,21 @@ response = https.request(request)`,
selectedValues={actionWatch("events")} selectedValues={actionWatch("events")}
/> />
<AnimatePresence> <AnimatePresence>
{(actionErrors.events as FieldError | undefined) {(actionErrors.events as FieldError | undefined)?.message && (
?.message && (
<motion.p <motion.p
initial={{ height: 0 }} initial={{ height: 0 }}
animate={{ height: "auto" }} animate={{ height: "auto" }}
exit={{ height: 0 }} exit={{ height: 0 }}
className="mt-1 text-xs text-red-500" className="mt-1 text-xs text-red-500"
> >
{ {(actionErrors.events as FieldError | undefined)?.message}
(actionErrors.events as FieldError | undefined)
?.message
}
</motion.p> </motion.p>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
<div> <div>
<label <label htmlFor={"template"} className="block text-sm font-medium text-neutral-700">
htmlFor={"template"}
className="block text-sm font-medium text-neutral-700"
>
Template that will be sent Template that will be sent
</label> </label>
<Dropdown <Dropdown
@@ -826,13 +692,9 @@ response = https.request(request)`,
e.preventDefault(); e.preventDefault();
setAdvancedSettings(!advancedSettings); setAdvancedSettings(!advancedSettings);
}} }}
className={ className={"text-sm font-medium text-neutral-500 transition hover:text-neutral-700"}
"text-sm font-medium text-neutral-500 transition hover:text-neutral-700"
}
> >
{advancedSettings {advancedSettings ? "Hide advanced settings" : "Show advanced settings"}
? "Hide advanced settings"
: "Show advanced settings"}
</button> </button>
</div> </div>
} }
@@ -847,10 +709,7 @@ response = https.request(request)`,
className={"sm:col-span-2"} className={"sm:col-span-2"}
> >
<div> <div>
<label <label htmlFor={"template"} className="block text-sm font-medium text-neutral-700">
htmlFor={"template"}
className="block text-sm font-medium text-neutral-700"
>
Delay before sending Delay before sending
</label> </label>
<div className={"grid grid-cols-2 gap-4"}> <div className={"grid grid-cols-2 gap-4"}>
@@ -900,9 +759,7 @@ response = https.request(request)`,
: "This action will run each time the required events are triggered." : "This action will run each time the required events are triggered."
} }
toggled={actionWatch("runOnce")} toggled={actionWatch("runOnce")}
onToggle={() => onToggle={() => actionSetValue("runOnce", !actionWatch("runOnce"))}
actionSetValue("runOnce", !actionWatch("runOnce"))
}
/> />
</div> </div>
</motion.div> </motion.div>
@@ -953,10 +810,7 @@ response = https.request(request)`,
description={"Trigger an event to use in your actions"} description={"Trigger an event to use in your actions"}
> >
<div> <div>
<label <label htmlFor={"event"} className="block text-sm font-medium text-neutral-700">
htmlFor={"event"}
className="block text-sm font-medium text-neutral-700"
>
Event Event
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -1025,24 +879,18 @@ response = https.request(request)`,
step >= 3 ? "border-neutral-800" : "border-neutral-200" step >= 3 ? "border-neutral-800" : "border-neutral-200"
} group flex flex-col border-l-4 py-2 pl-4 transition md:border-l-0 md:border-t-4 md:pb-0 md:pl-0 md:pt-4`} } group flex flex-col border-l-4 py-2 pl-4 transition md:border-l-0 md:border-t-4 md:pb-0 md:pl-0 md:pt-4`}
> >
<span className="text-sm font-medium"> <span className="text-sm font-medium">Create an action</span>
Create an action
</span>
</span> </span>
</li> </li>
</ol> </ol>
</nav> </nav>
</div> </div>
<div className={"mx-auto flex h-full flex-col items-center pt-16"}> <div className={"mx-auto flex h-full flex-col items-center pt-16"}>{renderStep()}</div>
{renderStep()}
</div>
</div> </div>
{step === 0 && ( {step === 0 && (
<div className={"fixed bottom-3 w-full bg-white text-center"}> <div className={"fixed bottom-3 w-full bg-white text-center"}>
<span <span
className={ className={"cursor-pointer text-sm text-neutral-500 transition ease-in-out hover:text-neutral-700"}
"cursor-pointer text-sm text-neutral-500 transition ease-in-out hover:text-neutral-700"
}
onClick={async () => { onClick={async () => {
await router.push("/onboarding"); await router.push("/onboarding");
}} }}
@@ -18,9 +18,7 @@ export default function Index() {
const project = useActiveProject(); const project = useActiveProject();
const { data: user } = useUser(); const { data: user } = useUser();
const { data: emails, mutate } = useEmailsCount(); const { data: emails, mutate } = useEmailsCount();
const [language, setLanguage] = useState< const [language, setLanguage] = useState<"javascript" | "python" | "curl" | "PHP" | "ruby">("curl");
"javascript" | "python" | "curl" | "PHP" | "ruby"
>("curl");
if (!project || !user || emails === undefined) { if (!project || !user || emails === undefined) {
return <FullscreenLoader />; return <FullscreenLoader />;
@@ -28,11 +26,7 @@ export default function Index() {
return ( return (
<> <>
<div <div className={"flex min-h-screen w-screen flex-col items-center justify-center gap-6"}>
className={
"flex min-h-screen w-screen flex-col items-center justify-center gap-6"
}
>
<div> <div>
{emails > 0 ? ( {emails > 0 ? (
<> <>
@@ -41,9 +35,7 @@ export default function Index() {
initial={{ opacity: 0, x: 100 }} initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }} exit={{ opacity: 0, x: -100 }}
className={ className={"flex h-96 flex-col items-center justify-center text-center"}
"flex h-96 flex-col items-center justify-center text-center"
}
> >
<motion.span <motion.span
animate={{ animate={{
@@ -55,15 +47,11 @@ export default function Index() {
🏎 🏎
</motion.span> </motion.span>
<h2 className={"my-4 text-2xl font-bold"}>Wasn't that easy?</h2> <h2 className={"my-4 text-2xl font-bold"}>Wasn't that easy?</h2>
<p className={"font-medium text-neutral-500"}> <p className={"font-medium text-neutral-500"}>Just like that you've sent your first email with Plunk!</p>
Just like that you've sent your first email with Plunk!
</p>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
className={ className={"mt-9 rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"}
"mt-9 rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"
}
onClick={async () => { onClick={async () => {
await router.push("/"); await router.push("/");
}} }}
@@ -93,22 +81,15 @@ export default function Index() {
</motion.span> </motion.span>
<h2 className={"my-4 text-4xl font-bold"}>Send it!</h2> <h2 className={"my-4 text-4xl font-bold"}>Send it!</h2>
<div className={"max-w-2xl font-medium text-neutral-500"}> <div className={"max-w-2xl font-medium text-neutral-500"}>
<p> <p>Are you ready to send a transactional email with Plunk?</p>
Are you ready to send a transactional email with Plunk?
</p>
<p> <p>Sending a transactional email is as easy as making a single API call.</p>
Sending a transactional email is as easy as making a
single API call.
</p>
</div> </div>
</div> </div>
<div className={"w-full max-w-2xl space-y-3"}> <div className={"w-full max-w-2xl space-y-3"}>
<Dropdown <Dropdown
onChange={(e) => onChange={(e) => setLanguage(e as "javascript" | "python" | "curl")}
setLanguage(e as "javascript" | "python" | "curl")
}
values={[ values={[
{ value: "curl", name: "cURL" }, { value: "curl", name: "cURL" },
{ name: "JavaScript", value: "javascript" }, { name: "JavaScript", value: "javascript" },
@@ -191,21 +172,14 @@ response = https.request(request)`,
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
className={ className={"rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"}
"rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"
}
onClick={() => { onClick={() => {
toast.promise( toast.promise(
network.mock<boolean, typeof EventSchemas.send>( network.mock<boolean, typeof EventSchemas.send>(project.secret, "POST", "/v1/send", {
project.secret, subject: "Your first email",
"POST", body: "Hello from Plunk!",
"/v1/send", to: user.email,
{ }),
subject: "Your first email",
body: "Hello from Plunk!",
to: user.email,
},
),
{ {
loading: "Sending the email", loading: "Sending the email",
success: () => { success: () => {
@@ -226,9 +200,7 @@ response = https.request(request)`,
<div className={"fixed bottom-3 w-full bg-white text-center"}> <div className={"fixed bottom-3 w-full bg-white text-center"}>
<span <span
className={ className={"cursor-pointer text-sm text-neutral-500 transition ease-in-out hover:text-neutral-700"}
"cursor-pointer text-sm text-neutral-500 transition ease-in-out hover:text-neutral-700"
}
onClick={async () => { onClick={async () => {
await router.push("/onboarding"); await router.push("/onboarding");
}} }}
+7 -23
View File
@@ -66,9 +66,7 @@ export default function Index() {
onAction={regenerate} onAction={regenerate}
type={"danger"} type={"danger"}
title={"Are you sure?"} title={"Are you sure?"}
description={ description={"Any applications that use your previously generated keys will stop working!"}
"Any applications that use your previously generated keys will stop working!"
}
/> />
<Dashboard> <Dashboard>
<SettingTabs /> <SettingTabs />
@@ -95,20 +93,13 @@ export default function Index() {
toast.success("Copied your public API key"); toast.success("Copied your public API key");
}} }}
> >
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">Public API Key</label>
Public API Key <p className={"cursor-pointer rounded border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm"}>
</label>
<p
className={
"cursor-pointer rounded border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm"
}
>
{activeProject.public} {activeProject.public}
</p> </p>
<p className={"text-sm text-neutral-500"}> <p className={"text-sm text-neutral-500"}>
Use this key for any front-end services. This key can only be used Use this key for any front-end services. This key can only be used to publish events.
to publish events.
</p> </p>
</div> </div>
@@ -119,20 +110,13 @@ export default function Index() {
toast.success("Copied your secret API key"); toast.success("Copied your secret API key");
}} }}
> >
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">Secret API Key</label>
Secret API Key <p className={"cursor-pointer rounded border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm"}>
</label>
<p
className={
"cursor-pointer rounded border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm"
}
>
{activeProject.secret} {activeProject.secret}
</p> </p>
<p className={"text-sm text-neutral-500"}> <p className={"text-sm text-neutral-500"}>
Use this key for any secure back-end services. This key gives Use this key for any secure back-end services. This key gives complete access to your Plunk setup.
complete access to your Plunk setup.
</p> </p>
</div> </div>
</div> </div>
@@ -5,22 +5,10 @@ import { Copy, Unlink } from "lucide-react";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Alert, Badge, Card, FullscreenLoader, Input, SettingTabs, Table } from "../../components";
Alert,
Badge,
Card,
FullscreenLoader,
Input,
SettingTabs,
Table,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { AWS_REGION } from "../../lib/constants"; import { AWS_REGION } from "../../lib/constants";
import { import { useActiveProject, useActiveProjectVerifiedIdentity, useProjects } from "../../lib/hooks/projects";
useActiveProject,
useActiveProjectVerifiedIdentity,
useProjects,
} from "../../lib/hooks/projects";
import { network } from "../../lib/network"; import { network } from "../../lib/network";
interface EmailValues { interface EmailValues {
@@ -37,8 +25,7 @@ interface FromValues {
export default function Index() { export default function Index() {
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const { mutate: projectsMutate } = useProjects(); const { mutate: projectsMutate } = useProjects();
const { data: identity, mutate: identityMutate } = const { data: identity, mutate: identityMutate } = useActiveProjectVerifiedIdentity();
useActiveProjectVerifiedIdentity();
const { const {
register, register,
@@ -143,9 +130,7 @@ export default function Index() {
<Card <Card
title={"Domain"} title={"Domain"}
description={ description={"By sending emails from your own domain you build up domain authority and trust."}
"By sending emails from your own domain you build up domain authority and trust."
}
actions={ actions={
activeProject.email && ( activeProject.email && (
<> <>
@@ -165,12 +150,10 @@ export default function Index() {
{activeProject.email && !activeProject.verified ? ( {activeProject.email && !activeProject.verified ? (
<> <>
<Alert type={"warning"} title={"Waiting for DNS verification"}> <Alert type={"warning"} title={"Waiting for DNS verification"}>
Please add the following records to{" "} Please add the following records to {activeProject.email.split("@")[1]} to verify {activeProject.email}, this
{activeProject.email.split("@")[1]} to verify{" "} may take up to 15 minutes to register. <br />
{activeProject.email}, this may take up to 15 minutes to In the meantime you can already start sending emails, we will automatically switch to your domain once it is
register. <br /> verified.
In the meantime you can already start sending emails, we will
automatically switch to your domain once it is verified.
</Alert> </Alert>
<div className="mt-6"> <div className="mt-6">
@@ -194,16 +177,11 @@ export default function Index() {
<div <div
className={"flex cursor-pointer items-center gap-3"} className={"flex cursor-pointer items-center gap-3"}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText( void navigator.clipboard.writeText("v=spf1 include:amazonses.com ~all");
"v=spf1 include:amazonses.com ~all",
);
toast.success("Copied value to clipboard"); toast.success("Copied value to clipboard");
}} }}
> >
<p className={"font-mono text-sm"}> <p className={"font-mono text-sm"}>v=spf1 include:amazonses.com ~all</p> <Copy size={14} />
v=spf1 include:amazonses.com ~all
</p>{" "}
<Copy size={14} />
</div> </div>
), ),
}, },
@@ -225,15 +203,11 @@ export default function Index() {
<div <div
className={"flex cursor-pointer items-center gap-3"} className={"flex cursor-pointer items-center gap-3"}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText( void navigator.clipboard.writeText(`10 feedback-smtp.${AWS_REGION}.amazonses.com`);
`10 feedback-smtp.${AWS_REGION}.amazonses.com`,
);
toast.success("Copied value to clipboard"); toast.success("Copied value to clipboard");
}} }}
> >
<p className={"font-mono text-sm"}> <p className={"font-mono text-sm"}>10 feedback-smtp.{AWS_REGION}.amazonses.com</p>
10 feedback-smtp.{AWS_REGION}.amazonses.com
</p>
<Copy size={14} /> <Copy size={14} />
</div> </div>
), ),
@@ -245,15 +219,11 @@ export default function Index() {
<div <div
className={"flex cursor-pointer items-center gap-3"} className={"flex cursor-pointer items-center gap-3"}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText( void navigator.clipboard.writeText(`${token}._domainkey`);
`${token}._domainkey`,
);
toast.success("Copied key to clipboard"); toast.success("Copied key to clipboard");
}} }}
> >
<p className={"font-mono text-sm"}> <p className={"font-mono text-sm"}>{token}._domainkey</p>
{token}._domainkey
</p>
<Copy size={14} /> <Copy size={14} />
</div> </div>
), ),
@@ -261,15 +231,11 @@ export default function Index() {
<div <div
className={"flex cursor-pointer items-center gap-3"} className={"flex cursor-pointer items-center gap-3"}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText( void navigator.clipboard.writeText(`${token}.dkim.amazonses.com`);
`${token}.dkim.amazonses.com`,
);
toast.success("Copied value to clipboard"); toast.success("Copied value to clipboard");
}} }}
> >
<p className={"font-mono text-sm"}> <p className={"font-mono text-sm"}>{token}.dkim.amazonses.com</p>
{token}.dkim.amazonses.com
</p>
<Copy size={14} /> <Copy size={14} />
</div> </div>
), ),
@@ -282,19 +248,13 @@ export default function Index() {
) : activeProject.email && activeProject.verified ? ( ) : activeProject.email && activeProject.verified ? (
<> <>
<Alert type={"success"} title={"Domain verified"}> <Alert type={"success"} title={"Domain verified"}>
You have confirmed {activeProject.email} as your domain. Any You have confirmed {activeProject.email} as your domain. Any emails sent by Plunk will now use this address.
emails sent by Plunk will now use this address.
</Alert> </Alert>
</> </>
) : ( ) : (
<> <>
<form onSubmit={handleSubmit(create)} className="space-y-6"> <form onSubmit={handleSubmit(create)} className="space-y-6">
<Input <Input register={register("email")} error={errors.email} placeholder={"[email protected]"} label={"Email"} />
register={register("email")}
error={errors.email}
placeholder={"[email protected]"}
label={"Email"}
/>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
@@ -6,20 +6,9 @@ import { useRouter } from "next/router";
import React, { useState } from "react"; import React, { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Card, FullscreenLoader, Input, Modal, SettingTabs, Table } from "../../components";
Card,
FullscreenLoader,
Input,
Modal,
SettingTabs,
Table,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { import { useActiveProject, useActiveProjectMemberships, useProjects } from "../../lib/hooks/projects";
useActiveProject,
useActiveProjectMemberships,
useProjects,
} from "../../lib/hooks/projects";
import { useUser } from "../../lib/hooks/users"; import { useUser } from "../../lib/hooks/users";
import { network } from "../../lib/network"; import { network } from "../../lib/network";
@@ -41,17 +30,14 @@ export default function Index() {
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const { data: user } = useUser(); const { data: user } = useUser();
const { data: projects, mutate: projectMutate } = useProjects(); const { data: projects, mutate: projectMutate } = useProjects();
const { data: memberships, mutate: membershipMutate } = const { data: memberships, mutate: membershipMutate } = useActiveProjectMemberships();
useActiveProjectMemberships();
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<EmailValues>({ } = useForm<EmailValues>({
resolver: zodResolver( resolver: zodResolver(MembershipSchemas.invite.omit({ id: true, role: true })),
MembershipSchemas.invite.omit({ id: true, role: true }),
),
}); });
if (activeProject && !project) { if (activeProject && !project) {
@@ -160,12 +146,7 @@ export default function Index() {
"Enter the email of the account you want to invite to this project. The person you want to invite needs to have an account on Plunk." "Enter the email of the account you want to invite to this project. The person you want to invite needs to have an account on Plunk."
} }
> >
<Input <Input register={register("email")} error={errors.email} label={"Email"} placeholder={"[email protected]"} />
register={register("email")}
error={errors.email}
label={"Email"}
placeholder={"[email protected]"}
/>
</Modal> </Modal>
<Dashboard> <Dashboard>
<SettingTabs /> <SettingTabs />
@@ -174,27 +155,19 @@ export default function Index() {
values={memberships.map((membership) => { values={memberships.map((membership) => {
return { return {
Account: membership.email, Account: membership.email,
Role: Role: membership.role.charAt(0).toUpperCase() + membership.role.slice(1).toLowerCase(),
membership.role.charAt(0).toUpperCase() +
membership.role.slice(1).toLowerCase(),
Manage: Manage:
membership.userId === user.id ? ( membership.userId === user.id ? (
<button <button
className={ className={"mb-2 text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-700"}
"mb-2 text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-700"
}
onClick={() => setShowLeaveModal(true)} onClick={() => setShowLeaveModal(true)}
> >
Leave Leave
</button> </button>
) : memberships.find( ) : memberships.find((membership) => membership.userId === user.id)?.role === "OWNER" ? (
(membership) => membership.userId === user.id,
)?.role === "OWNER" ? (
<button <button
className={ className={"mb-2 text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-700"}
"mb-2 text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-700"
}
onClick={() => kickAccount(membership.email)} onClick={() => kickAccount(membership.email)}
> >
Kick Kick
@@ -207,12 +180,10 @@ export default function Index() {
/> />
<div className={"mt-9 flex items-center"}> <div className={"mt-9 flex items-center"}>
<div className={"w-2/3"}> <div className={"w-2/3"}>
<p className={"text-sm font-semibold text-neutral-800"}> <p className={"text-sm font-semibold text-neutral-800"}>Invite team</p>
Invite team
</p>
<p className={"text-sm text-neutral-400"}> <p className={"text-sm text-neutral-400"}>
By adding someone to your project you give them access to all By adding someone to your project you give them access to all data present in your project including emails and
data present in your project including emails and your API key. your API key.
</p> </p>
</div> </div>
@@ -220,9 +191,7 @@ export default function Index() {
onClick={() => setShowInviteModal(true)} onClick={() => setShowInviteModal(true)}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
className={ className={"ml-auto mt-4 self-end rounded bg-neutral-800 px-8 py-2.5 text-sm font-medium text-white"}
"ml-auto mt-4 self-end rounded bg-neutral-800 px-8 py-2.5 text-sm font-medium text-white"
}
> >
Invite user Invite user
</motion.button> </motion.button>
@@ -6,19 +6,9 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Card, FullscreenLoader, Input, Modal, SettingTabs } from "../../components";
Card,
FullscreenLoader,
Input,
Modal,
SettingTabs,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { import { useActiveProject, useActiveProjectMemberships, useProjects } from "../../lib/hooks/projects";
useActiveProject,
useActiveProjectMemberships,
useProjects,
} from "../../lib/hooks/projects";
import { useUser } from "../../lib/hooks/users"; import { useUser } from "../../lib/hooks/users";
interface ProjectValues { interface ProjectValues {
@@ -132,24 +122,11 @@ export default function Index() {
/> />
<Dashboard> <Dashboard>
<SettingTabs /> <SettingTabs />
<Card <Card title={"Project details"} description={"Manage your project details"}>
title={"Project details"}
description={"Manage your project details"}
>
<form onSubmit={handleSubmit(update)} className="space-y-6"> <form onSubmit={handleSubmit(update)} className="space-y-6">
<div className={"grid gap-5 sm:grid-cols-2"}> <div className={"grid gap-5 sm:grid-cols-2"}>
<Input <Input register={register("name")} label={"Name"} placeholder={"ACME Inc."} error={errors.name} />
register={register("name")} <Input register={register("url")} label={"URL"} placeholder={"https://useplunk.com"} error={errors.url} />
label={"Name"}
placeholder={"ACME Inc."}
error={errors.name}
/>
<Input
register={register("url")}
label={"URL"}
placeholder={"https://useplunk.com"}
error={errors.url}
/>
</div> </div>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
@@ -162,22 +139,14 @@ export default function Index() {
</motion.button> </motion.button>
</form> </form>
</Card> </Card>
{memberships.find((membership) => membership.userId === user.id) {memberships.find((membership) => membership.userId === user.id)?.role === "OWNER" ? (
?.role === "OWNER" ? ( <Card title={"Danger zone"} description={"Better watch out here"} className={"mt-4"}>
<Card
title={"Danger zone"}
description={"Better watch out here"}
className={"mt-4"}
>
<div className={"flex"}> <div className={"flex"}>
<div className={"w-2/3"}> <div className={"w-2/3"}>
<p className={"text-sm font-bold text-neutral-500"}> <p className={"text-sm font-bold text-neutral-500"}>Delete your project</p>
Delete your project
</p>
<p className={"text-sm text-neutral-400"}> <p className={"text-sm text-neutral-400"}>
Deleting your project may have unwanted consequences. All data Deleting your project may have unwanted consequences. All data associated with this project will get deleted
associated with this project will get deleted and can not be and can not be recovered!{" "}
recovered!{" "}
</p> </p>
</div> </div>
<button <button
+12 -48
View File
@@ -23,9 +23,7 @@ export default function Index() {
id: router.query.id as string, id: router.query.id as string,
withProject: true, withProject: true,
}); });
const [submitted, setSubmitted] = useState< const [submitted, setSubmitted] = useState<"initial" | "loading" | "submitted">("initial");
"initial" | "loading" | "submitted"
>("initial");
if (error) { if (error) {
return <Redirect to={"/"} />; return <Redirect to={"/"} />;
@@ -39,14 +37,9 @@ export default function Index() {
setSubmitted("loading"); setSubmitted("loading");
toast.promise( toast.promise(
network.mock<User, typeof UtilitySchemas.id>( network.mock<User, typeof UtilitySchemas.id>(contact.project.public, "POST", "/v1/contacts/subscribe", {
contact.project.public, id: contact.id,
"POST", }),
"/v1/contacts/subscribe",
{
id: contact.id,
},
),
{ {
loading: "Subscribing...", loading: "Subscribing...",
success: "Thank you for subscribing!", success: "Thank you for subscribing!",
@@ -71,16 +64,8 @@ export default function Index() {
}, },
]} ]}
/> />
<div <div className={"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"}>
className={ <div className={"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"}>
"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"
}
>
<div
className={
"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"
}
>
{submitted === "submitted" ? ( {submitted === "submitted" ? (
<> <>
<motion.div className={"mb-3 flex items-center justify-center"}> <motion.div className={"mb-3 flex items-center justify-center"}>
@@ -97,9 +82,7 @@ export default function Index() {
initial={{ pathLength: 0 }} initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }} animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.2, ease: "easeInOut" }} transition={{ duration: 0.3, delay: 0.2, ease: "easeInOut" }}
className={ className={"h-24 w-24 rounded-full bg-emerald-100 p-6 text-emerald-900"}
"h-24 w-24 rounded-full bg-emerald-100 p-6 text-emerald-900"
}
> >
<motion.path <motion.path
d="M20 6 9 17l-5-5" d="M20 6 9 17l-5-5"
@@ -114,26 +97,14 @@ export default function Index() {
</motion.svg> </motion.svg>
</motion.div> </motion.div>
<h1 <h1 className={"text-center text-2xl font-bold leading-tight text-neutral-800"}>You have been subscribed!</h1>
className={
"text-center text-2xl font-bold leading-tight text-neutral-800"
}
>
You have been subscribed!
</h1>
</> </>
) : ( ) : (
<> <>
<h1 <h1 className={"text-center text-2xl font-bold leading-tight text-neutral-800"}>Confirm your subscription?</h1>
className={
"text-center text-2xl font-bold leading-tight text-neutral-800"
}
>
Confirm your subscription?
</h1>
<p className={"mt-4 text-center text-sm text-neutral-500"}> <p className={"mt-4 text-center text-sm text-neutral-500"}>
By confirming your subscription to {contact.project.name} for{" "} By confirming your subscription to {contact.project.name} for {contact.email} you agree to receive emails from
{contact.email} you agree to receive emails from us. us.
</p> </p>
<div className="relative mt-2 w-full"> <div className="relative mt-2 w-full">
<motion.button <motion.button
@@ -151,14 +122,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
+21 -71
View File
@@ -7,14 +7,7 @@ import { useRouter } from "next/router";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Card, Dropdown, Editor, FullscreenLoader, Input, Tooltip } from "../../components";
Card,
Dropdown,
Editor,
FullscreenLoader,
Input,
Tooltip,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useActiveProject } from "../../lib/hooks/projects"; import { useActiveProject } from "../../lib/hooks/projects";
import { useTemplate, useTemplates } from "../../lib/hooks/templates"; import { useTemplate, useTemplates } from "../../lib/hooks/templates";
@@ -63,25 +56,16 @@ export default function Index() {
reset(template); reset(template);
}, [reset, template]); }, [reset, template]);
if ( if (!project || !template || (watch("body") as string | undefined) === undefined) {
!project ||
!template ||
(watch("body") as string | undefined) === undefined
) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
const update = (data: TemplateValues) => { const update = (data: TemplateValues) => {
toast.promise( toast.promise(
network.mock<Template, typeof TemplateSchemas.update>( network.mock<Template, typeof TemplateSchemas.update>(project.secret, "PUT", "/v1/templates", {
project.secret, id: template.id,
"PUT", ...data,
"/v1/templates", }),
{
id: template.id,
...data,
},
),
{ {
loading: "Saving your template", loading: "Saving your template",
success: () => { success: () => {
@@ -97,14 +81,9 @@ export default function Index() {
const duplicate = async (e: { preventDefault: () => void }) => { const duplicate = async (e: { preventDefault: () => void }) => {
e.preventDefault(); e.preventDefault();
toast.promise( toast.promise(
network.mock<Template, typeof UtilitySchemas.id>( network.mock<Template, typeof UtilitySchemas.id>(project.secret, "POST", "/v1/templates/duplicate", {
project.secret, id: template.id,
"POST", }),
"/v1/templates/duplicate",
{
id: template.id,
},
),
{ {
loading: "Duplicating your template", loading: "Duplicating your template",
success: () => { success: () => {
@@ -122,20 +101,13 @@ export default function Index() {
e.preventDefault(); e.preventDefault();
if (template.actions.length > 0) { if (template.actions.length > 0) {
return toast.error( return toast.error("You cannot delete a template that is linked to an action!");
"You cannot delete a template that is linked to an action!",
);
} }
toast.promise( toast.promise(
network.mock<Template, typeof UtilitySchemas.id>( network.mock<Template, typeof UtilitySchemas.id>(project.secret, "DELETE", "/v1/templates", {
project.secret, id: template.id,
"DELETE", }),
"/v1/templates",
{
id: template.id,
},
),
{ {
loading: "Deleting your template", loading: "Deleting your template",
success: () => { success: () => {
@@ -205,23 +177,14 @@ export default function Index() {
strokeWidth="1.5" strokeWidth="1.5"
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5" d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
/> />
<path <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M5 7.75H19" />
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M5 7.75H19"
/>
</svg> </svg>
Delete Delete
</button> </button>
</> </>
} }
> >
<form <form onSubmit={handleSubmit(update)} className="grid gap-6 sm:grid-cols-6">
onSubmit={handleSubmit(update)}
className="grid gap-6 sm:grid-cols-6"
>
<Input <Input
className={"sm:col-span-4"} className={"sm:col-span-4"}
label={"Subject"} label={"Subject"}
@@ -231,36 +194,25 @@ export default function Index() {
/> />
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"type"} className="flex items-center text-sm font-medium text-neutral-700">
htmlFor={"type"}
className="flex items-center text-sm font-medium text-neutral-700"
>
Type Type
<Tooltip <Tooltip
content={ content={
<> <>
<p className={"mb-2 text-base font-semibold"}> <p className={"mb-2 text-base font-semibold"}>What type of email is this?</p>
What type of email is this?
</p>
<ul className={"list-inside"}> <ul className={"list-inside"}>
<li className={"mb-6"}> <li className={"mb-6"}>
<span className={"font-semibold"}>Marketing</span> <span className={"font-semibold"}>Marketing</span>
<br /> <br />
Promotional emails with a Plunk-hosted unsubscribe Promotional emails with a Plunk-hosted unsubscribe link
link
<br /> <br />
<span className={"text-neutral-400"}> <span className={"text-neutral-400"}>(e.g. welcome emails, promotions)</span>
(e.g. welcome emails, promotions)
</span>
</li> </li>
<li> <li>
<span className={"font-semibold"}>Transactional</span> <span className={"font-semibold"}>Transactional</span>
<br /> <br />
Mission critical emails <br /> Mission critical emails <br />
<span className={"text-neutral-400"}> <span className={"text-neutral-400"}> (e.g. email verification, password reset)</span>
{" "}
(e.g. email verification, password reset)
</span>
</li> </li>
</ul> </ul>
</> </>
@@ -275,9 +227,7 @@ export default function Index() {
/> />
</label> </label>
<Dropdown <Dropdown
onChange={(t) => onChange={(t) => setValue("type", t as "MARKETING" | "TRANSACTIONAL")}
setValue("type", t as "MARKETING" | "TRANSACTIONAL")
}
values={[ values={[
{ name: "Marketing", value: "MARKETING" }, { name: "Marketing", value: "MARKETING" },
{ name: "Transactional", value: "TRANSACTIONAL" }, { name: "Transactional", value: "TRANSACTIONAL" },
@@ -20,8 +20,7 @@ export default function Index() {
<Alert type={"info"} title={"Need a hand?"}> <Alert type={"info"} title={"Need a hand?"}>
<div className={"mt-3 grid items-center sm:grid-cols-4"}> <div className={"mt-3 grid items-center sm:grid-cols-4"}>
<p className={"sm:col-span-3"}> <p className={"sm:col-span-3"}>
Want us to help you get started? We can help you build your Want us to help you get started? We can help you build your first action in less than 5 minutes.
first action in less than 5 minutes.
</p> </p>
<Link <Link
@@ -59,11 +58,7 @@ export default function Index() {
{templates ? ( {templates ? (
templates.length > 0 ? ( templates.length > 0 ? (
<> <>
<div <div className={"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"}>
className={
"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
}
>
{templates {templates
.sort((a, b) => { .sort((a, b) => {
if (a.actions.length > 0 && b.actions.length === 0) { if (a.actions.length > 0 && b.actions.length === 0) {
@@ -93,16 +88,10 @@ export default function Index() {
</span> </span>
<div className="flex-1 truncate"> <div className="flex-1 truncate">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<h3 className="truncate text-sm font-medium text-neutral-800"> <h3 className="truncate text-sm font-medium text-neutral-800">{t.subject}</h3>
{t.subject} {t.actions.length > 0 && <Badge type={"success"}>Active</Badge>}
</h3>
{t.actions.length > 0 && (
<Badge type={"success"}>Active</Badge>
)}
</div> </div>
<p className="mt-1 truncate text-sm text-neutral-500"> <p className="mt-1 truncate text-sm text-neutral-500">Last edited {dayjs().to(t.updatedAt)}</p>
Last edited {dayjs().to(t.updatedAt)}
</p>
</div> </div>
</div> </div>
<div> <div>
@@ -112,12 +101,7 @@ export default function Index() {
href={`/templates/${t.id}`} href={`/templates/${t.id}`}
className="relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl border border-transparent py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700" className="relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl border border-transparent py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
> >
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
@@ -147,12 +131,7 @@ export default function Index() {
</> </>
) : ( ) : (
<> <>
<Empty <Empty title={"No templates here"} description={"Try creating a new email blueprint for your actions"} />
title={"No templates here"}
description={
"Try creating a new email blueprint for your actions"
}
/>
</> </>
) )
) : ( ) : (
+12 -43
View File
@@ -6,14 +6,7 @@ import { useRouter } from "next/router";
import React from "react"; import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Card, Dropdown, Editor, FullscreenLoader, Input, Tooltip } from "../../components";
Card,
Dropdown,
Editor,
FullscreenLoader,
Input,
Tooltip,
} from "../../components";
import { Dashboard } from "../../layouts"; import { Dashboard } from "../../layouts";
import { useActiveProject } from "../../lib/hooks/projects"; import { useActiveProject } from "../../lib/hooks/projects";
import { useTemplates } from "../../lib/hooks/templates"; import { useTemplates } from "../../lib/hooks/templates";
@@ -63,14 +56,9 @@ export default function Index() {
const create = async (data: TemplateValues) => { const create = async (data: TemplateValues) => {
toast.promise( toast.promise(
network.mock<Template, typeof TemplateSchemas.create>( network.mock<Template, typeof TemplateSchemas.create>(project.secret, "POST", "/v1/templates", {
project.secret, ...data,
"POST", }),
"/v1/templates",
{
...data,
},
),
{ {
loading: "Creating new template", loading: "Creating new template",
success: () => { success: () => {
@@ -88,14 +76,8 @@ export default function Index() {
return ( return (
<> <>
<Dashboard> <Dashboard>
<Card <Card title={"Create a new template"} description={"Reusable blueprints of your emails"}>
title={"Create a new template"} <form onSubmit={handleSubmit(create)} className="grid gap-6 sm:grid-cols-6">
description={"Reusable blueprints of your emails"}
>
<form
onSubmit={handleSubmit(create)}
className="grid gap-6 sm:grid-cols-6"
>
<Input <Input
className={"sm:col-span-4"} className={"sm:col-span-4"}
label={"Subject"} label={"Subject"}
@@ -105,36 +87,25 @@ export default function Index() {
/> />
<div className={"sm:col-span-2"}> <div className={"sm:col-span-2"}>
<label <label htmlFor={"type"} className="flex items-center text-sm font-medium text-neutral-700">
htmlFor={"type"}
className="flex items-center text-sm font-medium text-neutral-700"
>
Type Type
<Tooltip <Tooltip
content={ content={
<> <>
<p className={"mb-2 text-base font-semibold"}> <p className={"mb-2 text-base font-semibold"}>What type of email is this?</p>
What type of email is this?
</p>
<ul className={"list-inside"}> <ul className={"list-inside"}>
<li className={"mb-6"}> <li className={"mb-6"}>
<span className={"font-semibold"}>Marketing</span> <span className={"font-semibold"}>Marketing</span>
<br /> <br />
Promotional emails with a Plunk-hosted unsubscribe Promotional emails with a Plunk-hosted unsubscribe link
link
<br /> <br />
<span className={"text-neutral-400"}> <span className={"text-neutral-400"}>(e.g. welcome emails, promotions)</span>
(e.g. welcome emails, promotions)
</span>
</li> </li>
<li> <li>
<span className={"font-semibold"}>Transactional</span> <span className={"font-semibold"}>Transactional</span>
<br /> <br />
Mission critical emails <br /> Mission critical emails <br />
<span className={"text-neutral-400"}> <span className={"text-neutral-400"}> (e.g. email verification, password reset)</span>
{" "}
(e.g. email verification, password reset)
</span>
</li> </li>
</ul> </ul>
</> </>
@@ -149,9 +120,7 @@ export default function Index() {
/> />
</label> </label>
<Dropdown <Dropdown
onChange={(t) => onChange={(t) => setValue("type", t as "MARKETING" | "TRANSACTIONAL")}
setValue("type", t as "MARKETING" | "TRANSACTIONAL")
}
values={[ values={[
{ name: "Marketing", value: "MARKETING" }, { name: "Marketing", value: "MARKETING" },
{ name: "Transactional", value: "TRANSACTIONAL" }, { name: "Transactional", value: "TRANSACTIONAL" },
@@ -23,9 +23,7 @@ export default function Index() {
id: router.query.id as string, id: router.query.id as string,
withProject: true, withProject: true,
}); });
const [submitted, setSubmitted] = useState< const [submitted, setSubmitted] = useState<"initial" | "loading" | "submitted">("initial");
"initial" | "loading" | "submitted"
>("initial");
if (error) { if (error) {
return <Redirect to={"/"} />; return <Redirect to={"/"} />;
@@ -39,14 +37,9 @@ export default function Index() {
setSubmitted("loading"); setSubmitted("loading");
toast.promise( toast.promise(
network.mock<User, typeof UtilitySchemas.id>( network.mock<User, typeof UtilitySchemas.id>(contact.project.public, "POST", "/v1/contacts/unsubscribe", {
contact.project.public, id: contact.id,
"POST", }),
"/v1/contacts/unsubscribe",
{
id: contact.id,
},
),
{ {
loading: "Unsubscribing", loading: "Unsubscribing",
success: "Unsubscribed", success: "Unsubscribed",
@@ -71,16 +64,8 @@ export default function Index() {
}, },
]} ]}
/> />
<div <div className={"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"}>
className={ <div className={"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"}>
"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"
}
>
<div
className={
"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"
}
>
{submitted === "submitted" ? ( {submitted === "submitted" ? (
<> <>
<motion.div className={"mb-3 flex items-center justify-center"}> <motion.div className={"mb-3 flex items-center justify-center"}>
@@ -97,9 +82,7 @@ export default function Index() {
initial={{ pathLength: 0 }} initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }} animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.2, ease: "easeInOut" }} transition={{ duration: 0.3, delay: 0.2, ease: "easeInOut" }}
className={ className={"h-24 w-24 rounded-full bg-emerald-100 p-6 text-emerald-900"}
"h-24 w-24 rounded-full bg-emerald-100 p-6 text-emerald-900"
}
> >
<motion.path <motion.path
d="M20 6 9 17l-5-5" d="M20 6 9 17l-5-5"
@@ -114,27 +97,16 @@ export default function Index() {
</motion.svg> </motion.svg>
</motion.div> </motion.div>
<h1 <h1 className={"text-center text-2xl font-bold leading-tight text-neutral-800"}>You have been unsubscribed!</h1>
className={
"text-center text-2xl font-bold leading-tight text-neutral-800"
}
>
You have been unsubscribed!
</h1>
</> </>
) : ( ) : (
<> <>
<h1 <h1 className={"text-center text-2xl font-bold leading-tight text-neutral-800"}>
className={
"text-center text-2xl font-bold leading-tight text-neutral-800"
}
>
Are you sure you want to unsubscribe? Are you sure you want to unsubscribe?
</h1> </h1>
<p className={"mt-4 text-center text-sm text-neutral-500"}> <p className={"mt-4 text-center text-sm text-neutral-500"}>
You will no longer receive emails from {contact.project.name} on You will no longer receive emails from {contact.project.name} on the email {contact.email} when you confirm that
the email {contact.email} when you confirm that you want to you want to unsubscribe.
unsubscribe.
</p> </p>
<div className="relative mt-2 w-full"> <div className="relative mt-2 w-full">
<motion.button <motion.button
@@ -152,14 +124,7 @@ export default function Index() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"