Update Biome formatting settings, improved self-hosting capability and reformatted all files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
github: [driaug]
|
||||
github: [ driaug ]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -17,5 +17,10 @@
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 1,
|
||||
"lineWidth": 120
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_")) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,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={{
|
||||
|
||||
+2
-9
@@ -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 = () => {
|
||||
|
||||
+3
-11
@@ -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"} />
|
||||
|
||||
@@ -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" }],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"} />
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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"))}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"} />
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user