Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3b39d847cd | ||
|
|
dff25c62d9 | ||
|
|
200dfb1dbf | ||
|
|
a99cde8839 | ||
|
|
0e8b6f813d | ||
|
|
7b6872c4b7 | ||
|
|
c17f7065d9 | ||
|
|
f44d987bc9 | ||
|
|
dc2ce02d1a | ||
|
|
b9d692cf5b | ||
|
|
c19cf531b8 | ||
|
|
f0ef34646e | ||
|
|
6a340e9554 | ||
|
|
38565bbce2 | ||
|
|
c32b54f588 | ||
|
|
44987d2b5e | ||
|
|
2c3e8970cb | ||
|
|
395af0080c | ||
|
|
e3ea5fb6dc | ||
|
|
4451e921ca | ||
|
|
92949b7596 |
@@ -29,7 +29,7 @@ WIKI_URI=http://localhost:1000
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.1"
|
||||
".": "0.8.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
|
||||
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
|
||||
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
|
||||
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
|
||||
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
|
||||
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
|
||||
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
|
||||
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
|
||||
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
|
||||
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
|
||||
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
|
||||
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
|
||||
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
|
||||
|
||||
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
|
||||
### Background Job Architecture
|
||||
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
||||
|
||||
- **api**: Express.js API server with background worker process (BullMQ)
|
||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||
- **landing**: Next.js marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
@@ -135,7 +135,7 @@ between groups.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [documentation](https://next-wiki.useplunk.com)
|
||||
- Check the [documentation](https://docs.useplunk.com)
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join our community discussions
|
||||
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ ARG TARGETPLATFORM
|
||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||
ARG API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://next.useplunk.com
|
||||
ARG WIKI_URI=https://next-wiki.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ The easiest way to self-host Plunk is by using the `plunk` Docker image.
|
||||
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
||||
|
||||
A complete guide on how to deploy Plunk can be found in
|
||||
the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
|
||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ export class Webhooks {
|
||||
}
|
||||
|
||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com.
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com or sns.<region>.amazonaws.eu
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
|
||||
@@ -652,7 +652,7 @@ export class EmailService {
|
||||
|
||||
/**
|
||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
* rather than the visual editor. Mirrors the same logic in EmailPreviewModal.tsx.
|
||||
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||
*/
|
||||
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||
if (!html || html.trim() === '') return false;
|
||||
@@ -692,7 +692,7 @@ export class EmailService {
|
||||
|
||||
/**
|
||||
* Wraps visual editor content with a full HTML document and prose styles.
|
||||
* Mirrors wrapEmailWithStyles() in EmailPreviewModal.tsx so sent emails
|
||||
* Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails
|
||||
* match the preview modal exactly.
|
||||
*/
|
||||
private static wrapWithEmailStyles(htmlBody: string): string {
|
||||
@@ -1093,7 +1093,7 @@ export class EmailService {
|
||||
<tr>
|
||||
<td style="width:180px;">
|
||||
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
||||
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
<img alt="Powered by Plunk" height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -531,12 +531,9 @@ export class WorkflowExecutionService {
|
||||
throw new Error('No template configured for SEND_EMAIL step');
|
||||
}
|
||||
|
||||
// Parse step config to get recipient configuration
|
||||
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||
const recipientConfig =
|
||||
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||
: {type: 'CONTACT'};
|
||||
// Parse and validate step config using schema
|
||||
const stepConfig = WorkflowStepConfigSchemas.sendEmail.parse(config);
|
||||
const recipientConfig = stepConfig.recipient || {type: 'CONTACT' as const};
|
||||
|
||||
// Get contact data for variable substitution
|
||||
const contact = execution.contact;
|
||||
@@ -566,19 +563,13 @@ export class WorkflowExecutionService {
|
||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||
|
||||
// Determine recipient email
|
||||
let recipientEmail = contact.email;
|
||||
let recipientContactId = contact.id;
|
||||
|
||||
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||
recipientEmail = recipientConfig.customEmail;
|
||||
// For custom recipients, we don't associate with a contact
|
||||
recipientContactId = contact.id; // Keep original contact for tracking
|
||||
}
|
||||
// Schema validation ensures customEmail exists when type is CUSTOM
|
||||
const recipientEmail = recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail! : contact.email;
|
||||
|
||||
// Send email via EmailService
|
||||
const email = await EmailService.sendWorkflowEmail({
|
||||
projectId: execution.workflow.projectId,
|
||||
contactId: recipientContactId,
|
||||
contactId: contact.id, // Keep original contact for tracking
|
||||
workflowExecutionId: execution.id,
|
||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||
templateId: step.template.id,
|
||||
@@ -588,7 +579,7 @@ export class WorkflowExecutionService {
|
||||
fromName: step.template.fromName || undefined,
|
||||
replyTo: step.template.replyTo || undefined,
|
||||
// Pass custom recipient email if specified
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -727,7 +718,7 @@ export class WorkflowExecutionService {
|
||||
_stepExecution: WorkflowStepExecution,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
const parsed = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
|
||||
// Get the value to evaluate
|
||||
const contact = execution.contact;
|
||||
@@ -743,7 +734,7 @@ export class WorkflowExecutionService {
|
||||
// - data.firstName, data.lastName, etc.
|
||||
// - workflow.* (execution context - alias for event data)
|
||||
// - event.* (event data that triggered the workflow)
|
||||
const actualValue = this.resolveField(field, {
|
||||
const fieldData = {
|
||||
contact: {
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
@@ -751,16 +742,47 @@ export class WorkflowExecutionService {
|
||||
data: contactData,
|
||||
workflow: context,
|
||||
event: context, // Alias for easier access to event data
|
||||
});
|
||||
};
|
||||
|
||||
// Evaluate the condition
|
||||
// Multi-branch mode (switch/case)
|
||||
if ('mode' in parsed && parsed.mode === 'multi') {
|
||||
const actualValue = this.resolveField(parsed.field, fieldData);
|
||||
|
||||
for (const branch of parsed.branches) {
|
||||
if (this.evaluateCondition(actualValue, branch.operator, branch.value)) {
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: branch.name,
|
||||
actualValue,
|
||||
branch: branch.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No branch matched — use default
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: 'default',
|
||||
actualValue,
|
||||
branch: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy binary mode (if/else)
|
||||
const field = parsed.field;
|
||||
const operator = 'operator' in parsed ? parsed.operator : 'equals';
|
||||
const value = 'value' in parsed ? parsed.value : undefined;
|
||||
const actualValue = this.resolveField(field, fieldData);
|
||||
const result = this.evaluateCondition(actualValue, operator, value);
|
||||
|
||||
return {
|
||||
field,
|
||||
operator,
|
||||
expectedValue: value,
|
||||
actualValue,
|
||||
// Convert undefined to null so it's preserved in JSON (JSON.stringify removes undefined)
|
||||
actualValue: actualValue === undefined ? null : actualValue,
|
||||
result,
|
||||
branch: result ? 'yes' : 'no',
|
||||
};
|
||||
@@ -996,7 +1018,15 @@ export class WorkflowExecutionService {
|
||||
* Helper: Resolve field value from object using dot notation
|
||||
*/
|
||||
private static resolveField(field: string, data: Record<string, unknown>): unknown {
|
||||
const parts = field.split('.');
|
||||
// Handle legacy "contact.data.X" format by converting to "data.X"
|
||||
// The UI historically showed examples like "contact.data.plan" but the field structure
|
||||
// has "data" as a top-level key, not nested under "contact"
|
||||
let normalizedField = field;
|
||||
if (field.startsWith('contact.data.')) {
|
||||
normalizedField = field.substring(8); // Remove "contact." prefix, leaving "data.X"
|
||||
}
|
||||
|
||||
const parts = normalizedField.split('.');
|
||||
let value: unknown = data;
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
|
||||
expect(branch).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MULTI-BRANCH CONDITIONS (switch/case)
|
||||
// ========================================
|
||||
describe('Multi-Branch Conditions', () => {
|
||||
/**
|
||||
* Helper to create a workflow with a multi-branch condition step
|
||||
* Creates one exit step per branch + one for default
|
||||
*/
|
||||
async function createMultiBranchWorkflow(
|
||||
contactData: Record<string, unknown>,
|
||||
conditionConfig: Record<string, unknown>,
|
||||
) {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: contactData,
|
||||
});
|
||||
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
|
||||
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||
});
|
||||
|
||||
const conditionStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Multi Condition',
|
||||
position: {x: 100, y: 0},
|
||||
config: conditionConfig,
|
||||
},
|
||||
});
|
||||
|
||||
// Create exit steps for each branch + default
|
||||
const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>;
|
||||
const branchExits: Record<string, string> = {};
|
||||
for (let i = 0; i < branches.length; i++) {
|
||||
const branch = branches[i]!;
|
||||
const exitStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: `${branch.name} Path`,
|
||||
position: {x: 200, y: i * 100},
|
||||
config: {reason: branch.name},
|
||||
},
|
||||
});
|
||||
branchExits[branch.id] = exitStep.id;
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: branch.id},
|
||||
priority: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create default exit
|
||||
const defaultExit = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Default Path',
|
||||
position: {x: 200, y: branches.length * 100},
|
||||
config: {reason: 'default'},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: defaultExit.id,
|
||||
condition: {branch: 'default'},
|
||||
priority: branches.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Connect trigger to condition
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
|
||||
});
|
||||
|
||||
const execution = await prisma.workflowExecution.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
},
|
||||
});
|
||||
|
||||
return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id};
|
||||
}
|
||||
|
||||
const planBranches = {
|
||||
mode: 'multi' as const,
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'},
|
||||
{id: 'br-free', name: 'Free', operator: 'equals', value: 'free'},
|
||||
{id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'},
|
||||
],
|
||||
};
|
||||
|
||||
it('should match the first matching branch', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-premium');
|
||||
});
|
||||
|
||||
it('should match a non-first branch correctly', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'enterprise'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-enterprise');
|
||||
});
|
||||
|
||||
it('should fall through to default when no branch matches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'starter'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should fall through to default when field is missing', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{other: 'value'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should support mixed operators across branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{score: 85},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.score',
|
||||
branches: [
|
||||
{id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90},
|
||||
{id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70},
|
||||
{id: 'br-low', name: 'Low', operator: 'lessThan', value: 70},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// 85 >= 90 is false, 85 >= 70 is true → should match "Medium"
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-medium');
|
||||
});
|
||||
|
||||
it('should respect branch evaluation order (first match wins)', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-first', name: 'First', operator: 'contains', value: 'prem'},
|
||||
{id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// Both match, but first one should win
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-first');
|
||||
});
|
||||
|
||||
it('should support exists/notExists operators in branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-has-plan', name: 'Has Plan', operator: 'exists'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-has-plan');
|
||||
});
|
||||
|
||||
it('should route to correct exit step via transitions', async () => {
|
||||
const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow(
|
||||
{plan: 'free'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
|
||||
// Check the execution completed and ended at the correct exit step
|
||||
const completedExecution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
});
|
||||
|
||||
// Workflow completed after reaching exit step
|
||||
expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status);
|
||||
|
||||
// Verify it went through the "free" branch exit, not default
|
||||
const exitStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: branchExits['br-free'],
|
||||
},
|
||||
});
|
||||
expect(exitStepExecution).not.toBeNull();
|
||||
|
||||
// Verify it did NOT go through the default exit
|
||||
const defaultStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: defaultExitId,
|
||||
},
|
||||
});
|
||||
expect(defaultStepExecution).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://next.useplunk.com/assets/card.png',
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
}: GuideLayoutProps) {
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
||||
|
||||
// Generate breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
||||
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
||||
{position: 3, name: title, item: canonical || ''},
|
||||
];
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://next.useplunk.com/guides/email-api-guide"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://next.useplunk.com/guides/email-deliverability"
|
||||
canonical="https://www.useplunk.com/guides/email-deliverability"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="15 min"
|
||||
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
||||
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/email-open-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="11 min"
|
||||
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
||||
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -103,13 +103,13 @@ export default function GuidesIndex() {
|
||||
<NextSeo
|
||||
title="Email Marketing & Deliverability Guides | Plunk"
|
||||
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
||||
canonical="https://next.useplunk.com/guides"
|
||||
canonical="https://www.useplunk.com/guides"
|
||||
openGraph={{
|
||||
title: 'Email Marketing & Deliverability Guides | Plunk',
|
||||
description:
|
||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||
url: 'https://next.useplunk.com/guides',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
url: 'https://www.useplunk.com/guides',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
|
||||
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="8 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dkim"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDMARC() {
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dmarc"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsSPF() {
|
||||
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="7 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-spf"
|
||||
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -32,13 +32,13 @@ export default function ToolsIndex() {
|
||||
<NextSeo
|
||||
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||
canonical="https://next.useplunk.com/tools"
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||
description:
|
||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||
url: 'https://next.useplunk.com/tools',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function MarkdownToEmail() {
|
||||
<NextSeo
|
||||
title="Free Markdown to Email HTML Converter | Plunk"
|
||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||
canonical="https://next.useplunk.com/tools/markdown-to-email"
|
||||
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||
openGraph={{
|
||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||
description:
|
||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||
url: 'https://next.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ export default function VerifyEmailPage() {
|
||||
<NextSeo
|
||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||
canonical="https://next.useplunk.com/tools/verify-email"
|
||||
canonical="https://www.useplunk.com/tools/verify-email"
|
||||
openGraph={{
|
||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||
url: 'https://next.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
url: 'https://www.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function ActiveCampaignComparison() {
|
||||
<NextSeo
|
||||
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
|
||||
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
|
||||
canonical="https://next.useplunk.com/vs/activecampaign"
|
||||
canonical="https://www.useplunk.com/vs/activecampaign"
|
||||
openGraph={{
|
||||
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
|
||||
description:
|
||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||
url: 'https://next.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function BentoComparison() {
|
||||
<NextSeo
|
||||
title="Bento Alternative: Developer-First Email Platform | Plunk"
|
||||
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
|
||||
canonical="https://next.useplunk.com/vs/bento"
|
||||
canonical="https://www.useplunk.com/vs/bento"
|
||||
openGraph={{
|
||||
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
|
||||
description:
|
||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||
url: 'https://next.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
url: 'https://www.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function BrevoComparison() {
|
||||
<NextSeo
|
||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||
canonical="https://next.useplunk.com/vs/brevo"
|
||||
canonical="https://www.useplunk.com/vs/brevo"
|
||||
openGraph={{
|
||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||
url: 'https://next.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
url: 'https://www.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function ConvertkitComparison() {
|
||||
<NextSeo
|
||||
title="ConvertKit Alternative for Developers | Plunk"
|
||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation. No landing page bloat."
|
||||
canonical="https://next.useplunk.com/vs/convertkit"
|
||||
canonical="https://www.useplunk.com/vs/convertkit"
|
||||
openGraph={{
|
||||
title: 'ConvertKit Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||
url: 'https://next.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
url: 'https://www.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function CustomerioComparison() {
|
||||
<NextSeo
|
||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||
canonical="https://next.useplunk.com/vs/customerio"
|
||||
canonical="https://www.useplunk.com/vs/customerio"
|
||||
openGraph={{
|
||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||
url: 'https://next.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
url: 'https://www.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -96,13 +96,13 @@ export default function CompetitorsIndex() {
|
||||
<NextSeo
|
||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||
canonical="https://next.useplunk.com/vs"
|
||||
canonical="https://www.useplunk.com/vs"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||
description:
|
||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||
url: 'https://next.useplunk.com/vs',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
url: 'https://www.useplunk.com/vs',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function KlaviyoComparison() {
|
||||
<NextSeo
|
||||
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
|
||||
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
|
||||
canonical="https://next.useplunk.com/vs/klaviyo"
|
||||
canonical="https://www.useplunk.com/vs/klaviyo"
|
||||
openGraph={{
|
||||
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
|
||||
description:
|
||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||
url: 'https://next.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function LoopsComparison() {
|
||||
<NextSeo
|
||||
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
||||
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
||||
canonical="https://next.useplunk.com/vs/loops"
|
||||
canonical="https://www.useplunk.com/vs/loops"
|
||||
openGraph={{
|
||||
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
||||
url: 'https://next.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
url: 'https://www.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function MailchimpComparison() {
|
||||
<NextSeo
|
||||
title="Mailchimp Alternative for Developers | Plunk"
|
||||
description="Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact. Transactional + marketing in one platform."
|
||||
canonical="https://next.useplunk.com/vs/mailchimp"
|
||||
canonical="https://www.useplunk.com/vs/mailchimp"
|
||||
openGraph={{
|
||||
title: 'Mailchimp Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
||||
url: 'https://next.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
url: 'https://www.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function MailerliteComparison() {
|
||||
<NextSeo
|
||||
title="MailerLite Alternative: Open-Source & Self-Hostable | Plunk"
|
||||
description="The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber. Modern API, full transparency."
|
||||
canonical="https://next.useplunk.com/vs/mailerlite"
|
||||
canonical="https://www.useplunk.com/vs/mailerlite"
|
||||
openGraph={{
|
||||
title: 'MailerLite Alternative: Open-Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
||||
url: 'https://next.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
url: 'https://www.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function MailgunComparison() {
|
||||
<NextSeo
|
||||
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
||||
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
||||
canonical="https://next.useplunk.com/vs/mailgun"
|
||||
canonical="https://www.useplunk.com/vs/mailgun"
|
||||
openGraph={{
|
||||
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
||||
url: 'https://next.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
url: 'https://www.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -54,13 +54,13 @@ export default function PostmarkComparison() {
|
||||
<NextSeo
|
||||
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
||||
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
||||
canonical="https://next.useplunk.com/vs/postmark"
|
||||
canonical="https://www.useplunk.com/vs/postmark"
|
||||
openGraph={{
|
||||
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
||||
description:
|
||||
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
||||
url: 'https://next.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
url: 'https://www.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function ResendComparison() {
|
||||
<NextSeo
|
||||
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
||||
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
||||
canonical="https://next.useplunk.com/vs/resend"
|
||||
canonical="https://www.useplunk.com/vs/resend"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
||||
description:
|
||||
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
||||
url: 'https://next.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
url: 'https://www.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function SendGridComparison() {
|
||||
<NextSeo
|
||||
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
||||
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
||||
canonical="https://next.useplunk.com/vs/sendgrid"
|
||||
canonical="https://www.useplunk.com/vs/sendgrid"
|
||||
openGraph={{
|
||||
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
||||
url: 'https://next.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
url: 'https://www.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||
import {network} from '../../lib/network';
|
||||
import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
interface EmailEditorProps {
|
||||
@@ -51,54 +52,6 @@ const commonVariables = [
|
||||
{name: 'manageUrl', description: 'Manage link'},
|
||||
];
|
||||
|
||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
// Check for common patterns that indicate custom HTML
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
// Check for custom classes (exclude TipTap's generated classes)
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
// Split by whitespace to get individual classes
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
// Check if any class is NOT in the allowed list
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
|
||||
// Detect if initial value has custom HTML and start in appropriate mode
|
||||
const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
|
||||
@@ -348,31 +301,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
|
||||
if (iframeDoc) {
|
||||
const previewContent = getPreviewHtml();
|
||||
const fullHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${previewContent}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const fullHtml = wrapEmailWithStyles(previewContent);
|
||||
|
||||
iframeDoc.open();
|
||||
iframeDoc.write(fullHtml);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui';
|
||||
import {Monitor, Smartphone, Tablet} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
import {wrapEmailWithStyles} from '../lib/emailStyles';
|
||||
|
||||
interface EmailPreviewModalProps {
|
||||
open: boolean;
|
||||
@@ -16,391 +17,6 @@ interface EmailPreviewModalProps {
|
||||
|
||||
type PreviewDevice = 'mobile' | 'tablet' | 'desktop';
|
||||
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
// Check for common patterns that indicate custom HTML
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
// Check for custom classes (exclude TipTap's generated classes)
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
// Split by whitespace to get individual classes
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
// Check if any class is NOT in the allowed list
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to wrap email body with necessary styles for proper rendering
|
||||
// Only wraps visual editor content - custom HTML is returned as-is
|
||||
const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
// If this is custom HTML (from HTML editor), return it as-is without wrapping
|
||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
// For visual editor content, wrap with prose styles
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
export function EmailPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -91,6 +91,34 @@ const STEP_TYPE_BG = {
|
||||
UPDATE_CONTACT: '#e0e7ff',
|
||||
};
|
||||
|
||||
// Multi-branch condition helpers
|
||||
function getExpectedBranches(config: any): string[] {
|
||||
if (config?.mode === 'multi') {
|
||||
return [...(config.branches || []).map((b: any) => b.id), 'default'];
|
||||
}
|
||||
return ['yes', 'no'];
|
||||
}
|
||||
|
||||
function getBranchLabel(config: any, branchId: string): string {
|
||||
if (config?.mode === 'multi') {
|
||||
if (branchId === 'default') return 'Default';
|
||||
const branch = config.branches?.find((b: any) => b.id === branchId);
|
||||
return branch?.name || branchId;
|
||||
}
|
||||
return branchId === 'yes' ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
const BRANCH_COLORS = ['#16a34a', '#dc2626', '#2563eb', '#d97706', '#7c3aed', '#0891b2', '#be185d', '#059669'];
|
||||
|
||||
function getBranchColor(config: any, branchId: string): string {
|
||||
if (config?.mode === 'multi') {
|
||||
if (branchId === 'default') return '#64748b';
|
||||
const idx = config.branches?.findIndex((b: any) => b.id === branchId) ?? 0;
|
||||
return BRANCH_COLORS[idx % BRANCH_COLORS.length]!;
|
||||
}
|
||||
return branchId === 'yes' ? '#16a34a' : '#dc2626';
|
||||
}
|
||||
|
||||
// Dagre layout function
|
||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
@@ -129,6 +157,7 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
});
|
||||
|
||||
// Center "Add Step" nodes directly below their parent nodes
|
||||
// For condition nodes with multiple branches, let dagre handle horizontal spread
|
||||
const adjustedNodes = layoutedNodes.map(node => {
|
||||
if (node.type === 'addStep') {
|
||||
// Find the parent node (the source of the edge connecting to this add node)
|
||||
@@ -136,7 +165,17 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
if (parentEdge) {
|
||||
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
||||
if (parentNode) {
|
||||
// Center the add node below the parent node
|
||||
// Check how many add-step siblings share this parent
|
||||
const siblingAddNodes = edges.filter(
|
||||
e => e.source === parentEdge.source && layoutedNodes.find(n => n.id === e.target && n.type === 'addStep'),
|
||||
);
|
||||
|
||||
if (siblingAddNodes.length > 1) {
|
||||
// Multiple branches — let dagre's spread positioning stand, only keep y
|
||||
return node;
|
||||
}
|
||||
|
||||
// Single add node — center below parent
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
@@ -324,9 +363,16 @@ function CustomNode({
|
||||
: String(data.config.field)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
{data.config.mode === 'multi' ? (
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.branches?.length || 0} branch{(data.config.branches?.length || 0) !== 1 ? 'es' : ''} +
|
||||
default
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -393,7 +439,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const [addStepContext, setAddStepContext] = useState<{
|
||||
fromStepId: string | null;
|
||||
branch?: 'yes' | 'no';
|
||||
branch?: string;
|
||||
} | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||
@@ -455,41 +501,27 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
if (step.type === 'EXIT') return; // Exit steps can't have next steps
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
// Check for yes and no branches
|
||||
const hasYesBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
});
|
||||
const expectedBranches = getExpectedBranches(step.config);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-yes`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: 'Yes',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
||||
},
|
||||
expectedBranches.forEach(branchId => {
|
||||
const hasBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-no`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: 'No',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!hasBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-${branchId}`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: getBranchLabel(step.config, branchId),
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: branchId}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For non-condition steps, add + node if no outgoing transitions
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
@@ -520,7 +552,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const condition = transition.condition;
|
||||
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
|
||||
const branch =
|
||||
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
|
||||
condition && typeof condition === 'object' && 'branch' in condition
|
||||
? (condition.branch as string)
|
||||
: undefined;
|
||||
|
||||
const branchColor = branch ? getBranchColor(step.config, branch) : '#94a3b8';
|
||||
const branchLabel = branch ? getBranchLabel(step.config, branch) : undefined;
|
||||
|
||||
edges.push({
|
||||
id: transition.id,
|
||||
@@ -528,9 +565,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
target: transition.toStepId,
|
||||
type: 'smoothstep',
|
||||
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
|
||||
label: isConditional ? (branch === 'yes' ? 'Yes' : 'No') : undefined,
|
||||
label: isConditional ? branchLabel : undefined,
|
||||
labelStyle: {
|
||||
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
|
||||
fill: isConditional ? branchColor : '#64748b',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
@@ -541,12 +578,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
stroke: isConditional ? branchColor : '#94a3b8',
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
color: isConditional ? branchColor : '#94a3b8',
|
||||
width: 22,
|
||||
height: 22,
|
||||
},
|
||||
@@ -558,47 +595,37 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
if (step.type === 'EXIT') return;
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
const hasYesBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
||||
);
|
||||
const hasNoBranch = step.outgoingTransitions?.some(
|
||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
||||
);
|
||||
const expectedBranches = getExpectedBranches(step.config);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-yes-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-yes`,
|
||||
type: 'straight',
|
||||
animated: false,
|
||||
label: 'Yes',
|
||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
expectedBranches.forEach(branchId => {
|
||||
const hasBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition &&
|
||||
typeof t.condition === 'object' &&
|
||||
'branch' in t.condition &&
|
||||
t.condition.branch === branchId,
|
||||
);
|
||||
|
||||
if (!hasNoBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-no-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-no`,
|
||||
type: 'straight',
|
||||
animated: false,
|
||||
label: 'No',
|
||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
if (!hasBranch) {
|
||||
const color = getBranchColor(step.config, branchId);
|
||||
const label = getBranchLabel(step.config, branchId);
|
||||
|
||||
edges.push({
|
||||
id: `${step.id}-add-${branchId}-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-${branchId}`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label,
|
||||
labelStyle: {fill: color, fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: color, strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color, width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
edges.push({
|
||||
@@ -645,15 +672,13 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const connectedAddNodes = updatedNodes.filter(node => {
|
||||
if (node.type !== 'addStep') return false;
|
||||
// Check if this add node is connected to the moved node
|
||||
return (
|
||||
node.id === `${movedNode.id}-add` ||
|
||||
node.id === `${movedNode.id}-add-yes` ||
|
||||
node.id === `${movedNode.id}-add-no`
|
||||
);
|
||||
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
|
||||
});
|
||||
|
||||
// Update positions of connected "+" nodes to stay centered below parent
|
||||
connectedAddNodes.forEach(addNode => {
|
||||
// Update positions of connected "+" nodes to stay below parent
|
||||
if (connectedAddNodes.length === 1) {
|
||||
// Single add node — center below parent
|
||||
const addNode = connectedAddNodes[0]!;
|
||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||
const existingNode = updatedNodes[addNodeIndex];
|
||||
@@ -665,7 +690,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (connectedAddNodes.length > 1) {
|
||||
// Multiple branches — move all add nodes by the same delta as the parent
|
||||
const oldX = movedNode.position.x;
|
||||
const deltaX = change.position.x - oldX;
|
||||
connectedAddNodes.forEach(addNode => {
|
||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||
const existingNode = updatedNodes[addNodeIndex];
|
||||
updatedNodes[addNodeIndex] = {
|
||||
...existingNode,
|
||||
position: {
|
||||
x: existingNode.position.x + deltaX,
|
||||
y: existingNode.position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -733,7 +775,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
|
||||
// Create the transition with proper condition
|
||||
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
|
||||
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
|
||||
const expectedBranches = fromStep.type === 'CONDITION' ? getExpectedBranches(fromStep.config) : [];
|
||||
const branchIndex = addStepContext.branch ? expectedBranches.indexOf(addStepContext.branch) : -1;
|
||||
const priority = branchIndex >= 0 ? branchIndex : 0;
|
||||
|
||||
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
|
||||
'POST',
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
@@ -205,23 +205,33 @@ export default function WorkflowEditorPage() {
|
||||
break;
|
||||
|
||||
case 'CONDITION':
|
||||
// Extract field name from both legacy format (object) and new format (string)
|
||||
let fieldValue = '';
|
||||
if (config.field) {
|
||||
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
|
||||
fieldValue = String(config.field.field || '');
|
||||
} else {
|
||||
fieldValue = String(config.field);
|
||||
if (config.mode === 'multi') {
|
||||
// Multi-branch validation
|
||||
if (!config.field) {
|
||||
errors.push(`"${step.name}" step is missing condition field`);
|
||||
}
|
||||
if (!Array.isArray(config.branches) || config.branches.length === 0) {
|
||||
errors.push(`"${step.name}" step needs at least one branch`);
|
||||
}
|
||||
} else {
|
||||
// Extract field name from both legacy format (object) and new format (string)
|
||||
let fieldValue = '';
|
||||
if (config.field) {
|
||||
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
|
||||
fieldValue = String(config.field.field || '');
|
||||
} else {
|
||||
fieldValue = String(config.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fieldValue || !config.operator) {
|
||||
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
|
||||
}
|
||||
// Check if value is required for this operator
|
||||
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
|
||||
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
|
||||
errors.push(`"${step.name}" step is missing a value for the condition`);
|
||||
if (!fieldValue || !config.operator) {
|
||||
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
|
||||
}
|
||||
// Check if value is required for this operator
|
||||
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
|
||||
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
|
||||
errors.push(`"${step.name}" step is missing a value for the condition`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -264,20 +274,37 @@ export default function WorkflowEditorPage() {
|
||||
errors.push('Workflow must have a trigger step');
|
||||
}
|
||||
|
||||
// Check for CONDITION steps that don't have both yes and no branches
|
||||
// Check for CONDITION steps that don't have all required branches connected
|
||||
workflow.steps.forEach(step => {
|
||||
if (step.type === 'CONDITION' && step.outgoingTransitions) {
|
||||
const hasYesBranch = step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {};
|
||||
|
||||
// Determine expected branches based on mode
|
||||
let expectedBranches: string[];
|
||||
if ((config as any).mode === 'multi' && Array.isArray((config as any).branches)) {
|
||||
expectedBranches = [...(config as any).branches.map((b: any) => b.id), 'default'];
|
||||
} else {
|
||||
expectedBranches = ['yes', 'no'];
|
||||
}
|
||||
|
||||
const missingBranches = expectedBranches.filter(branchId => {
|
||||
return !step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasYesBranch || !hasNoBranch) {
|
||||
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
|
||||
if (missingBranches.length > 0) {
|
||||
if ((config as any).mode === 'multi') {
|
||||
const branchNames = missingBranches.map(id => {
|
||||
if (id === 'default') return 'Default';
|
||||
const branch = (config as any).branches?.find((b: any) => b.id === id);
|
||||
return branch?.name || id;
|
||||
});
|
||||
errors.push(`"${step.name}" condition step is missing connections for: ${branchNames.join(', ')}`);
|
||||
} else {
|
||||
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1839,6 +1866,59 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
);
|
||||
|
||||
// CONDITION fields - handle both old format (object) and new format (string)
|
||||
const [conditionMode, setConditionMode] = useState<'binary' | 'multi'>(() => {
|
||||
return config?.mode === 'multi' ? 'multi' : 'binary';
|
||||
});
|
||||
|
||||
// Helper to check if switching from multi to binary is safe
|
||||
const hasMultiBranchConnections = () => {
|
||||
if (step.type !== 'CONDITION') return false;
|
||||
if (config?.mode !== 'multi') return false;
|
||||
|
||||
const transitions = (step as any).outgoingTransitions || [];
|
||||
// Check if any transition has a branch that's not 'yes' or 'no' (multi-branch specific)
|
||||
return transitions.some((t: any) => {
|
||||
const condition = t.condition;
|
||||
if (condition && typeof condition === 'object' && 'branch' in condition) {
|
||||
const branch = condition.branch as string;
|
||||
// Multi-branch specific branches (not the simple if/else branches)
|
||||
return branch !== 'yes' && branch !== 'no';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to check if switching from binary to multi is safe
|
||||
const hasBinaryConnections = () => {
|
||||
if (step.type !== 'CONDITION') return false;
|
||||
if (config?.mode === 'multi') return false;
|
||||
|
||||
const transitions = (step as any).outgoingTransitions || [];
|
||||
// Check if any transition has 'yes' or 'no' branches (binary-specific)
|
||||
return transitions.some((t: any) => {
|
||||
const condition = t.condition;
|
||||
if (condition && typeof condition === 'object' && 'branch' in condition) {
|
||||
const branch = condition.branch as string;
|
||||
return branch === 'yes' || branch === 'no';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Safe mode change handler
|
||||
const handleModeChange = (newMode: 'binary' | 'multi') => {
|
||||
// If switching from multi to binary, check for multi-branch connections
|
||||
if (conditionMode === 'multi' && newMode === 'binary' && hasMultiBranchConnections()) {
|
||||
// Don't allow the switch - user needs to disconnect branches first
|
||||
return;
|
||||
}
|
||||
// If switching from binary to multi, check for binary connections
|
||||
if (conditionMode === 'binary' && newMode === 'multi' && hasBinaryConnections()) {
|
||||
// Don't allow the switch - user needs to disconnect branches first
|
||||
return;
|
||||
}
|
||||
setConditionMode(newMode);
|
||||
};
|
||||
const [conditionField, setConditionField] = useState(() => {
|
||||
if (!config?.field) return '';
|
||||
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
|
||||
@@ -1850,6 +1930,19 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
});
|
||||
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
|
||||
const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
|
||||
const [conditionBranches, setConditionBranches] = useState<
|
||||
Array<{id: string; name: string; operator: string; value: string}>
|
||||
>(() => {
|
||||
if (config?.mode === 'multi' && Array.isArray(config.branches)) {
|
||||
return config.branches.map((b: any) => ({
|
||||
id: String(b.id || crypto.randomUUID().slice(0, 8)),
|
||||
name: String(b.name || ''),
|
||||
operator: String(b.operator || 'equals'),
|
||||
value: String(b.value ?? ''),
|
||||
}));
|
||||
}
|
||||
return [{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''}];
|
||||
});
|
||||
const [availableFields, setAvailableFields] = useState<Array<{field: string; type: string; category: string}>>([]);
|
||||
const [loadingFields, setLoadingFields] = useState(false);
|
||||
|
||||
@@ -2020,17 +2113,50 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
}
|
||||
newConfig = {amount, unit: delayUnit};
|
||||
} else if (step.type === 'CONDITION') {
|
||||
// Parse the value based on type
|
||||
let parsedValue: string | number | boolean = conditionValue;
|
||||
if (conditionValue === 'true') parsedValue = true;
|
||||
else if (conditionValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
|
||||
if (conditionMode === 'multi') {
|
||||
// Validate branches
|
||||
const validBranches = conditionBranches.filter(b => b.name.trim());
|
||||
if (validBranches.length === 0) {
|
||||
toast.error('At least one branch with a name is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!conditionField) {
|
||||
toast.error('Please select a field');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
newConfig = {
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: parsedValue,
|
||||
};
|
||||
newConfig = {
|
||||
mode: 'multi' as const,
|
||||
field: conditionField,
|
||||
branches: validBranches.map(b => {
|
||||
let parsedValue: string | number | boolean = b.value;
|
||||
if (b.value === 'true') parsedValue = true;
|
||||
else if (b.value === 'false') parsedValue = false;
|
||||
else if (b.value !== '' && !isNaN(Number(b.value))) parsedValue = Number(b.value);
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name.trim(),
|
||||
operator: b.operator,
|
||||
value: parsedValue,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Parse the value based on type
|
||||
let parsedValue: string | number | boolean = conditionValue;
|
||||
if (conditionValue === 'true') parsedValue = true;
|
||||
else if (conditionValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
|
||||
|
||||
newConfig = {
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: parsedValue,
|
||||
};
|
||||
}
|
||||
} else if (step.type === 'EXIT') {
|
||||
newConfig = {reason: exitReason};
|
||||
} else if (step.type === 'WEBHOOK') {
|
||||
@@ -2318,10 +2444,71 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pl-3">
|
||||
<p className="text-xs text-neutral-600">
|
||||
Define the condition that will be evaluated to determine which path the workflow should follow
|
||||
</p>
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('binary')}
|
||||
disabled={conditionMode === 'multi' && hasMultiBranchConnections()}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
conditionMode === 'binary'
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: conditionMode === 'multi' && hasMultiBranchConnections()
|
||||
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Simple (If/Else)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('multi')}
|
||||
disabled={conditionMode === 'binary' && hasBinaryConnections()}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
conditionMode === 'multi'
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: conditionMode === 'binary' && hasBinaryConnections()
|
||||
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Multi-branch (Switch)
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
{conditionMode === 'binary'
|
||||
? 'Evaluates a single condition with Yes/No paths'
|
||||
: 'Match a field against multiple values, each routing to its own branch'}
|
||||
</p>
|
||||
{hasMultiBranchConnections() && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections
|
||||
first.
|
||||
</div>
|
||||
)}
|
||||
{hasBinaryConnections() && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch
|
||||
connections first.
|
||||
</div>
|
||||
)}
|
||||
{conditionMode !== (config?.mode === 'multi' ? 'multi' : 'binary') &&
|
||||
!hasMultiBranchConnections() &&
|
||||
!hasBinaryConnections() &&
|
||||
(step as any).outgoingTransitions &&
|
||||
(step as any).outgoingTransitions.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Changing mode will disconnect existing branches. You will need to rewire them.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field selector (shared between modes) */}
|
||||
<div>
|
||||
<Label htmlFor="editConditionField">Field to Check *</Label>
|
||||
{loadingFields ? (
|
||||
@@ -2396,80 +2583,199 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editConditionOperator">Operator *</Label>
|
||||
<Select value={conditionOperator} onValueChange={setConditionOperator}>
|
||||
<SelectTrigger id="editConditionOperator" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentFieldType && (
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Showing operators for{' '}
|
||||
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div>
|
||||
<Label htmlFor="editConditionValue">Value *</Label>
|
||||
{currentFieldType === 'boolean' ? (
|
||||
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
|
||||
<SelectTrigger id="editConditionValue" className="mt-1.5">
|
||||
{/* Binary mode: single operator + value */}
|
||||
{conditionMode === 'binary' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="editConditionOperator">Operator *</Label>
|
||||
<Select value={conditionOperator} onValueChange={setConditionOperator}>
|
||||
<SelectTrigger id="editConditionOperator" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentFieldType === 'number' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., 100"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
|
||||
</>
|
||||
) : currentFieldType === 'date' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="datetime-local"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="text"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., premium, active"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
|
||||
</>
|
||||
{currentFieldType && (
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Showing operators for{' '}
|
||||
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div>
|
||||
<Label htmlFor="editConditionValue">Value *</Label>
|
||||
{currentFieldType === 'boolean' ? (
|
||||
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
|
||||
<SelectTrigger id="editConditionValue" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentFieldType === 'number' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., 100"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
|
||||
</>
|
||||
) : currentFieldType === 'date' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="datetime-local"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="text"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., premium, active"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Multi-branch mode: dynamic list of branches */}
|
||||
{conditionMode === 'multi' && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Branches</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Each branch defines a condition. The first matching branch is taken. Contacts not matching any
|
||||
branch follow the Default path.
|
||||
</p>
|
||||
|
||||
{conditionBranches.map((branch, idx) => (
|
||||
<div
|
||||
key={branch.id}
|
||||
className="p-3 border border-neutral-200 rounded-lg space-y-3 bg-neutral-50/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">Branch {idx + 1}</span>
|
||||
{conditionBranches.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConditionBranches(prev => prev.filter(b => b.id !== branch.id))}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Name *</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={branch.name}
|
||||
onChange={e => {
|
||||
const newName = e.target.value;
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, name: newName} : b)),
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Premium, Free, Enterprise"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={branch.operator}
|
||||
onValueChange={val =>
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, operator: val} : b)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!['exists', 'notExists'].includes(branch.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={branch.value}
|
||||
onChange={e => {
|
||||
const newValue = e.target.value;
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, value: newValue} : b)),
|
||||
);
|
||||
}}
|
||||
placeholder="Value..."
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{conditionBranches.length < 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setConditionBranches(prev => [
|
||||
...prev,
|
||||
{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''},
|
||||
])
|
||||
}
|
||||
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="p-2 bg-neutral-100 rounded-lg text-xs text-neutral-600 flex items-start gap-2">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
Branches are evaluated in order. The first match wins. Contacts not matching any branch will
|
||||
follow the <strong>Default</strong> path.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,23 +26,23 @@ export default function Layout({children}: {children: ReactNode}) {
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://next-wiki.useplunk.com/" />
|
||||
<meta property="og:url" content="https://docs.useplunk.com/" />
|
||||
<meta property="og:title" content="Plunk Documentation" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
|
||||
/>
|
||||
<meta property="og:image" content="https://next-wiki.useplunk.com/assets/card.png" />
|
||||
<meta property="og:image" content="https://docs.useplunk.com/assets/card.png" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://next-wiki.useplunk.com/" />
|
||||
<meta property="twitter:url" content="https://docs.useplunk.com/" />
|
||||
<meta property="twitter:title" content="Plunk Documentation" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
|
||||
/>
|
||||
<meta property="twitter:image" content="https://next-wiki.useplunk.com/assets/card.png" />
|
||||
<meta property="twitter:image" content="https://docs.useplunk.com/assets/card.png" />
|
||||
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -337,6 +337,6 @@ curl -X POST {{API_URL}}/v1/send \
|
||||
|
||||
## What's next
|
||||
|
||||
- [Send your first email](/getting-started/quick-start)
|
||||
- [View detailed endpoint docs](/api-reference/public-api)
|
||||
- [Check error codes](/api-reference/errors)
|
||||
- [Send a transactional email](/api-reference/public-api/sendEmail)
|
||||
- [Track an event](/api-reference/public-api/trackEvent)
|
||||
- [Error codes reference](/api-reference/errors)
|
||||
|
||||
@@ -3,64 +3,118 @@ title: Environment Variables
|
||||
description: Configuration reference
|
||||
---
|
||||
|
||||
## Required
|
||||
## Security & Database
|
||||
|
||||
```bash
|
||||
# Security
|
||||
JWT_SECRET="your-secret" # openssl rand -base64 32
|
||||
DB_PASSWORD="your-password"
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `JWT_SECRET` | Yes | Secret key used to sign JWT tokens. Generate with `openssl rand -base64 32`. | `s3cr3t...` |
|
||||
| `DB_PASSWORD` | Yes | PostgreSQL database password. Used by the Docker Compose setup. | `changeme123` |
|
||||
| `DATABASE_URL` | Yes | Full PostgreSQL connection string. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
|
||||
| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` |
|
||||
| `PORT` | No | Port the API server listens on. | `8080` (default) |
|
||||
|
||||
# Database (auto-configured in Docker)
|
||||
DATABASE_URL="postgresql://plunk:password@postgres:5432/plunk"
|
||||
REDIS_URL="redis://redis:6379"
|
||||
## URLs & Domains
|
||||
|
||||
# AWS SES
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="your-key"
|
||||
AWS_SES_SECRET_ACCESS_KEY="your-secret"
|
||||
SES_CONFIGURATION_SET="plunk-tracking"
|
||||
```
|
||||
Set your subdomains here. The application automatically derives all internal and client-side URLs from these at container startup — you don't need to set `*_URI` or `NEXT_PUBLIC_*` variables manually.
|
||||
|
||||
## Domains
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `API_DOMAIN` | Yes | Subdomain for the API server. | `api.yourdomain.com` |
|
||||
| `DASHBOARD_DOMAIN` | Yes | Subdomain for the dashboard app. | `app.yourdomain.com` |
|
||||
| `LANDING_DOMAIN` | Yes | Subdomain for the landing page. | `www.yourdomain.com` |
|
||||
| `WIKI_DOMAIN` | Yes | Subdomain for the documentation site. | `docs.yourdomain.com` |
|
||||
| `USE_HTTPS` | No | Set to `true` when running behind a TLS-terminating reverse proxy. Used to construct URLs with the correct protocol. | `false` (default) |
|
||||
|
||||
```bash
|
||||
# Subdomains for routing
|
||||
API_DOMAIN="api.yourdomain.com"
|
||||
DASHBOARD_DOMAIN="app.yourdomain.com"
|
||||
LANDING_DOMAIN="www.yourdomain.com"
|
||||
WIKI_DOMAIN="docs.yourdomain.com"
|
||||
SMTP_DOMAIN="smtp.yourdomain.com"
|
||||
## AWS SES
|
||||
|
||||
# Protocol
|
||||
USE_HTTPS="true" # false for local dev
|
||||
```
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `AWS_SES_REGION` | Yes | AWS region where SES is configured. | `us-east-1` |
|
||||
| `AWS_SES_ACCESS_KEY_ID` | Yes | AWS access key ID with SES send permissions. | `AKIA...` |
|
||||
| `AWS_SES_SECRET_ACCESS_KEY` | Yes | AWS secret access key for SES. | `wJalr...` |
|
||||
| `SES_CONFIGURATION_SET` | No | SES configuration set name used for open/click tracking. | `plunk-configuration-set` (default) |
|
||||
| `SES_CONFIGURATION_SET_NO_TRACKING` | No | A second SES configuration set without tracking. When set, projects can toggle email tracking on/off. If omitted, the tracking toggle is hidden. | `plunk-no-tracking-configuration-set` (default) |
|
||||
|
||||
## Storage (Minio)
|
||||
|
||||
Defaults work with bundled Minio. Only set for external S3:
|
||||
The bundled Docker setup includes Minio with defaults that work out of the box. Only change these when connecting to an external S3-compatible bucket.
|
||||
|
||||
```bash
|
||||
S3_ENDPOINT="http://minio:9000"
|
||||
S3_ACCESS_KEY_ID="plunk"
|
||||
S3_ACCESS_KEY_SECRET="plunkminiopass"
|
||||
S3_BUCKET="uploads"
|
||||
S3_PUBLIC_URL="http://localhost:9000/uploads"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
```
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `MINIO_ROOT_USER` | No | Minio root username (Docker Compose only). | `plunk` |
|
||||
| `MINIO_ROOT_PASSWORD` | No | Minio root password (Docker Compose only). | `plunkminiopass` |
|
||||
| `MINIO_API_PORT` | No | Port for the Minio API (Docker Compose only). | `9000` |
|
||||
| `MINIO_CONSOLE_PORT` | No | Port for the Minio console UI (Docker Compose only). | `9001` |
|
||||
| `S3_ENDPOINT` | No | S3 or Minio endpoint URL. | `http://minio:9000` |
|
||||
| `S3_ACCESS_KEY_ID` | No | S3 or Minio access key. | — |
|
||||
| `S3_ACCESS_KEY_SECRET` | No | S3 or Minio secret key. | — |
|
||||
| `S3_BUCKET` | No | Bucket name for file uploads. | `uploads` |
|
||||
| `S3_PUBLIC_URL` | No | Publicly accessible base URL for stored files. | — |
|
||||
| `S3_FORCE_PATH_STYLE` | No | Use path-style URLs instead of virtual-hosted. Required for Minio. | `true` |
|
||||
|
||||
## Optional
|
||||
## SMTP Server
|
||||
|
||||
```bash
|
||||
# OAuth
|
||||
GITHUB_OAUTH_CLIENT="client-id"
|
||||
GITHUB_OAUTH_SECRET="client-secret"
|
||||
GOOGLE_OAUTH_CLIENT="client-id"
|
||||
GOOGLE_OAUTH_SECRET="client-secret"
|
||||
The optional SMTP relay lets you send emails through Plunk via the SMTP protocol.
|
||||
|
||||
# Stripe
|
||||
STRIPE_SK="sk_..."
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `SMTP_DOMAIN` | No | SMTP relay domain. Required when using Traefik's `acme.json` with multiple certificates so the correct cert can be selected. | `localhost` |
|
||||
| `SMTP_ENABLED` | No | Explicitly enable SMTP features in the UI. Automatically enabled when `SMTP_DOMAIN` is set to a non-localhost value in production. | `false` |
|
||||
| `PORT_SECURE` | No | SMTPS port (implicit TLS). | `465` |
|
||||
| `PORT_SUBMISSION` | No | SMTP submission port (STARTTLS). | `587` |
|
||||
| `MAX_RECIPIENTS` | No | Maximum number of recipients per email. | `5` |
|
||||
|
||||
# Notifications
|
||||
NTFY_URL="http://ntfy/plunk-notifications"
|
||||
```
|
||||
## OAuth
|
||||
|
||||
Enables social login. Register an OAuth app with each provider and add the credentials here.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_OAUTH_CLIENT` | No | GitHub OAuth app client ID. |
|
||||
| `GITHUB_OAUTH_SECRET` | No | GitHub OAuth app client secret. |
|
||||
| `GOOGLE_OAUTH_CLIENT` | No | Google OAuth app client ID. |
|
||||
| `GOOGLE_OAUTH_SECRET` | No | Google OAuth app client secret. |
|
||||
|
||||
## Stripe
|
||||
|
||||
Required if you want to enable billing features. All Stripe variables must be set together for billing to activate.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `STRIPE_SK` | No | Stripe secret key. |
|
||||
| `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook signing secret for verifying events. |
|
||||
| `STRIPE_PRICE_ONBOARDING` | No | Stripe price ID for the one-time onboarding fee. |
|
||||
| `STRIPE_PRICE_EMAIL_USAGE` | No | Stripe price ID for metered pay-per-email usage. |
|
||||
| `STRIPE_METER_EVENT_NAME` | No | Stripe meter event name. | `emails` (default) |
|
||||
|
||||
## Platform Emails
|
||||
|
||||
When configured, Plunk will send email notifications to users for critical events (e.g. project disabled, billing limits reached). Without these, only ntfy notifications are sent.
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `PLUNK_API_KEY` | No | API key for a Plunk instance to send transactional emails. | `pk_...` |
|
||||
| `PLUNK_FROM_ADDRESS` | No | From address used for platform notification emails. | `[email protected]` |
|
||||
|
||||
## Notifications (ntfy)
|
||||
|
||||
Plunk bundles a self-hosted [ntfy](https://ntfy.sh) server for internal system notifications.
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `NTFY_PORT` | No | Port for the ntfy web UI (Docker Compose only). | `8080` |
|
||||
| `NTFY_URL` | No | ntfy topic URL. Change this to use an external ntfy.sh server or your own instance. | `http://ntfy/plunk-notifications` |
|
||||
|
||||
## User Management
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `DISABLE_SIGNUPS` | No | When `true`, the signup endpoint rejects new registrations. Useful for private instances. | `false` |
|
||||
| `VERIFY_EMAIL_ON_SIGNUP` | No | When `true`, validates emails on signup — checks for disposable domains, plus-addressing, domain existence, and MX records. | `false` |
|
||||
|
||||
## Security
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `AUTO_PROJECT_DISABLE` | No | When `true`, projects are automatically suspended when bounce or complaint rate thresholds are exceeded. Set to `false` to manage project status manually. | `true` |
|
||||
| `EMAIL_RATE_LIMIT_PER_SECOND` | No | Override the email sending rate limit. If not set, Plunk automatically fetches the quota from your AWS SES account. | — |
|
||||
|
||||
@@ -46,7 +46,7 @@ function filePathToUrl(filePath) {
|
||||
}
|
||||
|
||||
const config = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_WIKI_URI || 'https://next-wiki.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_WIKI_URI || 'https://docs.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
additionalPaths: async () => {
|
||||
const contentDocsPath = path.join(__dirname, 'content', 'docs');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "Plunk Support",
|
||||
"url": "https://next.useplunk.com"
|
||||
"url": "https://www.useplunk.com"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"fumadocs-openapi": "^10.0.11",
|
||||
"fumadocs-ui": "16.0.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -19,8 +19,8 @@ echo "🔍 Generating URL manifest for $APP_NAME..."
|
||||
# Define placeholder URLs that will be replaced at runtime
|
||||
PLACEHOLDER_API="https://next-api.useplunk.com"
|
||||
PLACEHOLDER_DASHBOARD="https://next-app.useplunk.com"
|
||||
PLACEHOLDER_LANDING="https://next.useplunk.com"
|
||||
PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
||||
PLACEHOLDER_LANDING="https://www.useplunk.com"
|
||||
PLACEHOLDER_WIKI="https://docs.useplunk.com"
|
||||
|
||||
# Output manifest file
|
||||
MANIFEST_FILE="$APP_DIR/.next/url-manifest.txt"
|
||||
|
||||
@@ -13,8 +13,8 @@ replace_urls_in_app() {
|
||||
# Define placeholder URLs (built into the app at compile time)
|
||||
local PLACEHOLDER_API="https://next-api.useplunk.com"
|
||||
local PLACEHOLDER_DASHBOARD="https://next-app.useplunk.com"
|
||||
local PLACEHOLDER_LANDING="https://next.useplunk.com"
|
||||
local PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
||||
local PLACEHOLDER_LANDING="https://www.useplunk.com"
|
||||
local PLACEHOLDER_WIKI="https://docs.useplunk.com"
|
||||
|
||||
# Use pre-generated manifest instead of scanning all files
|
||||
local manifest_file="$app_dir/.next/url-manifest.txt"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
||||
@@ -6,7 +6,7 @@ interface FooterProps {
|
||||
landingUrl?: string;
|
||||
}
|
||||
|
||||
export function Footer({projectId, landingUrl = 'https://next.useplunk.com'}: FooterProps) {
|
||||
export function Footer({projectId, landingUrl = 'https://www.useplunk.com'}: FooterProps) {
|
||||
return (
|
||||
<Section className="border-t border-gray-100 bg-gray-50 px-8 py-8">
|
||||
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-600">
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Header() {
|
||||
return (
|
||||
<Section className="border-b border-gray-100 bg-white px-8 py-8">
|
||||
<Img
|
||||
src="https://next.useplunk.com/assets/logo.png"
|
||||
src="https://www.useplunk.com/assets/logo.png"
|
||||
alt="Plunk"
|
||||
width="40"
|
||||
height="40"
|
||||
|
||||
@@ -21,7 +21,7 @@ export function BillingLimitExceededEmail({
|
||||
limit = 10000,
|
||||
sourceType = 'Transactional',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: BillingLimitExceededEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function BillingLimitWarningEmail({
|
||||
percentage = 85,
|
||||
sourceType = 'Transactional',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: BillingLimitWarningEmailProps) {
|
||||
const percentageRounded = Math.round(percentage);
|
||||
const remaining = limit - usage;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DomainUnverifiedEmail({
|
||||
projectId = 'proj_example123',
|
||||
domain = 'example.com',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: DomainUnverifiedEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DomainVerifiedEmail({
|
||||
projectId = 'proj_example123',
|
||||
domain = 'example.com',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: DomainVerifiedEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface EmailVerificationEmailProps {
|
||||
export function EmailVerificationEmail({
|
||||
email = '[email protected]',
|
||||
verificationUrl = 'https://api.useplunk.com/auth/verify-email?token=abc123',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: EmailVerificationEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PasswordResetEmailProps {
|
||||
export function PasswordResetEmail({
|
||||
email = '[email protected]',
|
||||
resetUrl = 'https://next-app.useplunk.com/auth/reset-password?token=abc123',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: PasswordResetEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ProjectDisabledEmail({
|
||||
projectId = 'proj_example123',
|
||||
violations = ['Bounce rate exceeded 10% threshold', 'Complaint rate exceeded 0.5% threshold'],
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: ProjectDisabledEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -10,6 +10,10 @@ import bgTranslations from './locales/bg.json' with {type: 'json'};
|
||||
import csTranslations from './locales/cs.json' with {type: 'json'};
|
||||
import plTranslations from './locales/pl.json' with {type: 'json'};
|
||||
import esTranslations from './locales/es.json' with {type: 'json'};
|
||||
import itTranslations from './locales/it.json' with {type: 'json'};
|
||||
import zhTWTranslations from './locales/zh-TW.json' with {type: 'json'};
|
||||
import zhHKTranslations from './locales/zh-HK.json' with {type: 'json'};
|
||||
import zhCNTranslations from './locales/zh-CN.json' with {type: 'json'};
|
||||
|
||||
export {
|
||||
SUPPORTED_LANGUAGES,
|
||||
@@ -45,6 +49,10 @@ const translationsMap: Record<string, Translations> = {
|
||||
cs: csTranslations,
|
||||
pl: plTranslations,
|
||||
es: esTranslations,
|
||||
it: itTranslations,
|
||||
'zh-TW': zhTWTranslations,
|
||||
'zh-HK': zhHKTranslations,
|
||||
'zh-CN': zhCNTranslations,
|
||||
};
|
||||
|
||||
// In-memory cache for loaded translations
|
||||
|
||||
@@ -16,6 +16,10 @@ export const SUPPORTED_LANGUAGES: Language[] = [
|
||||
{code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'},
|
||||
{code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'},
|
||||
{code: 'es', name: 'Spanish (Spain)', nativeName: 'Español (España)', flag: '🇪🇸'},
|
||||
{code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹'},
|
||||
{code: 'zh-TW', name: 'Chinese (Traditional, Taiwan)', nativeName: '繁體中文', flag: '🇹🇼'},
|
||||
{code: 'zh-HK', name: 'Chinese (Traditional, Hong Kong)', nativeName: '繁體中文(香港)', flag: '🇭🇰'},
|
||||
{code: 'zh-CN', name: 'Chinese (Simplified, China)', nativeName: '简体中文', flag: '🇨🇳'},
|
||||
];
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "Annulla l'iscrizione",
|
||||
"description": "Ci dispiace vederti andare via. Sei sicuro di voler annullare l'iscrizione per {email} dalla ricezione di e-mail?",
|
||||
"button": "Annulla iscrizione",
|
||||
"buttonLoading": "Annullamento in corso...",
|
||||
"managePreferences": "Gestisci invece le preferenze",
|
||||
"successTitle": "Iscrizione annullata",
|
||||
"successDescription": "L'iscrizione per {email} è stata annullata. Non riceverai più nostre e-mail.",
|
||||
"changedMind": "Hai cambiato idea?",
|
||||
"subscribeAgain": "Iscriviti di nuovo"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "Iscriviti agli aggiornamenti",
|
||||
"description": "Vuoi iscrivere {email} per ricevere e-mail?",
|
||||
"button": "Iscriviti",
|
||||
"buttonLoading": "Iscrizione in corso...",
|
||||
"successTitle": "Ti sei iscritto!",
|
||||
"successDescription": "{email} è ora iscritto per ricevere nostre e-mail."
|
||||
},
|
||||
"manage": {
|
||||
"title": "Gestisci le preferenze",
|
||||
"description": "Gestisci le preferenze e-mail per {email}",
|
||||
"subscriptionLabel": "Iscrizione e-mail",
|
||||
"subscribedStatus": "Al momento sei iscritto per ricevere e-mail",
|
||||
"unsubscribedStatus": "Al momento l'iscrizione alle e-mail è annullata",
|
||||
"subscribedSuccess": "Iscrizione avvenuta con successo!",
|
||||
"unsubscribedSuccess": "Iscrizione annullata con successo!",
|
||||
"unsubscribeCompletely": "Annulla completamente l'iscrizione",
|
||||
"subscribeToEmails": "Iscriviti alle e-mail",
|
||||
"disclaimer": "Questa pagina ti consente di gestire le tue preferenze e-mail. Lo stato della tua iscrizione viene aggiornato in tempo reale."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Caricamento in corso...",
|
||||
"error": "Errore"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "Hai ricevuto questa e-mail perché hai accettato di ricevere e-mail da {projectName}. Se non desideri più ricevere e-mail di questo tipo, ti preghiamo di",
|
||||
"updatePreferences": "aggiornare le tue preferenze"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "取消订阅",
|
||||
"description": "确定要取消 {email} 的邮件订阅吗?",
|
||||
"button": "取消订阅",
|
||||
"buttonLoading": "处理中...",
|
||||
"managePreferences": "改为管理偏好设置",
|
||||
"successTitle": "已取消订阅",
|
||||
"successDescription": "{email} 已取消订阅,你将不会再收到我们的邮件。",
|
||||
"changedMind": "改变主意了吗?",
|
||||
"subscribeAgain": "重新订阅"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "订阅最新消息",
|
||||
"description": "要为 {email} 订阅邮件吗?",
|
||||
"button": "订阅",
|
||||
"buttonLoading": "处理中...",
|
||||
"successTitle": "订阅成功!",
|
||||
"successDescription": "{email} 已订阅,你将会收到我们的最新消息。"
|
||||
},
|
||||
"manage": {
|
||||
"title": "管理偏好设置",
|
||||
"description": "管理 {email} 的邮件偏好设置",
|
||||
"subscriptionLabel": "邮件订阅",
|
||||
"subscribedStatus": "目前已订阅,会收到邮件通知",
|
||||
"unsubscribedStatus": "目前已取消订阅",
|
||||
"subscribedSuccess": "订阅成功!",
|
||||
"unsubscribedSuccess": "已取消订阅!",
|
||||
"unsubscribeCompletely": "取消订阅",
|
||||
"subscribeToEmails": "订阅邮件",
|
||||
"disclaimer": "你可以在此管理邮件偏好设置,订阅状态会实时更新。"
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"error": "发生错误"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "你收到这封邮件是因为你订阅了 {projectName} 的邮件。如果你不想再收到这类邮件,请",
|
||||
"updatePreferences": "更新你的偏好设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "取消訂閱",
|
||||
"description": "確定要取消 {email} 的電郵訂閱嗎?",
|
||||
"button": "取消訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"managePreferences": "改為管理偏好設定",
|
||||
"successTitle": "已取消訂閱",
|
||||
"successDescription": "{email} 已取消訂閱,你將不會再收到我們的電郵。",
|
||||
"changedMind": "改變主意了嗎?",
|
||||
"subscribeAgain": "重新訂閱"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "訂閱最新消息",
|
||||
"description": "要為 {email} 訂閱電郵嗎?",
|
||||
"button": "訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"successTitle": "訂閱成功!",
|
||||
"successDescription": "{email} 已訂閱,你將會收到我們的最新消息。"
|
||||
},
|
||||
"manage": {
|
||||
"title": "管理偏好設定",
|
||||
"description": "管理 {email} 的電郵偏好設定",
|
||||
"subscriptionLabel": "電郵訂閱",
|
||||
"subscribedStatus": "目前已訂閱,會收到電郵通知",
|
||||
"unsubscribedStatus": "目前已取消訂閱",
|
||||
"subscribedSuccess": "訂閱成功!",
|
||||
"unsubscribedSuccess": "已取消訂閱!",
|
||||
"unsubscribeCompletely": "取消訂閱",
|
||||
"subscribeToEmails": "訂閱電郵",
|
||||
"disclaimer": "你可以在此管理電郵偏好設定,訂閱狀態會即時更新。"
|
||||
},
|
||||
"common": {
|
||||
"loading": "載入中...",
|
||||
"error": "發生錯誤"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "你收到這封電郵是因為你訂閱了 {projectName} 的電郵。如果你不想再收到這類電郵,請",
|
||||
"updatePreferences": "更新你的偏好設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "取消訂閱",
|
||||
"description": "確定要取消 {email} 的郵件訂閱嗎?",
|
||||
"button": "取消訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"managePreferences": "改為管理偏好設定",
|
||||
"successTitle": "已取消訂閱",
|
||||
"successDescription": "{email} 已取消訂閱,你將不會再收到我們的郵件。",
|
||||
"changedMind": "改變主意了嗎?",
|
||||
"subscribeAgain": "重新訂閱"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "訂閱最新消息",
|
||||
"description": "要為 {email} 訂閱郵件嗎?",
|
||||
"button": "訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"successTitle": "訂閱成功!",
|
||||
"successDescription": "{email} 已訂閱,你將會收到我們的最新消息。"
|
||||
},
|
||||
"manage": {
|
||||
"title": "管理偏好設定",
|
||||
"description": "管理 {email} 的郵件偏好設定",
|
||||
"subscriptionLabel": "郵件訂閱",
|
||||
"subscribedStatus": "目前已訂閱,會收到郵件通知",
|
||||
"unsubscribedStatus": "目前已取消訂閱",
|
||||
"subscribedSuccess": "訂閱成功!",
|
||||
"unsubscribedSuccess": "已取消訂閱!",
|
||||
"unsubscribeCompletely": "取消訂閱",
|
||||
"subscribeToEmails": "訂閱郵件",
|
||||
"disclaimer": "你可以在此管理郵件偏好設定,訂閱狀態會即時更新。"
|
||||
},
|
||||
"common": {
|
||||
"loading": "載入中...",
|
||||
"error": "發生錯誤"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "你收到這封信是因為你訂閱了 {projectName} 的郵件。如果你不想再收到這類郵件,請",
|
||||
"updatePreferences": "更新你的偏好設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,22 +260,52 @@ export const WorkflowStepConfigSchemas = {
|
||||
eventName: z.string().min(1),
|
||||
timeout: z.number().positive().max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)').optional(),
|
||||
}),
|
||||
condition: z.object({
|
||||
field: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'exists',
|
||||
'notExists',
|
||||
]),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
condition: z.union([
|
||||
// Legacy binary condition (if/else)
|
||||
z.object({
|
||||
field: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'exists',
|
||||
'notExists',
|
||||
]),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
// Multi-branch condition (switch/case)
|
||||
z.object({
|
||||
mode: z.literal('multi'),
|
||||
field: z.string().min(1),
|
||||
branches: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'exists',
|
||||
'notExists',
|
||||
]),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
}),
|
||||
]),
|
||||
webhook: z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'),
|
||||
@@ -370,7 +400,7 @@ export const ActionSchemas = {
|
||||
]),
|
||||
),
|
||||
]),
|
||||
subject: z.string().min(1).max(998).optional(),
|
||||
subject: z.string().min(1).max(998).regex(/^[^\r\n]*$/, 'Subject contains invalid characters').optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
template: uuid.optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
@@ -381,7 +411,7 @@ export const ActionSchemas = {
|
||||
email, // Simple email string (backward compatible)
|
||||
z.object({
|
||||
// Object with name and email
|
||||
name: z.string().optional(),
|
||||
name: z.string().regex(/^[^\r\n]*$/, 'Name contains invalid characters').optional(),
|
||||
email: email,
|
||||
}),
|
||||
],
|
||||
@@ -399,12 +429,17 @@ export const ActionSchemas = {
|
||||
)
|
||||
.optional(),
|
||||
reply: email.optional(),
|
||||
headers: z.record(z.string().max(998)).optional(),
|
||||
headers: z
|
||||
.record(
|
||||
z.string().regex(/^[^\r\n]+$/, 'Header key contains invalid characters'),
|
||||
z.string().max(998).regex(/^[^\r\n]*$/, 'Header value contains invalid characters'),
|
||||
)
|
||||
.optional(),
|
||||
data: jsonSchema.optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
filename: z.string().min(1).max(255).regex(/^[^\r\n"]+$/, 'Filename contains invalid characters'),
|
||||
content: z.string().min(1), // Base64 encoded file content
|
||||
contentType: z.string().min(1).max(255),
|
||||
contentId: z
|
||||
|
||||
@@ -2821,10 +2821,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/env@npm:16.1.6"
|
||||
checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6
|
||||
"@next/env@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/env@npm:16.1.7"
|
||||
checksum: 10c0/89a9e657b29d01be04394cd8a4c917cfdd76aec76ea9a0f7670896efe7668e665713adcf72632958b0c19ce66cf7e1f39961cfd9ba69d10c5a3e08ee20d2370a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2851,9 +2851,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-darwin-arm64@npm:16.1.6"
|
||||
"@next/swc-darwin-arm64@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-darwin-arm64@npm:16.1.7"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2865,9 +2865,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-darwin-x64@npm:16.1.6"
|
||||
"@next/swc-darwin-x64@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-darwin-x64@npm:16.1.7"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2879,9 +2879,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6"
|
||||
"@next/swc-linux-arm64-gnu@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.1.7"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2893,9 +2893,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:16.1.6"
|
||||
"@next/swc-linux-arm64-musl@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:16.1.7"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2907,9 +2907,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:16.1.6"
|
||||
"@next/swc-linux-x64-gnu@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:16.1.7"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2921,9 +2921,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-linux-x64-musl@npm:16.1.6"
|
||||
"@next/swc-linux-x64-musl@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-x64-musl@npm:16.1.7"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2935,9 +2935,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6"
|
||||
"@next/swc-win32-arm64-msvc@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.1.7"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2949,9 +2949,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:16.1.6":
|
||||
version: 16.1.6
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:16.1.6"
|
||||
"@next/swc-win32-x64-msvc@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:16.1.7"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -8203,15 +8203,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"baseline-browser-mapping@npm:^2.8.3":
|
||||
version: 2.9.18
|
||||
resolution: "baseline-browser-mapping@npm:2.9.18"
|
||||
bin:
|
||||
baseline-browser-mapping: dist/cli.js
|
||||
checksum: 10c0/869bdbb2784f8b1bc49b52d54ea48bf9ea6da8309195e3a0b3f4625197b8187a9b557b1d02f1b6b6dd51f163840a87db259e2b791eed35f0c5fddf3110c4cf28
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"baseline-browser-mapping@npm:^2.9.0":
|
||||
version: 2.9.6
|
||||
resolution: "baseline-browser-mapping@npm:2.9.6"
|
||||
@@ -8221,6 +8212,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"baseline-browser-mapping@npm:^2.9.19":
|
||||
version: 2.10.8
|
||||
resolution: "baseline-browser-mapping@npm:2.10.8"
|
||||
bin:
|
||||
baseline-browser-mapping: dist/cli.cjs
|
||||
checksum: 10c0/a77882e8ac37a900a9a0757b9bf4e50f407829ed17ee7630273ab5da0ae10d9c64ed4c5a1e03afb3490e39713440092417ded4bb856eeb9bd44856eacbd97497
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"basic-auth@npm:~2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "basic-auth@npm:2.0.1"
|
||||
@@ -11165,9 +11165,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"flatted@npm:^3.2.9, flatted@npm:^3.3.3":
|
||||
version: 3.3.3
|
||||
resolution: "flatted@npm:3.3.3"
|
||||
checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538
|
||||
version: 3.4.2
|
||||
resolution: "flatted@npm:3.4.2"
|
||||
checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12925,7 +12925,7 @@ __metadata:
|
||||
eslint-config-next: "npm:^16.0.1"
|
||||
juice: "npm:^11.0.3"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.1.5"
|
||||
next: "npm:^16.1.7"
|
||||
next-seo: "npm:^6.6.0"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
postcss: "npm:^8.4.33"
|
||||
@@ -14674,21 +14674,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^16.1.5":
|
||||
version: 16.1.6
|
||||
resolution: "next@npm:16.1.6"
|
||||
"next@npm:^16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "next@npm:16.1.7"
|
||||
dependencies:
|
||||
"@next/env": "npm:16.1.6"
|
||||
"@next/swc-darwin-arm64": "npm:16.1.6"
|
||||
"@next/swc-darwin-x64": "npm:16.1.6"
|
||||
"@next/swc-linux-arm64-gnu": "npm:16.1.6"
|
||||
"@next/swc-linux-arm64-musl": "npm:16.1.6"
|
||||
"@next/swc-linux-x64-gnu": "npm:16.1.6"
|
||||
"@next/swc-linux-x64-musl": "npm:16.1.6"
|
||||
"@next/swc-win32-arm64-msvc": "npm:16.1.6"
|
||||
"@next/swc-win32-x64-msvc": "npm:16.1.6"
|
||||
"@next/env": "npm:16.1.7"
|
||||
"@next/swc-darwin-arm64": "npm:16.1.7"
|
||||
"@next/swc-darwin-x64": "npm:16.1.7"
|
||||
"@next/swc-linux-arm64-gnu": "npm:16.1.7"
|
||||
"@next/swc-linux-arm64-musl": "npm:16.1.7"
|
||||
"@next/swc-linux-x64-gnu": "npm:16.1.7"
|
||||
"@next/swc-linux-x64-musl": "npm:16.1.7"
|
||||
"@next/swc-win32-arm64-msvc": "npm:16.1.7"
|
||||
"@next/swc-win32-x64-msvc": "npm:16.1.7"
|
||||
"@swc/helpers": "npm:0.5.15"
|
||||
baseline-browser-mapping: "npm:^2.8.3"
|
||||
baseline-browser-mapping: "npm:^2.9.19"
|
||||
caniuse-lite: "npm:^1.0.30001579"
|
||||
postcss: "npm:8.4.31"
|
||||
sharp: "npm:^0.34.4"
|
||||
@@ -14730,7 +14730,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
next: dist/bin/next
|
||||
checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b
|
||||
checksum: 10c0/f6cebb3ce57d267cd92fb364b56710004c883baafea9cfed36434c0cd51db74299d59094ab782674d9813f10ce2891b633bc6d94e7e38aa9af7a1870194818d0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15406,9 +15406,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "picomatch@npm:2.3.1"
|
||||
checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be
|
||||
version: 2.3.2
|
||||
resolution: "picomatch@npm:2.3.2"
|
||||
checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18027,15 +18027,15 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:^7.5.2":
|
||||
version: 7.5.10
|
||||
resolution: "tar@npm:7.5.10"
|
||||
version: 7.5.11
|
||||
resolution: "tar@npm:7.5.11"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
minizlib: "npm:^3.1.0"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 10c0/ed905e4b33886377df6e9206e5d1bd34458c21666e27943f946799416f86348c938590d573d6a69312cb29c583b122647a64ec92782f2b7e24e68d985dd72531
|
||||
checksum: 10c0/b6bb420550ef50ef23356018155e956cd83282c97b6128d8d5cfe5740c57582d806a244b2ef0bf686a74ce526babe8b8b9061527623e935e850008d86d838929
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19130,7 +19130,7 @@ __metadata:
|
||||
framer-motion: "npm:^12.23.24"
|
||||
juice: "npm:^11.0.3"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.1.5"
|
||||
next: "npm:^16.1.7"
|
||||
next-seo: "npm:^6.6.0"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
nuqs: "npm:^2.7.3"
|
||||
@@ -19327,7 +19327,7 @@ __metadata:
|
||||
fumadocs-openapi: "npm:^10.0.11"
|
||||
fumadocs-ui: "npm:16.0.8"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.1.5"
|
||||
next: "npm:^16.1.7"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
postcss: "npm:^8.4.33"
|
||||
react: "npm:^19.2.3"
|
||||
|
||||
Reference in New Issue
Block a user