Update Biome formatting settings, improved self-hosting capability and reformatted all files

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