Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e450ae62a3 | ||
|
|
d56e876428 | ||
|
|
b79d416667 | ||
|
|
bf12392b88 | ||
|
|
ae9d5a6f3d | ||
|
|
1362e4672f | ||
|
|
35a830fa7b | ||
|
|
742821eba9 | ||
|
|
91f80de981 | ||
|
|
c584e42c54 | ||
|
|
545d733794 | ||
|
|
37ed1e49b7 | ||
|
|
df76be1b87 | ||
|
|
bd5a085802 | ||
|
|
bc4c79235e | ||
|
|
f2ebce0ef2 | ||
|
|
e10a94b2bc | ||
|
|
a6d5740b77 | ||
|
|
6114c21a3f | ||
|
|
4ce4c8c2a4 | ||
|
|
c3967a98b0 | ||
|
|
474c822640 | ||
|
|
c83541c3eb |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.8.0"
|
||||
".": "0.9.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [0.9.0](https://github.com/useplunk/plunk/compare/v0.8.0...v0.9.0) (2026-04-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add accept/markdown to apps/landing ([df76be1](https://github.com/useplunk/plunk/commit/df76be1b87de14769a219ae0e6c99711edf993a1))
|
||||
* Add emailId field to webhook events for better correlation with send requests ([ae64c2d](https://github.com/useplunk/plunk/commit/ae64c2dbb5b76f16b562641953f262d76ea0d8e8)), closes [#344](https://github.com/useplunk/plunk/issues/344)
|
||||
* Add headless template type ([fb5aa87](https://github.com/useplunk/plunk/commit/fb5aa8796a8550be7a70fdd91616c9a41ed3047d))
|
||||
* Add onboarding flow ([bd5a085](https://github.com/useplunk/plunk/commit/bd5a0858021b9d74fae07cca5d2a0b8c10deabd7))
|
||||
* Add type to campaign ([d24259e](https://github.com/useplunk/plunk/commit/d24259e8d202edd1b8c7771a63cc212c982b52e1))
|
||||
* Block domain changes for disabled projects with appropriate error handling ([44c8657](https://github.com/useplunk/plunk/commit/44c86572c275221b5068605c64958f43a1f96ff4))
|
||||
* Disable projects on failed payment ([3343e89](https://github.com/useplunk/plunk/commit/3343e891bdbcfee7d17d73a0d7fb181652e7c262))
|
||||
* Enhance email processing to include parsed HTML body content in inbound email records ([e339508](https://github.com/useplunk/plunk/commit/e3395083db11fdb4b6cfdbace46518792ec132a3)), closes [#342](https://github.com/useplunk/plunk/issues/342)
|
||||
* Enhance login forms with last used authentication method ([f2ebce0](https://github.com/useplunk/plunk/commit/f2ebce0ef2ac9c9c4e994cb754f67f6fa8a3f465))
|
||||
* Enhance security metrics handling with new thresholds and improved messaging ([c0b2aba](https://github.com/useplunk/plunk/commit/c0b2abaeca5656743e189f975ab82ff9b25b577b))
|
||||
* implement AWS SNS signature verification in SecurityService ([b79d416](https://github.com/useplunk/plunk/commit/b79d4166671cc04cc1458d2c24af262af0e16c9e))
|
||||
* integrate DOMPurify for sanitizing HTML content ([bf12392](https://github.com/useplunk/plunk/commit/bf12392b88fc92b0760333e1b568e3d6d1b6ed74))
|
||||
* Update new project bounce thresholds for stricter email handling ([5e62f51](https://github.com/useplunk/plunk/commit/5e62f517fc896849c270e795cd31a110b83707cc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enhance email processing to support campaign types and improve unsubscribe logic ([9e2400c](https://github.com/useplunk/plunk/commit/9e2400c6de6f13c498f02c489f956ebf89de36b0))
|
||||
* Hint custom event names in combobox when no matches are found ([3214f6c](https://github.com/useplunk/plunk/commit/3214f6c42d4ca6ab628e905ac69ed82648ccf47c))
|
||||
* Implement content negotiation for markdown and html in middleware ([37ed1e4](https://github.com/useplunk/plunk/commit/37ed1e49b78eb9339e7b62fd2fd5b60d451707d6))
|
||||
* Implement SSRF protection in webhook handling with safeFetch method ([2c5a715](https://github.com/useplunk/plunk/commit/2c5a71518da358927bd5e41035b85fd278790b47))
|
||||
* Refactor date filtering logic for pagination in ActivityFeed and ActivityService ([52cb2b6](https://github.com/useplunk/plunk/commit/52cb2b6c77bc3912b356887bdd48e9c8b9b1728a))
|
||||
* Update language validation regex to support locale variants ([7834b9e](https://github.com/useplunk/plunk/commit/7834b9e7ef615ba029a1a091b0c37c8f40eb543c))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add content negotiation to apps/wiki ([545d733](https://github.com/useplunk/plunk/commit/545d7337948f41f006f9cb0270b4bfff2b08ed2c))
|
||||
|
||||
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ export class Webhooks {
|
||||
@CatchAsync
|
||||
public async receiveSNSWebhook(req: Request, res: Response) {
|
||||
try {
|
||||
// Verify SNS message signature before processing anything
|
||||
const signatureValid = await SecurityService.verifySnsSignature(req.body as Record<string, string>);
|
||||
if (!signatureValid) {
|
||||
signale.warn('[WEBHOOK] SNS signature verification failed — request rejected');
|
||||
return res.status(403).json({success: false, message: 'Invalid SNS signature'});
|
||||
}
|
||||
|
||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||
signale.info('SNS Subscription Confirmation received');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
@@ -84,9 +86,76 @@ interface SecurityStatus {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const SNS_CERT_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|cn)$/;
|
||||
const snsSigningCertCache = new Map<string, string>();
|
||||
|
||||
async function fetchSigningCert(certUrl: string): Promise<string> {
|
||||
const cached = snsSigningCertCache.get(certUrl);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(certUrl);
|
||||
if (!response.ok) throw new Error(`Failed to fetch SNS signing cert: ${response.statusText}`);
|
||||
|
||||
const pem = await response.text();
|
||||
snsSigningCertCache.set(certUrl, pem);
|
||||
return pem;
|
||||
}
|
||||
|
||||
function buildSnsStringToSign(message: Record<string, string>): string {
|
||||
const fields =
|
||||
message['Type'] === 'Notification'
|
||||
? ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type']
|
||||
: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'];
|
||||
|
||||
return fields
|
||||
.filter(key => message[key] !== undefined)
|
||||
.map(key => `${key}\n${message[key]}\n`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export class SecurityService {
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Verify an AWS SNS message signature. Returns false if the cert URL is
|
||||
* untrusted, or the signature doesn't match.
|
||||
*/
|
||||
public static async verifySnsSignature(body: Record<string, string>): Promise<boolean> {
|
||||
try {
|
||||
const certUrl = body['SigningCertURL'];
|
||||
const signature = body['Signature'];
|
||||
|
||||
if (!certUrl || !signature) {
|
||||
signale.warn('[SNS] Missing SigningCertURL or Signature');
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(certUrl);
|
||||
} catch {
|
||||
signale.warn('[SNS] Unparseable SigningCertURL');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'https:' || !SNS_CERT_HOST_RE.test(parsedUrl.hostname)) {
|
||||
signale.warn(`[SNS] Untrusted SigningCertURL host: ${parsedUrl.hostname}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pem = await fetchSigningCert(certUrl);
|
||||
const stringToSign = buildSnsStringToSign(body);
|
||||
const algorithm = body['SignatureVersion'] === '2' ? 'RSA-SHA256' : 'RSA-SHA1';
|
||||
|
||||
const verifier = crypto.createVerify(algorithm);
|
||||
verifier.update(stringToSign, 'utf8');
|
||||
return verifier.verify(pem, signature, 'base64');
|
||||
} catch (err) {
|
||||
signale.error('[SNS] Signature verification error:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security status for a project (with caching)
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.7",
|
||||
"next": "^16.2.3",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
export const MARKDOWN_PAGES: Record<string, string> = {
|
||||
index: `# Plunk — The Open-Source Email Platform
|
||||
|
||||
Transactional emails, marketing campaigns, and workflow automation — in one platform.
|
||||
Self-hostable, $0.001 per email, no contact limits.
|
||||
|
||||
[Get started free](https://next-app.useplunk.com/auth/signup) | [Read the docs](https://docs.useplunk.com)
|
||||
|
||||
---
|
||||
|
||||
## Key facts
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Price | $0.001 / email |
|
||||
| GitHub stars | 5,000+ |
|
||||
| Contacts | Unlimited, always free |
|
||||
| Setup time | < 5 minutes |
|
||||
| License | AGPL-3.0 |
|
||||
|
||||
---
|
||||
|
||||
## Replace your email stack
|
||||
|
||||
One tool in place of Resend, SendGrid, Mailchimp, Customer.io, and Mailgun.
|
||||
|
||||
- [Plunk vs Resend](/vs/resend)
|
||||
- [Plunk vs SendGrid](/vs/sendgrid)
|
||||
- [Plunk vs Mailchimp](/vs/mailchimp)
|
||||
- [Plunk vs Customer.io](/vs/customerio)
|
||||
- [Plunk vs Mailgun](/vs/mailgun)
|
||||
|
||||
---
|
||||
|
||||
## Why Plunk
|
||||
|
||||
### Setup — < 5 min
|
||||
Most email platforms take days to configure. Plunk is running in under five minutes — domain, DKIM, and first send included.
|
||||
|
||||
### Pricing — 0 limits
|
||||
Other platforms charge more as your list grows. Plunk stores unlimited contacts for free and bills only on send.
|
||||
|
||||
### Ownership — AGPL-3.0
|
||||
Closed-source platforms own your stack. Plunk is fully inspectable, forkable, and self-hostable on your own infra.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Workflow Automation
|
||||
Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.
|
||||
|
||||
### Dynamic Segments
|
||||
Real-time audience segmentation based on contact data and behavior.
|
||||
|
||||
### Campaign Management
|
||||
Broadcast emails with scheduling and performance tracking.
|
||||
|
||||
### Analytics
|
||||
Detailed metrics on opens, clicks, bounces, and conversions across campaigns.
|
||||
|
||||
### Inbound Email
|
||||
Receive and process incoming emails with webhook notifications.
|
||||
|
||||
### Custom Domains
|
||||
Brand consistency with DKIM authentication and custom sending domains.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
Every interaction flows into a single contact record — transactional, campaign, and workflow events unified in one source of truth.
|
||||
|
||||
- **Transactional** — Receipts, password resets, event-driven sends
|
||||
- **Campaigns** — Newsletters, product launches, announcements
|
||||
- **Workflows** — Onboarding sequences, drip campaigns
|
||||
|
||||
---
|
||||
|
||||
## Open source
|
||||
|
||||
- **License**: AGPL-3.0
|
||||
- **Hosting**: EU-hosted, GDPR compliant
|
||||
- **Deployment**: Self-hostable via Docker Compose
|
||||
- **Community**: 5,000+ GitHub stars
|
||||
|
||||
[View on GitHub](https://github.com/useplunk/plunk)
|
||||
|
||||
---
|
||||
|
||||
## Pricing comparison
|
||||
|
||||
**Plunk: $0.001 / email**
|
||||
|
||||
| Provider | Price / email |
|
||||
|----------|--------------|
|
||||
| Plunk | $0.001 |
|
||||
| Mailgun | $0.003 |
|
||||
| SendGrid | $0.002 |
|
||||
| Mailchimp | $0.004 |
|
||||
|
||||
*Based on plans matching Plunk at 10,000 emails per month.*
|
||||
|
||||
[Full pricing details](/pricing)
|
||||
|
||||
---
|
||||
|
||||
## Testimonials
|
||||
|
||||
> "Transparent and intuitive UI, extremely easy setup & automation and great support."
|
||||
> — Artur Czemiel, Founder at GraphQL Editor
|
||||
|
||||
> "I've been using Plunk for building & sending out marketing emails and genuinely love it!"
|
||||
> — Joe Ashwell, Founder at UnwindHR
|
||||
|
||||
> "I loved the ease of use, beautiful UI and great UX. Everything simply works."
|
||||
> — Alisson Leal, Founder at Brapi
|
||||
|
||||
> "Clean design, easy to understand, fair pricing."
|
||||
> — Pierre Jacquel, Founder at Landingly
|
||||
|
||||
> "Simple to use, efficient and no regrets!"
|
||||
> — Noah Di Gesu, Founder at Smoothey
|
||||
|
||||
> "Lots of care put into Plunk"
|
||||
> — Jonni Lundy, Founding Operations Manager at Resend
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**How is Plunk different from other email automation tools?**
|
||||
Plunk is built for SaaS businesses, indie hackers, and developers. Create complex automated email flows and trigger them from anywhere through a single API call.
|
||||
|
||||
**Can I use Plunk for transactional emails?**
|
||||
Yes. Plunk handles transactional emails — sign-ups, cancellations, plan changes — alongside marketing emails in the same platform.
|
||||
|
||||
**Can I use Plunk for newsletters?**
|
||||
Yes. Plunk lets you send newsletters and broadcast emails to your full list or any segment.
|
||||
|
||||
**What programming languages does Plunk support?**
|
||||
Any language capable of HTTP requests. Plunk provides a REST API plus SDKs for Node.js, Python, and more.
|
||||
|
||||
**How much does Plunk cost?**
|
||||
Free plan includes 1,000 emails/month. Paid plan is $0.001 per email with no contact limits.
|
||||
|
||||
---
|
||||
|
||||
Start sending in 5 minutes. Free plan available. No contact limits, no surprises.
|
||||
|
||||
[Create free account](https://next-app.useplunk.com/auth/signup) | [Read the docs](https://docs.useplunk.com)
|
||||
`,
|
||||
|
||||
pricing: `# Plunk Pricing — Simple, Transparent
|
||||
|
||||
Free plan: 1,000 emails/month. Paid plan: $0.001/email. Unlimited contacts, no hidden fees.
|
||||
|
||||
---
|
||||
|
||||
## Plans
|
||||
|
||||
### Free forever
|
||||
|
||||
**1,000 emails / month** — No credit card required
|
||||
|
||||
Includes:
|
||||
- Transactional emails
|
||||
- Workflow automation
|
||||
- Campaign broadcasts
|
||||
- Custom domains
|
||||
- Click & open tracking
|
||||
- Unlimited contacts
|
||||
- Plunk branding on emails (removed on paid)
|
||||
|
||||
[Start for free](https://next-app.useplunk.com/auth/signup)
|
||||
|
||||
---
|
||||
|
||||
### Pay as you grow
|
||||
|
||||
**$0.001 / email** — No base fee
|
||||
|
||||
Includes everything in Free, plus:
|
||||
- No Plunk branding
|
||||
- Monthly spend cap
|
||||
- Unlimited emails
|
||||
|
||||
[Get started](https://next-app.useplunk.com/auth/signup)
|
||||
|
||||
---
|
||||
|
||||
## Every feature, every plan
|
||||
|
||||
No feature tiers, no add-ons, no surprises.
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Transactional emails | API and SMTP delivery for receipts, password resets, and event-driven email. |
|
||||
| Workflow automation | Event-triggered sequences with delays, conditions, and branching logic. |
|
||||
| Campaign broadcasts | Send newsletters and announcements to your full list or a targeted segment. |
|
||||
| Unlimited contacts | Store as many contacts as you need. Growing your list never costs more. |
|
||||
| Full API access | REST API with SDKs for Node.js, Python, and more. |
|
||||
| Custom domains | Send from your own domain with DKIM, SPF, and DMARC set up automatically. |
|
||||
| Audience segmentation | Dynamic segments built on behavior, attributes, and engagement data. |
|
||||
| Analytics & tracking | Opens, clicks, bounces, and unsubscribes. Real data, no guessing. |
|
||||
| Open source | AGPL-3.0 licensed. Inspect the code, self-host it, or contribute. |
|
||||
|
||||
---
|
||||
|
||||
## Self-host for free
|
||||
|
||||
Run Plunk on your own infrastructure — full data ownership, no per-email costs, GDPR compliance by default.
|
||||
Deploy with Docker Compose in minutes.
|
||||
|
||||
[View on GitHub](https://github.com/useplunk/plunk)
|
||||
|
||||
---
|
||||
|
||||
Start sending in 5 minutes. Free plan, no credit card required.
|
||||
|
||||
[Create free account](https://next-app.useplunk.com/auth/signup) | [Self-host for free](https://github.com/useplunk/plunk)
|
||||
`,
|
||||
|
||||
'features/workflows': `# Workflow Automation — Plunk
|
||||
|
||||
Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.
|
||||
|
||||
[Get started free](https://next-app.useplunk.com/auth/signup) | [Documentation](https://docs.useplunk.com)
|
||||
|
||||
---
|
||||
|
||||
## What are workflows?
|
||||
|
||||
Workflows are automated email sequences that trigger based on events in your application. Define who receives emails, when they receive them, and what conditions must be met — all without writing code.
|
||||
|
||||
## Key capabilities
|
||||
|
||||
- **Event-triggered**: Start workflows from any API event
|
||||
- **Delays**: Wait minutes, hours, or days between steps
|
||||
- **Conditional branching**: Branch paths based on contact data or behavior
|
||||
- **Visual builder**: Drag-and-drop interface, no code required
|
||||
- **Unlimited steps**: As complex as your use case demands
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Onboarding sequences**: Welcome new users and guide them to activation
|
||||
- **Drip campaigns**: Nurture leads over time with educational content
|
||||
- **Re-engagement**: Win back inactive contacts automatically
|
||||
- **Lifecycle emails**: Upgrades, renewals, cancellations
|
||||
|
||||
[Back to features](/features) | [Pricing](/pricing)
|
||||
`,
|
||||
|
||||
'features/segments': `# Dynamic Segments — Plunk
|
||||
|
||||
Real-time audience segmentation based on contact data and behavior.
|
||||
|
||||
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||
|
||||
---
|
||||
|
||||
## What are segments?
|
||||
|
||||
Segments are dynamic groups of contacts that update automatically as contact data changes. Target campaigns and workflows to exactly the right audience without manual list management.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- **Attribute-based**: Filter by any contact property
|
||||
- **Behavior-based**: Segment by email opens, clicks, and engagement
|
||||
- **Real-time**: Segments update instantly as contact data changes
|
||||
- **Combinable**: AND/OR logic for complex targeting
|
||||
- **Unlimited contacts**: No per-segment contact limits
|
||||
|
||||
[Back to features](/features) | [Pricing](/pricing)
|
||||
`,
|
||||
|
||||
'features/inbound-email': `# Inbound Email — Plunk
|
||||
|
||||
Receive and process incoming emails with webhook notifications.
|
||||
|
||||
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||
|
||||
---
|
||||
|
||||
## What is inbound email?
|
||||
|
||||
Inbound email lets your application receive emails sent to your Plunk domain. When a message arrives, Plunk parses it and delivers the content to your webhook endpoint in real time.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- **Webhook delivery**: Parsed email payload posted to your endpoint
|
||||
- **Attachment handling**: Access attachments programmatically
|
||||
- **Custom addresses**: Route different addresses to different webhooks
|
||||
- **Reply detection**: Track email threads automatically
|
||||
|
||||
[Back to features](/features) | [Pricing](/pricing)
|
||||
`,
|
||||
|
||||
'features/email-editor': `# Email Editor — Plunk
|
||||
|
||||
Design beautiful emails with a drag-and-drop builder or write in Markdown.
|
||||
|
||||
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||
|
||||
---
|
||||
|
||||
## What is the email editor?
|
||||
|
||||
Plunk's email editor lets you build professional emails without writing HTML. Choose from the visual drag-and-drop builder or write in Markdown — both export to responsive HTML email.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- **Drag-and-drop builder**: Compose layouts with blocks and columns
|
||||
- **Markdown mode**: Write in plain text, render as rich email
|
||||
- **Responsive**: Emails adapt to any screen size automatically
|
||||
- **Preview**: See how your email looks on desktop and mobile
|
||||
- **Reusable templates**: Save and reuse your designs
|
||||
|
||||
[Back to features](/features) | [Pricing](/pricing)
|
||||
`,
|
||||
|
||||
'features/smtp': `# SMTP — Plunk
|
||||
|
||||
Send emails through Plunk using standard SMTP — no API changes required.
|
||||
|
||||
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||
|
||||
---
|
||||
|
||||
## SMTP access
|
||||
|
||||
Plunk provides SMTP credentials so you can send emails from any application or framework that supports standard SMTP. Drop in your Plunk credentials and start sending immediately — no code changes needed beyond configuration.
|
||||
|
||||
## Details
|
||||
|
||||
- **Host**: SMTP endpoint provided in your dashboard
|
||||
- **Port**: 587 (TLS) or 465 (SSL)
|
||||
- **Authentication**: Username and password from your Plunk project
|
||||
- **Compatible with**: Any language, framework, or tool that supports SMTP
|
||||
|
||||
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
type Negotiated = 'markdown' | 'html' | 'none';
|
||||
|
||||
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||
return accept.split(',').map(part => {
|
||||
const segments = part.trim().split(';');
|
||||
const type = segments[0]?.trim().toLowerCase() ?? '';
|
||||
const qParam = segments.slice(1).find(s => s.trim().startsWith('q='));
|
||||
const q = qParam ? parseFloat(qParam.split('=')[1] ?? '1') : 1;
|
||||
return { type, q: isNaN(q) ? 1 : q };
|
||||
});
|
||||
}
|
||||
|
||||
function getQ(types: Array<{ type: string; q: number }>, target: string): number {
|
||||
const exact = types.find(t => t.type === target);
|
||||
if (exact) return exact.q;
|
||||
const [main] = target.split('/');
|
||||
const sub = types.find(t => t.type === `${main}/*`);
|
||||
if (sub) return sub.q;
|
||||
const wildcard = types.find(t => t.type === '*/*');
|
||||
return wildcard ? wildcard.q : -1;
|
||||
}
|
||||
|
||||
function negotiate(accept: string): Negotiated {
|
||||
if (!accept) return 'html';
|
||||
const types = parseAccept(accept);
|
||||
const mdQ = getQ(types, 'text/markdown');
|
||||
const htmlQ = getQ(types, 'text/html');
|
||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||
return 'html';
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const accept = request.headers.get('accept') ?? '';
|
||||
const { pathname } = request.nextUrl;
|
||||
const result = negotiate(accept);
|
||||
|
||||
if (result === 'none') {
|
||||
return new NextResponse(null, { status: 406, headers: { 'Vary': 'Accept' } });
|
||||
}
|
||||
|
||||
if (result === 'markdown') {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('x-md-path', pathname);
|
||||
const response = NextResponse.rewrite(new URL('/api/md', request.url), { request: { headers } });
|
||||
response.headers.set('Vary', 'Accept');
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
response.headers.set('Vary', 'Accept');
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js)).*)',],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { MARKDOWN_PAGES } from '../../content/pages';
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const pathname = req.headers['x-md-path'];
|
||||
if (typeof pathname !== 'string') {
|
||||
res.status(406).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
|
||||
|
||||
res.setHeader('Vary', 'Accept');
|
||||
|
||||
const content = MARKDOWN_PAGES[slug];
|
||||
if (!content) {
|
||||
res.status(406).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
||||
res.status(200).send(content);
|
||||
}
|
||||
@@ -34,14 +34,16 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"@tiptap/suggestion": "^3.11.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"@xyflow/react": "^12.9.3",
|
||||
"dagre": "^0.8.5",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.4.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.7",
|
||||
"next": "^16.2.3",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useUser} from '../lib/hooks/useUser';
|
||||
import {WIKI_URI} from '../lib/constants';
|
||||
import {network} from '../lib/network';
|
||||
import {OnboardingBanner} from './onboarding/OnboardingBanner';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
@@ -352,7 +353,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">{children}</div>
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<OnboardingBanner />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {Check, Copy} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
|
||||
export interface CodeSnippet {
|
||||
id: string;
|
||||
label: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface CodeTabsProps {
|
||||
snippets: CodeSnippet[];
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
export function CodeTabs({snippets, defaultTab}: CodeTabsProps) {
|
||||
const [active, setActive] = useState(defaultTab ?? snippets[0]?.id ?? '');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const activeSnippet = snippets.find(s => s.id === active) ?? snippets[0];
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!activeSnippet) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(activeSnippet.code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard unavailable — silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={active} onValueChange={setActive} className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-neutral-200 px-3 py-2">
|
||||
<TabsList className="bg-transparent p-0 h-auto gap-1">
|
||||
{snippets.map(s => (
|
||||
<TabsTrigger
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 data-[state=active]:bg-neutral-100 data-[state=active]:text-neutral-900 data-[state=active]:shadow-none"
|
||||
>
|
||||
{s.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="relative flex h-7 items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 text-xs font-medium text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 transition-colors overflow-hidden"
|
||||
aria-label={`Copy ${activeSnippet?.label ?? ''} snippet`}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.span
|
||||
key="copied"
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.15}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600">Copied</span>
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="idle"
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.15}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>Copy</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{snippets.map(s => (
|
||||
<TabsContent key={s.id} value={s.id} className="mt-0">
|
||||
<pre className="m-0 overflow-x-auto bg-neutral-50 px-4 py-4 text-xs leading-relaxed text-neutral-900 font-mono">
|
||||
<code>{s.code}</code>
|
||||
</pre>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {ArrowRight, Check, Code2, FileText, Mail, ShieldCheck, Users, Workflow, X, Zap} from 'lucide-react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useSWRConfig} from 'swr';
|
||||
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingComplete} from '../../lib/hooks/useOnboardingComplete';
|
||||
import {type OnboardingPath, useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
import {useOnboardingStatus} from '../../lib/hooks/useOnboardingStatus';
|
||||
import {useProjectSetupState} from '../../lib/hooks/useProjectSetupState';
|
||||
|
||||
// Keys the banner derives state from. Revalidated on every route change so
|
||||
// progress reflects the latest state as soon as the user moves between pages.
|
||||
const LIVE_KEY_FRAGMENTS = ['/setup-state', '/activity/stats', '/contacts?limit=1', '/campaigns?page=1&limit=1'];
|
||||
const BANNER_POLL_MS = 5_000;
|
||||
|
||||
interface BannerStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
href: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function buildMarketingSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
|
||||
if (!setupState) return [];
|
||||
return [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your domain',
|
||||
href: '/settings?tab=domains',
|
||||
done: setupState.hasVerifiedDomain,
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
icon: Users,
|
||||
title: 'Add contacts',
|
||||
href: '/contacts',
|
||||
done: setupState.contactCount > 0,
|
||||
},
|
||||
{
|
||||
id: 'campaign',
|
||||
icon: Mail,
|
||||
title: 'Draft your first campaign',
|
||||
href: '/campaigns/create',
|
||||
done: setupState.lastCampaignSentAt !== null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildDeveloperSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
|
||||
if (!setupState) return [];
|
||||
return [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your domain',
|
||||
href: '/settings?tab=domains',
|
||||
done: setupState.hasVerifiedDomain,
|
||||
},
|
||||
{
|
||||
id: 'snippets',
|
||||
icon: Code2,
|
||||
title: 'Send your first email',
|
||||
href: '/onboarding/developer',
|
||||
done: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildWorkflowSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
|
||||
if (!setupState) return [];
|
||||
return [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your domain',
|
||||
href: '/settings?tab=domains',
|
||||
done: setupState.hasVerifiedDomain,
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
icon: Zap,
|
||||
title: 'Fire your first event',
|
||||
href: '/onboarding/workflows',
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
id: 'template',
|
||||
icon: FileText,
|
||||
title: 'Create a template',
|
||||
href: '/templates/create',
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
icon: Workflow,
|
||||
title: 'Build a workflow',
|
||||
href: '/workflows',
|
||||
done: setupState.hasEnabledWorkflow,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function stepsForPath(
|
||||
path: OnboardingPath,
|
||||
setupState: ReturnType<typeof useProjectSetupState>['setupState'],
|
||||
): BannerStep[] {
|
||||
if (path === 'developer') return buildDeveloperSteps(setupState);
|
||||
if (path === 'workflows') return buildWorkflowSteps(setupState);
|
||||
return buildMarketingSteps(setupState);
|
||||
}
|
||||
|
||||
export function OnboardingBanner() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const status = useOnboardingStatus();
|
||||
const {path, clearPath} = useOnboardingPath(activeProject?.id);
|
||||
const {markComplete} = useOnboardingComplete(activeProject?.id);
|
||||
|
||||
const onOnboardingRoute = router.pathname.startsWith('/onboarding');
|
||||
const bannerCandidate = !onOnboardingRoute && status === 'show' && Boolean(path);
|
||||
|
||||
const {setupState} = useProjectSetupState(activeProject?.id);
|
||||
|
||||
const {mutate} = useSWRConfig();
|
||||
useEffect(() => {
|
||||
if (!bannerCandidate) return;
|
||||
|
||||
const revalidate = () => {
|
||||
void mutate(
|
||||
key => typeof key === 'string' && LIVE_KEY_FRAGMENTS.some(fragment => key.includes(fragment)),
|
||||
);
|
||||
};
|
||||
|
||||
const interval = window.setInterval(revalidate, BANNER_POLL_MS);
|
||||
router.events.on('routeChangeComplete', revalidate);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
router.events.off('routeChangeComplete', revalidate);
|
||||
};
|
||||
}, [bannerCandidate, mutate, router.events]);
|
||||
|
||||
const steps = useMemo<BannerStep[]>(() => {
|
||||
if (!path) return [];
|
||||
return stepsForPath(path, setupState);
|
||||
}, [path, setupState]);
|
||||
|
||||
const visible = bannerCandidate && steps.length > 0;
|
||||
|
||||
const doneCount = steps.filter(s => s.done).length;
|
||||
const currentStepId = steps.find(s => !s.done)?.id;
|
||||
|
||||
const handleDismiss = () => {
|
||||
markComplete();
|
||||
clearPath();
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.25, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="mb-6 rounded-xl border border-neutral-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-4 sm:flex-col sm:items-start sm:justify-center">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-semibold text-neutral-900">Finish your setup</p>
|
||||
<p className="text-xs text-neutral-500 tabular-nums">
|
||||
{doneCount} of {steps.length} {doneCount === 1 ? 'step' : 'steps'} done
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors sm:hidden"
|
||||
aria-label="Dismiss setup guide"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2 sm:justify-center lg:justify-end">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const isCurrent = step.id === currentStepId;
|
||||
const base =
|
||||
'group relative inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2';
|
||||
const variant = step.done
|
||||
? 'border-green-200 bg-green-50 text-green-900 hover:border-green-300'
|
||||
: isCurrent
|
||||
? 'border-neutral-900 bg-neutral-900 text-white hover:bg-neutral-800'
|
||||
: 'border-neutral-200 bg-white text-neutral-700 hover:border-neutral-300 hover:text-neutral-900';
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
onClick={() => handleStepClick(step.href)}
|
||||
className={`${base} ${variant}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums ${
|
||||
step.done
|
||||
? 'bg-green-600 text-white'
|
||||
: isCurrent
|
||||
? 'bg-white text-neutral-900'
|
||||
: 'bg-neutral-100 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{step.done ? <Check className="h-2.5 w-2.5" /> : index + 1}
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5 opacity-80" />
|
||||
<span>{step.title}</span>
|
||||
{isCurrent && (
|
||||
<ArrowRight className="h-3 w-3 opacity-80 transition-transform group-hover:translate-x-0.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors sm:flex"
|
||||
aria-label="Dismiss setup guide"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {type CSSProperties, type ReactNode, useCallback} from 'react';
|
||||
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingComplete} from '../../lib/hooks/useOnboardingComplete';
|
||||
|
||||
interface OnboardingLayoutProps {
|
||||
step: 1 | 2;
|
||||
totalSteps?: 1 | 2;
|
||||
/** Tailwind max-width class for the content column. Defaults to max-w-2xl. */
|
||||
maxWidthClass?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const backgroundStyle: CSSProperties = {
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
|
||||
backgroundSize: '20px 20px',
|
||||
};
|
||||
|
||||
export function OnboardingLayout({step, totalSteps = 2, maxWidthClass = 'max-w-2xl', children}: OnboardingLayoutProps) {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {markComplete} = useOnboardingComplete(activeProject?.id);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
markComplete();
|
||||
void router.push('/');
|
||||
}, [markComplete, router]);
|
||||
|
||||
const dots = Array.from({length: totalSteps}, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-y-auto" style={backgroundStyle}>
|
||||
<header className="flex items-center justify-between px-6 py-5 sm:px-10">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
|
||||
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-neutral-500">
|
||||
<span className="tabular-nums">
|
||||
Step {step} of {totalSteps}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 ml-1">
|
||||
{dots.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-colors ${
|
||||
d <= step ? 'bg-neutral-900' : 'bg-neutral-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex justify-center px-4 pb-16 pt-4 sm:pb-24 sm:pt-8">
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`w-full ${maxWidthClass}`}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useCallback, useSyncExternalStore} from 'react';
|
||||
|
||||
const storageKey = (projectId: string) => `plunk-onboarded-${projectId}`;
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
if (typeof window === 'undefined') return () => undefined;
|
||||
window.addEventListener('storage', callback);
|
||||
window.addEventListener('plunk:onboarding-changed', callback);
|
||||
return () => {
|
||||
window.removeEventListener('storage', callback);
|
||||
window.removeEventListener('plunk:onboarding-changed', callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project onboarding completion flag backed by localStorage.
|
||||
* `isComplete` is `null` on the server and during the first client render,
|
||||
* then resolves to `true`/`false` after hydration.
|
||||
*/
|
||||
export function useOnboardingComplete(projectId: string | undefined) {
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!projectId || typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(storageKey(projectId)) === 'true';
|
||||
}, [projectId]);
|
||||
|
||||
const isComplete = useSyncExternalStore(subscribe, getSnapshot, () => null);
|
||||
|
||||
const markComplete = useCallback(() => {
|
||||
if (!projectId || typeof window === 'undefined') return;
|
||||
localStorage.setItem(storageKey(projectId), 'true');
|
||||
window.dispatchEvent(new Event('plunk:onboarding-changed'));
|
||||
}, [projectId]);
|
||||
|
||||
return {isComplete, markComplete};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {useActiveProject} from '../contexts/ActiveProjectProvider';
|
||||
import {useOnboardingComplete} from './useOnboardingComplete';
|
||||
import {useOnboardingStatus, type OnboardingStatus} from './useOnboardingStatus';
|
||||
|
||||
export type OnboardingGateState = OnboardingStatus;
|
||||
|
||||
/**
|
||||
* Gate helper for the /onboarding pages. Redirects to the dashboard when the
|
||||
* project is already onboarded (flag set or first email sent) and persists
|
||||
* the flag so future visits short-circuit immediately.
|
||||
*/
|
||||
export function useOnboardingGate() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {isComplete, markComplete} = useOnboardingComplete(activeProject?.id);
|
||||
const state = useOnboardingStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'skip') return;
|
||||
if (!isComplete) markComplete();
|
||||
void router.replace('/');
|
||||
}, [state, isComplete, markComplete, router]);
|
||||
|
||||
return {state, markComplete};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useCallback, useSyncExternalStore} from 'react';
|
||||
|
||||
export type OnboardingPath = 'developer' | 'marketing' | 'workflows';
|
||||
|
||||
const storageKey = (projectId: string) => `plunk-onboarding-path-${projectId}`;
|
||||
const CHANGE_EVENT = 'plunk:onboarding-path-changed';
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
if (typeof window === 'undefined') return () => undefined;
|
||||
window.addEventListener('storage', callback);
|
||||
window.addEventListener(CHANGE_EVENT, callback);
|
||||
return () => {
|
||||
window.removeEventListener('storage', callback);
|
||||
window.removeEventListener(CHANGE_EVENT, callback);
|
||||
};
|
||||
}
|
||||
|
||||
function isPath(value: string | null): value is OnboardingPath {
|
||||
return value === 'developer' || value === 'marketing' || value === 'workflows';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project record of the onboarding path the user chose.
|
||||
* Drives the persistent onboarding banner so navigation doesn't lose the thread.
|
||||
*/
|
||||
export function useOnboardingPath(projectId: string | undefined) {
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!projectId || typeof window === 'undefined') return null;
|
||||
const value = localStorage.getItem(storageKey(projectId));
|
||||
return isPath(value) ? value : null;
|
||||
}, [projectId]);
|
||||
|
||||
const path = useSyncExternalStore(subscribe, getSnapshot, () => null);
|
||||
|
||||
const setPath = useCallback(
|
||||
(next: OnboardingPath) => {
|
||||
if (!projectId || typeof window === 'undefined') return;
|
||||
localStorage.setItem(storageKey(projectId), next);
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const clearPath = useCallback(() => {
|
||||
if (!projectId || typeof window === 'undefined') return;
|
||||
localStorage.removeItem(storageKey(projectId));
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}, [projectId]);
|
||||
|
||||
return {path, setPath, clearPath};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMemo} from 'react';
|
||||
|
||||
import {useActiveProject} from '../contexts/ActiveProjectProvider';
|
||||
import {useDashboardStats} from './useDashboardStats';
|
||||
import {useOnboardingComplete} from './useOnboardingComplete';
|
||||
|
||||
export type OnboardingStatus = 'loading' | 'show' | 'skip';
|
||||
|
||||
/**
|
||||
* Read-only onboarding status. "skip" means the user is already onboarded —
|
||||
* either they dismissed/finished explicitly, or their project has already
|
||||
* sent at least one email.
|
||||
*/
|
||||
export function useOnboardingStatus(): OnboardingStatus {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {isComplete} = useOnboardingComplete(activeProject?.id);
|
||||
const {totalEmailsSent, isLoading} = useDashboardStats();
|
||||
|
||||
return useMemo<OnboardingStatus>(() => {
|
||||
if (isComplete === true) return 'skip';
|
||||
if (isComplete === null) return 'loading';
|
||||
if (isLoading) return 'loading';
|
||||
if (totalEmailsSent > 0) return 'skip';
|
||||
return 'show';
|
||||
}, [isComplete, isLoading, totalEmailsSent]);
|
||||
}
|
||||
@@ -57,6 +57,11 @@ export default function Login() {
|
||||
},
|
||||
});
|
||||
|
||||
const [lastUsed] = useState<'email' | 'google' | 'github' | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const stored = localStorage.getItem('plunk_last_auth_method');
|
||||
return stored === 'email' || stored === 'google' || stored === 'github' ? stored : null;
|
||||
});
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
@@ -82,6 +87,7 @@ export default function Login() {
|
||||
setErrorMessage('Email or password is incorrect');
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
localStorage.setItem('plunk_last_auth_method', 'email');
|
||||
|
||||
await userMutate();
|
||||
await projectsMutate();
|
||||
@@ -157,11 +163,13 @@ export default function Login() {
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
{oauthConfig.google && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
localStorage.setItem('plunk_last_auth_method', 'google');
|
||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
||||
}}
|
||||
>
|
||||
@@ -185,13 +193,21 @@ export default function Login() {
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
{lastUsed === 'google' && (
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-neutral-400">
|
||||
Last used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{oauthConfig.github && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
localStorage.setItem('plunk_last_auth_method', 'github');
|
||||
window.location.href = `${API_URI}/oauth/github/outbound`;
|
||||
}}
|
||||
>
|
||||
@@ -200,6 +216,12 @@ export default function Login() {
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
{lastUsed === 'github' && (
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-neutral-400">
|
||||
Last used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
@@ -219,7 +241,12 @@ export default function Login() {
|
||||
name="email"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Email</FormLabel>
|
||||
{lastUsed === 'email' && (
|
||||
<span className="text-[10px] text-neutral-400">Last used</span>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="[email protected]" autoFocus {...field} />
|
||||
</FormControl>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
Users,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -996,7 +997,7 @@ export default function CampaignDetailsPage() {
|
||||
<p className="text-sm font-medium text-neutral-700 mb-3">Message Content</p>
|
||||
<div className="border-2 border-neutral-200 rounded-lg overflow-hidden bg-white">
|
||||
<div className="p-6 max-h-96 overflow-y-auto">
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: c.body}} />
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(c.body)}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {QuickStart} from '../components/QuickStart';
|
||||
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||
import {useOnboardingPath} from '../lib/hooks/useOnboardingPath';
|
||||
import {useOnboardingStatus} from '../lib/hooks/useOnboardingStatus';
|
||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
@@ -32,6 +34,9 @@ export default function Index() {
|
||||
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||
const {data: config} = useConfig();
|
||||
const {data: user} = useUser();
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
const {path: onboardingPath} = useOnboardingPath(activeProject?.id);
|
||||
const bannerActive = onboardingStatus === 'show' && Boolean(onboardingPath);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resendMessage, setResendMessage] = useState<string>('');
|
||||
|
||||
@@ -200,9 +205,9 @@ export default function Index() {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Quick Start */}
|
||||
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
|
||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
||||
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
|
||||
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowLeft, ArrowRight, KeyRound, Mail, ShieldCheck} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
|
||||
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {CodeTabs, type CodeSnippet} from '../../components/onboarding/CodeTabs';
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
cta?: string;
|
||||
href?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your sender domain',
|
||||
description: 'Sending requires a verified domain. Two DNS records and you\'re set.',
|
||||
cta: 'Verify domain',
|
||||
href: '/settings?tab=domains',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
icon: KeyRound,
|
||||
title: 'Copy your secret key',
|
||||
description: 'Add it to your server environment. Never expose it in client-side code.',
|
||||
},
|
||||
{
|
||||
id: 'send',
|
||||
icon: Mail,
|
||||
title: 'Send your first email',
|
||||
description: 'One API call. Pick your language and drop it into your app.',
|
||||
},
|
||||
];
|
||||
|
||||
function buildSnippets(apiUrl: string, secret: string): CodeSnippet[] {
|
||||
const body = {
|
||||
to: '[email protected]',
|
||||
subject: 'Hello from Plunk',
|
||||
body: '<p>Your first email is live.</p>',
|
||||
from: '[email protected]',
|
||||
};
|
||||
const curl = `curl -X POST ${apiUrl}/v1/send \\
|
||||
-H "Authorization: Bearer ${secret}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}'`;
|
||||
|
||||
const node = `await fetch("${apiUrl}/v1/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer ${secret}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),
|
||||
});`;
|
||||
|
||||
const python = `import requests
|
||||
|
||||
requests.post(
|
||||
"${apiUrl}/v1/send",
|
||||
headers={"Authorization": "Bearer ${secret}"},
|
||||
json=${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')},
|
||||
)`;
|
||||
|
||||
const php = `$ch = curl_init("${apiUrl}/v1/send");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer ${secret}",
|
||||
"Content-Type: application/json",
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode(${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')}),
|
||||
]);
|
||||
curl_exec($ch);`;
|
||||
|
||||
return [
|
||||
{id: 'curl', label: 'cURL', code: curl},
|
||||
{id: 'node', label: 'Node.js', code: node},
|
||||
{id: 'python', label: 'Python', code: python},
|
||||
{id: 'php', label: 'PHP', code: php},
|
||||
];
|
||||
}
|
||||
|
||||
export default function OnboardingDeveloper() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {state} = useOnboardingGate();
|
||||
const {path, setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
const snippets = useMemo(
|
||||
() => buildSnippets(API_URI, activeProject?.secret ?? 'sk_your_secret_key'),
|
||||
[activeProject?.secret],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject && !path) setPath('developer');
|
||||
}, [activeProject, path, setPath]);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleContinue = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Send your first email" />
|
||||
<OnboardingLayout step={2}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Send your first email.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
|
||||
Three steps to your first send. Start with your domain so emails actually reach the inbox.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
|
||||
{step.required && (
|
||||
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{step.cta && step.href && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-center sm:flex-shrink-0"
|
||||
onClick={() => handleStepClick(step.href!)}
|
||||
>
|
||||
{step.cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step.id === 'key' && (
|
||||
<div className="border-t border-neutral-100 pt-4">
|
||||
{activeProject ? (
|
||||
<ApiKeyDisplay
|
||||
label="Secret key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this server-side. Treat it like a password."
|
||||
isSecret
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 animate-pulse rounded-lg bg-neutral-100" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.id === 'send' && (
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200">
|
||||
<CodeTabs snippets={snippets} />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowRight, Code2, Megaphone, Workflow} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {type OnboardingPath, useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface PathOption {
|
||||
id: OnboardingPath;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
const paths: PathOption[] = [
|
||||
{
|
||||
id: 'developer',
|
||||
icon: Code2,
|
||||
title: 'Send transactional emails',
|
||||
description: 'Wire Plunk into your app for password resets, receipts, and notifications.',
|
||||
meta: '~1 minute',
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
icon: Megaphone,
|
||||
title: 'Run email campaigns',
|
||||
description: 'Import contacts, draft campaigns, and broadcast to your audience.',
|
||||
meta: '~5 minutes',
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
icon: Workflow,
|
||||
title: 'Automate with workflows',
|
||||
description: 'Trigger emails from events in your app — onboarding, retention, receipts.',
|
||||
meta: '~5 minutes',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingWelcome() {
|
||||
const router = useRouter();
|
||||
const {state} = useOnboardingGate();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleSelect = (id: PathOption['id']) => {
|
||||
setPath(id);
|
||||
void router.push(`/onboarding/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Welcome to Plunk" />
|
||||
<OnboardingLayout step={1} maxWidthClass="max-w-5xl">
|
||||
<div className="flex flex-col gap-8">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Welcome to Plunk.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-lg leading-relaxed">
|
||||
Two minutes of setup and you'll be sending. How do you plan to use Plunk?
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{paths.map((path, index) => {
|
||||
const Icon = path.icon;
|
||||
return (
|
||||
<motion.button
|
||||
key={path.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(path.id)}
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.35, delay: 0.08 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
whileHover={{y: -2}}
|
||||
whileTap={{scale: 0.995}}
|
||||
className="group flex flex-col gap-5 rounded-xl border border-neutral-200 bg-white p-6 text-left shadow-sm transition-colors hover:border-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-neutral-400 tabular-nums">{path.meta}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-base font-semibold text-neutral-900">{path.title}</h2>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{path.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-neutral-900">
|
||||
<span>Start here</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-500">
|
||||
Not sure? Pick the closest fit — you can do all three once you're set up.
|
||||
</p>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowLeft, ArrowRight, Mail, ShieldCheck, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your sender domain',
|
||||
description: 'Two DNS records prove you own the address your campaigns send from.',
|
||||
href: '/settings?tab=domains',
|
||||
cta: 'Verify domain',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
icon: Users,
|
||||
title: 'Add your contacts',
|
||||
description: 'Import a CSV, paste a list, or sync contacts from your app.',
|
||||
href: '/contacts',
|
||||
cta: 'Add contacts',
|
||||
},
|
||||
{
|
||||
id: 'campaign',
|
||||
icon: Mail,
|
||||
title: 'Draft your first campaign',
|
||||
description: 'Compose in the editor, preview, and schedule — or send when you are ready.',
|
||||
href: '/campaigns/create',
|
||||
cta: 'Draft campaign',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingMarketing() {
|
||||
const router = useRouter();
|
||||
const {state} = useOnboardingGate();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {path, setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
// Record the marketing path so the persistent banner guides the user after
|
||||
// they click into a step. Only set if unset — don't overwrite a developer
|
||||
// choice if the user navigated here manually.
|
||||
useEffect(() => {
|
||||
if (activeProject && !path) setPath('marketing');
|
||||
}, [activeProject, path, setPath]);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleContinue = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Set up your campaigns" />
|
||||
<OnboardingLayout step={2}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Set up for campaigns.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
|
||||
Three steps to your first broadcast. Start anywhere — you can come back to finish the rest.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
|
||||
{step.required && (
|
||||
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-center sm:flex-shrink-0"
|
||||
onClick={() => handleStepClick(step.href)}
|
||||
>
|
||||
{step.cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowLeft, ArrowRight, FileText, ShieldCheck, Workflow, Zap} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
|
||||
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {CodeTabs, type CodeSnippet} from '../../components/onboarding/CodeTabs';
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
cta?: string;
|
||||
href?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your sender domain',
|
||||
description: 'Workflows send from a verified domain. Two DNS records and you\'re set.',
|
||||
cta: 'Verify domain',
|
||||
href: '/settings?tab=domains',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'track',
|
||||
icon: Zap,
|
||||
title: 'Fire events from your app',
|
||||
description: 'Call /v1/track whenever something interesting happens. Workflows listen and respond.',
|
||||
},
|
||||
{
|
||||
id: 'template',
|
||||
icon: FileText,
|
||||
title: 'Create an email template',
|
||||
description: 'Design the email your workflow will send — drag blocks or write HTML.',
|
||||
cta: 'Create template',
|
||||
href: '/templates/create',
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
icon: Workflow,
|
||||
title: 'Build your first workflow',
|
||||
description: 'Pick a trigger event, attach your template, chain delays, hit publish.',
|
||||
cta: 'Open workflow builder',
|
||||
href: '/workflows',
|
||||
},
|
||||
];
|
||||
|
||||
function buildTrackSnippets(apiUrl: string, publicKey: string): CodeSnippet[] {
|
||||
const body = {
|
||||
event: 'user.signed-up',
|
||||
email: '[email protected]',
|
||||
data: {plan: 'pro'},
|
||||
};
|
||||
|
||||
const curl = `curl -X POST ${apiUrl}/v1/track \\
|
||||
-H "Authorization: Bearer ${publicKey}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}'`;
|
||||
|
||||
const node = `await fetch("${apiUrl}/v1/track", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer ${publicKey}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),
|
||||
});`;
|
||||
|
||||
const python = `import requests
|
||||
|
||||
requests.post(
|
||||
"${apiUrl}/v1/track",
|
||||
headers={"Authorization": "Bearer ${publicKey}"},
|
||||
json=${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')},
|
||||
)`;
|
||||
|
||||
const php = `$ch = curl_init("${apiUrl}/v1/track");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer ${publicKey}",
|
||||
"Content-Type: application/json",
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode(${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')}),
|
||||
]);
|
||||
curl_exec($ch);`;
|
||||
|
||||
return [
|
||||
{id: 'curl', label: 'cURL', code: curl},
|
||||
{id: 'node', label: 'Node.js', code: node},
|
||||
{id: 'python', label: 'Python', code: python},
|
||||
{id: 'php', label: 'PHP', code: php},
|
||||
];
|
||||
}
|
||||
|
||||
export default function OnboardingWorkflows() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {state} = useOnboardingGate();
|
||||
const {path, setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
const snippets = useMemo(
|
||||
() => buildTrackSnippets(API_URI, activeProject?.public ?? 'pk_your_public_key'),
|
||||
[activeProject?.public],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject && !path) setPath('workflows');
|
||||
}, [activeProject, path, setPath]);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleContinue = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Automate with workflows" />
|
||||
<OnboardingLayout step={2}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Automate with workflows.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
|
||||
Four steps to your first automated email. Start with your domain so emails actually reach the inbox.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
|
||||
{step.required && (
|
||||
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{step.cta && step.href && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-center sm:flex-shrink-0"
|
||||
onClick={() => handleStepClick(step.href!)}
|
||||
>
|
||||
{step.cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step.id === 'track' && (
|
||||
<div className="flex flex-col gap-3 border-t border-neutral-100 pt-4">
|
||||
{activeProject ? (
|
||||
<ApiKeyDisplay
|
||||
label="Public key"
|
||||
value={activeProject.public}
|
||||
description="Use this key to track events from browsers or clients."
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 animate-pulse rounded-lg bg-neutral-100" />
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200">
|
||||
<CodeTabs snippets={snippets} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +54,8 @@ export default function CreateProject() {
|
||||
// Set the newly created project as active
|
||||
setActiveProject(newProject);
|
||||
|
||||
// Redirect to dashboard
|
||||
await router.push('/');
|
||||
// Redirect into onboarding for this project
|
||||
await router.push('/onboarding');
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export async function GET(_req: Request, props: {params: Promise<{slug?: string[
|
||||
|
||||
return new Response(await getLLMText(page), {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown',
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Vary': 'Accept',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
type Negotiated = 'markdown' | 'html' | 'none';
|
||||
|
||||
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||
return accept.split(',').map(part => {
|
||||
const segments = part.trim().split(';');
|
||||
const type = segments[0]?.trim().toLowerCase() ?? '';
|
||||
const qParam = segments.slice(1).find(s => s.trim().startsWith('q='));
|
||||
const q = qParam ? parseFloat(qParam.split('=')[1] ?? '1') : 1;
|
||||
return { type, q: isNaN(q) ? 1 : q };
|
||||
});
|
||||
}
|
||||
|
||||
function getQ(types: Array<{ type: string; q: number }>, target: string): number {
|
||||
const exact = types.find(t => t.type === target);
|
||||
if (exact) return exact.q;
|
||||
const [main] = target.split('/');
|
||||
const sub = types.find(t => t.type === `${main}/*`);
|
||||
if (sub) return sub.q;
|
||||
const wildcard = types.find(t => t.type === '*/*');
|
||||
return wildcard ? wildcard.q : -1;
|
||||
}
|
||||
|
||||
function negotiate(accept: string): Negotiated {
|
||||
if (!accept) return 'html';
|
||||
const types = parseAccept(accept);
|
||||
const mdQ = getQ(types, 'text/markdown');
|
||||
const htmlQ = getQ(types, 'text/html');
|
||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||
return 'html';
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const accept = request.headers.get('accept') ?? '';
|
||||
const { pathname } = request.nextUrl;
|
||||
const result = negotiate(accept);
|
||||
|
||||
if (result === 'none') {
|
||||
return new NextResponse(null, { status: 406, headers: { 'Vary': 'Accept' } });
|
||||
}
|
||||
|
||||
if (result === 'markdown') {
|
||||
const rewriteUrl = new URL(`/llms.mdx${pathname}`, request.url);
|
||||
const response = NextResponse.rewrite(rewriteUrl);
|
||||
response.headers.set('Vary', 'Accept');
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
response.headers.set('Vary', 'Accept');
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js)).*)',],
|
||||
};
|
||||
@@ -21,7 +21,7 @@
|
||||
"fumadocs-openapi": "^10.0.11",
|
||||
"fumadocs-ui": "16.0.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.7",
|
||||
"next": "^16.2.3",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
||||
@@ -1287,6 +1287,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/aix-ppc64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/aix-ppc64@npm:0.27.7"
|
||||
conditions: os=aix & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/android-arm64@npm:0.25.10"
|
||||
@@ -1308,6 +1315,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/android-arm64@npm:0.27.7"
|
||||
conditions: os=android & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/android-arm@npm:0.25.10"
|
||||
@@ -1329,6 +1343,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/android-arm@npm:0.27.7"
|
||||
conditions: os=android & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/android-x64@npm:0.25.10"
|
||||
@@ -1350,6 +1371,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/android-x64@npm:0.27.7"
|
||||
conditions: os=android & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/darwin-arm64@npm:0.25.10"
|
||||
@@ -1371,6 +1399,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/darwin-arm64@npm:0.27.7"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/darwin-x64@npm:0.25.10"
|
||||
@@ -1392,6 +1427,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/darwin-x64@npm:0.27.7"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/freebsd-arm64@npm:0.25.10"
|
||||
@@ -1413,6 +1455,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/freebsd-arm64@npm:0.27.7"
|
||||
conditions: os=freebsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/freebsd-x64@npm:0.25.10"
|
||||
@@ -1434,6 +1483,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/freebsd-x64@npm:0.27.7"
|
||||
conditions: os=freebsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-arm64@npm:0.25.10"
|
||||
@@ -1455,6 +1511,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-arm64@npm:0.27.7"
|
||||
conditions: os=linux & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-arm@npm:0.25.10"
|
||||
@@ -1476,6 +1539,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-arm@npm:0.27.7"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ia32@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-ia32@npm:0.25.10"
|
||||
@@ -1497,6 +1567,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ia32@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-ia32@npm:0.27.7"
|
||||
conditions: os=linux & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-loong64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-loong64@npm:0.25.10"
|
||||
@@ -1518,6 +1595,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-loong64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-loong64@npm:0.27.7"
|
||||
conditions: os=linux & cpu=loong64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-mips64el@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-mips64el@npm:0.25.10"
|
||||
@@ -1539,6 +1623,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-mips64el@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-mips64el@npm:0.27.7"
|
||||
conditions: os=linux & cpu=mips64el
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ppc64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-ppc64@npm:0.25.10"
|
||||
@@ -1560,6 +1651,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ppc64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-ppc64@npm:0.27.7"
|
||||
conditions: os=linux & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-riscv64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-riscv64@npm:0.25.10"
|
||||
@@ -1581,6 +1679,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-riscv64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-riscv64@npm:0.27.7"
|
||||
conditions: os=linux & cpu=riscv64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-s390x@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-s390x@npm:0.25.10"
|
||||
@@ -1602,6 +1707,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-s390x@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-s390x@npm:0.27.7"
|
||||
conditions: os=linux & cpu=s390x
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/linux-x64@npm:0.25.10"
|
||||
@@ -1623,6 +1735,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/linux-x64@npm:0.27.7"
|
||||
conditions: os=linux & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/netbsd-arm64@npm:0.25.10"
|
||||
@@ -1644,6 +1763,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/netbsd-arm64@npm:0.27.7"
|
||||
conditions: os=netbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/netbsd-x64@npm:0.25.10"
|
||||
@@ -1665,6 +1791,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/netbsd-x64@npm:0.27.7"
|
||||
conditions: os=netbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/openbsd-arm64@npm:0.25.10"
|
||||
@@ -1686,6 +1819,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/openbsd-arm64@npm:0.27.7"
|
||||
conditions: os=openbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/openbsd-x64@npm:0.25.10"
|
||||
@@ -1707,6 +1847,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/openbsd-x64@npm:0.27.7"
|
||||
conditions: os=openbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openharmony-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/openharmony-arm64@npm:0.25.10"
|
||||
@@ -1728,6 +1875,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openharmony-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/openharmony-arm64@npm:0.27.7"
|
||||
conditions: os=openharmony & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/sunos-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/sunos-x64@npm:0.25.10"
|
||||
@@ -1749,6 +1903,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/sunos-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/sunos-x64@npm:0.27.7"
|
||||
conditions: os=sunos & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-arm64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/win32-arm64@npm:0.25.10"
|
||||
@@ -1770,6 +1931,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-arm64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/win32-arm64@npm:0.27.7"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-ia32@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/win32-ia32@npm:0.25.10"
|
||||
@@ -1791,6 +1959,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-ia32@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/win32-ia32@npm:0.27.7"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-x64@npm:0.25.10":
|
||||
version: 0.25.10
|
||||
resolution: "@esbuild/win32-x64@npm:0.25.10"
|
||||
@@ -1812,6 +1987,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-x64@npm:0.27.7":
|
||||
version: 0.27.7
|
||||
resolution: "@esbuild/win32-x64@npm:0.27.7"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0":
|
||||
version: 4.9.0
|
||||
resolution: "@eslint-community/eslint-utils@npm:4.9.0"
|
||||
@@ -2821,10 +3003,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/env@npm:16.1.7"
|
||||
checksum: 10c0/89a9e657b29d01be04394cd8a4c917cfdd76aec76ea9a0f7670896efe7668e665713adcf72632958b0c19ce66cf7e1f39961cfd9ba69d10c5a3e08ee20d2370a
|
||||
"@next/env@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/env@npm:16.2.3"
|
||||
checksum: 10c0/56c3fee8ea226efe59ef065e054380f872c00c45c9fe4475eaa45f80773c3c1adc3ead3ccdd77447d3c1aeb4b3004aaaa033dd4a100d3e572fd01b83f992dde8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2851,9 +3033,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-darwin-arm64@npm:16.1.7"
|
||||
"@next/swc-darwin-arm64@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-darwin-arm64@npm:16.2.3"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2865,9 +3047,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-darwin-x64@npm:16.1.7"
|
||||
"@next/swc-darwin-x64@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-darwin-x64@npm:16.2.3"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2879,9 +3061,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.1.7"
|
||||
"@next/swc-linux-arm64-gnu@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2893,9 +3075,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:16.1.7"
|
||||
"@next/swc-linux-arm64-musl@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:16.2.3"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2907,9 +3089,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:16.1.7"
|
||||
"@next/swc-linux-x64-gnu@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:16.2.3"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2921,9 +3103,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-linux-x64-musl@npm:16.1.7"
|
||||
"@next/swc-linux-x64-musl@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-x64-musl@npm:16.2.3"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2935,9 +3117,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.1.7"
|
||||
"@next/swc-win32-arm64-msvc@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2949,9 +3131,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:16.1.7"
|
||||
"@next/swc-win32-x64-msvc@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:16.2.3"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -6584,6 +6766,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/dompurify@npm:^3.2.0":
|
||||
version: 3.2.0
|
||||
resolution: "@types/dompurify@npm:3.2.0"
|
||||
dependencies:
|
||||
dompurify: "npm:*"
|
||||
checksum: 10c0/e83fec586ffbb1b43f7c8479f2a99c223814d240db912d53fb126aaeea52a4091deceeb75632b5d76004d88ef0fbf444984887b121dece6fa69f4172ed4b2b07
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/eslint-scope@npm:^3.7.7":
|
||||
version: 3.7.7
|
||||
resolution: "@types/eslint-scope@npm:3.7.7"
|
||||
@@ -6955,6 +7146,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/trusted-types@npm:^2.0.7":
|
||||
version: 2.0.7
|
||||
resolution: "@types/trusted-types@npm:2.0.7"
|
||||
checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/unist@npm:*, @types/unist@npm:^3.0.0":
|
||||
version: 3.0.3
|
||||
resolution: "@types/unist@npm:3.0.3"
|
||||
@@ -9338,7 +9536,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.3":
|
||||
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.3, debug@npm:~4.4.1":
|
||||
version: 4.4.3
|
||||
resolution: "debug@npm:4.4.3"
|
||||
dependencies:
|
||||
@@ -9447,9 +9645,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"defu@npm:^6.1.4":
|
||||
version: 6.1.4
|
||||
resolution: "defu@npm:6.1.4"
|
||||
checksum: 10c0/2d6cc366262dc0cb8096e429368e44052fdf43ed48e53ad84cc7c9407f890301aa5fcb80d0995abaaf842b3949f154d060be4160f7a46cb2bc2f7726c81526f5
|
||||
version: 6.1.6
|
||||
resolution: "defu@npm:6.1.6"
|
||||
checksum: 10c0/6755a914d4d1b40346d582cadf3af0c4efb1b89c462631f4c8a20c933da88d5da674344dd0856426d611d5fc0c65a3ffa82388519127b06f1a688dc103ddf5ba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9614,6 +9812,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dompurify@npm:*, dompurify@npm:^3.4.0":
|
||||
version: 3.4.0
|
||||
resolution: "dompurify@npm:3.4.0"
|
||||
dependencies:
|
||||
"@types/trusted-types": "npm:^2.0.7"
|
||||
dependenciesMeta:
|
||||
"@types/trusted-types":
|
||||
optional: true
|
||||
checksum: 10c0/5593ac44ee20b9aa521c2120884effc98927fb9128c548183c75e79e0a04357c62ee913a049a267c8f396cb8c9d520ecf72562826c5524c46d4fe03c12063638
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"domutils@npm:^2.4.2":
|
||||
version: 2.8.0
|
||||
resolution: "domutils@npm:2.8.0"
|
||||
@@ -10297,6 +10507,95 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"esbuild@npm:^0.27.0":
|
||||
version: 0.27.7
|
||||
resolution: "esbuild@npm:0.27.7"
|
||||
dependencies:
|
||||
"@esbuild/aix-ppc64": "npm:0.27.7"
|
||||
"@esbuild/android-arm": "npm:0.27.7"
|
||||
"@esbuild/android-arm64": "npm:0.27.7"
|
||||
"@esbuild/android-x64": "npm:0.27.7"
|
||||
"@esbuild/darwin-arm64": "npm:0.27.7"
|
||||
"@esbuild/darwin-x64": "npm:0.27.7"
|
||||
"@esbuild/freebsd-arm64": "npm:0.27.7"
|
||||
"@esbuild/freebsd-x64": "npm:0.27.7"
|
||||
"@esbuild/linux-arm": "npm:0.27.7"
|
||||
"@esbuild/linux-arm64": "npm:0.27.7"
|
||||
"@esbuild/linux-ia32": "npm:0.27.7"
|
||||
"@esbuild/linux-loong64": "npm:0.27.7"
|
||||
"@esbuild/linux-mips64el": "npm:0.27.7"
|
||||
"@esbuild/linux-ppc64": "npm:0.27.7"
|
||||
"@esbuild/linux-riscv64": "npm:0.27.7"
|
||||
"@esbuild/linux-s390x": "npm:0.27.7"
|
||||
"@esbuild/linux-x64": "npm:0.27.7"
|
||||
"@esbuild/netbsd-arm64": "npm:0.27.7"
|
||||
"@esbuild/netbsd-x64": "npm:0.27.7"
|
||||
"@esbuild/openbsd-arm64": "npm:0.27.7"
|
||||
"@esbuild/openbsd-x64": "npm:0.27.7"
|
||||
"@esbuild/openharmony-arm64": "npm:0.27.7"
|
||||
"@esbuild/sunos-x64": "npm:0.27.7"
|
||||
"@esbuild/win32-arm64": "npm:0.27.7"
|
||||
"@esbuild/win32-ia32": "npm:0.27.7"
|
||||
"@esbuild/win32-x64": "npm:0.27.7"
|
||||
dependenciesMeta:
|
||||
"@esbuild/aix-ppc64":
|
||||
optional: true
|
||||
"@esbuild/android-arm":
|
||||
optional: true
|
||||
"@esbuild/android-arm64":
|
||||
optional: true
|
||||
"@esbuild/android-x64":
|
||||
optional: true
|
||||
"@esbuild/darwin-arm64":
|
||||
optional: true
|
||||
"@esbuild/darwin-x64":
|
||||
optional: true
|
||||
"@esbuild/freebsd-arm64":
|
||||
optional: true
|
||||
"@esbuild/freebsd-x64":
|
||||
optional: true
|
||||
"@esbuild/linux-arm":
|
||||
optional: true
|
||||
"@esbuild/linux-arm64":
|
||||
optional: true
|
||||
"@esbuild/linux-ia32":
|
||||
optional: true
|
||||
"@esbuild/linux-loong64":
|
||||
optional: true
|
||||
"@esbuild/linux-mips64el":
|
||||
optional: true
|
||||
"@esbuild/linux-ppc64":
|
||||
optional: true
|
||||
"@esbuild/linux-riscv64":
|
||||
optional: true
|
||||
"@esbuild/linux-s390x":
|
||||
optional: true
|
||||
"@esbuild/linux-x64":
|
||||
optional: true
|
||||
"@esbuild/netbsd-arm64":
|
||||
optional: true
|
||||
"@esbuild/netbsd-x64":
|
||||
optional: true
|
||||
"@esbuild/openbsd-arm64":
|
||||
optional: true
|
||||
"@esbuild/openbsd-x64":
|
||||
optional: true
|
||||
"@esbuild/openharmony-arm64":
|
||||
optional: true
|
||||
"@esbuild/sunos-x64":
|
||||
optional: true
|
||||
"@esbuild/win32-arm64":
|
||||
optional: true
|
||||
"@esbuild/win32-ia32":
|
||||
optional: true
|
||||
"@esbuild/win32-x64":
|
||||
optional: true
|
||||
bin:
|
||||
esbuild: bin/esbuild
|
||||
checksum: 10c0/ccd51f0555708bc9ff4ec9dc3ac92d3daacd45ecaac949ca8645984c5c323bf8cefe98c2df307418685e0b4ce37f9a3bdbfe8e3651fe632a0059a436195a17d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"esbuild@npm:~0.27.0":
|
||||
version: 0.27.1
|
||||
resolution: "esbuild@npm:0.27.1"
|
||||
@@ -12927,7 +13226,7 @@ __metadata:
|
||||
eslint-config-next: "npm:^16.0.1"
|
||||
juice: "npm:^11.0.3"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.1.7"
|
||||
next: "npm:^16.2.3"
|
||||
next-seo: "npm:^6.6.0"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
postcss: "npm:^8.4.33"
|
||||
@@ -13283,9 +13582,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"lodash@npm:^4.17.15":
|
||||
version: 4.17.23
|
||||
resolution: "lodash@npm:4.17.23"
|
||||
checksum: 10c0/1264a90469f5bb95d4739c43eb6277d15b6d9e186df4ac68c3620443160fc669e2f14c11e7d8b2ccf078b81d06147c01a8ccced9aab9f9f63d50dcf8cace6bf6
|
||||
version: 4.18.1
|
||||
resolution: "lodash@npm:4.18.1"
|
||||
checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14706,24 +15005,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^16.1.7":
|
||||
version: 16.1.7
|
||||
resolution: "next@npm:16.1.7"
|
||||
"next@npm:^16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "next@npm:16.2.3"
|
||||
dependencies:
|
||||
"@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"
|
||||
"@next/env": "npm:16.2.3"
|
||||
"@next/swc-darwin-arm64": "npm:16.2.3"
|
||||
"@next/swc-darwin-x64": "npm:16.2.3"
|
||||
"@next/swc-linux-arm64-gnu": "npm:16.2.3"
|
||||
"@next/swc-linux-arm64-musl": "npm:16.2.3"
|
||||
"@next/swc-linux-x64-gnu": "npm:16.2.3"
|
||||
"@next/swc-linux-x64-musl": "npm:16.2.3"
|
||||
"@next/swc-win32-arm64-msvc": "npm:16.2.3"
|
||||
"@next/swc-win32-x64-msvc": "npm:16.2.3"
|
||||
"@swc/helpers": "npm:0.5.15"
|
||||
baseline-browser-mapping: "npm:^2.9.19"
|
||||
caniuse-lite: "npm:^1.0.30001579"
|
||||
postcss: "npm:8.4.31"
|
||||
sharp: "npm:^0.34.4"
|
||||
sharp: "npm:^0.34.5"
|
||||
styled-jsx: "npm:5.1.6"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.1.0
|
||||
@@ -14762,7 +15061,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
next: dist/bin/next
|
||||
checksum: 10c0/f6cebb3ce57d267cd92fb364b56710004c883baafea9cfed36434c0cd51db74299d59094ab782674d9813f10ce2891b633bc6d94e7e38aa9af7a1870194818d0
|
||||
checksum: 10c0/8a9d27fc773d69f7f471cf1a23bde2ab2950e0411ef3e0d5c1664ed9654e94c3304eae1c4283ec0fa4e70e7b3f4416913350e118e0c18e8b055693dc5d021883
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -17146,7 +17445,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sharp@npm:^0.34.4":
|
||||
"sharp@npm:^0.34.4, sharp@npm:^0.34.5":
|
||||
version: 0.34.5
|
||||
resolution: "sharp@npm:0.34.5"
|
||||
dependencies:
|
||||
@@ -17433,12 +17732,12 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"socket.io-parser@npm:~4.2.4":
|
||||
version: 4.2.4
|
||||
resolution: "socket.io-parser@npm:4.2.4"
|
||||
version: 4.2.6
|
||||
resolution: "socket.io-parser@npm:4.2.6"
|
||||
dependencies:
|
||||
"@socket.io/component-emitter": "npm:~3.1.0"
|
||||
debug: "npm:~4.3.1"
|
||||
checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48
|
||||
debug: "npm:~4.4.1"
|
||||
checksum: 10c0/ba0a0b541b0a8e9d02b45c04c4c93a02331be5ea3478073c65bb9ff87032f12469c9adb309728eb90c0a352618d645ab88999c167a11c783cac861d7fd35c9d1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18606,9 +18905,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^6.19.5":
|
||||
version: 6.23.0
|
||||
resolution: "undici@npm:6.23.0"
|
||||
checksum: 10c0/d846b3fdfd05aa6081ba1eab5db6bbc21b283042c7a43722b86d1ee2bf749d7c990ceac0c809f9a07ffd88b1b0f4c0f548a8362c035088cb1997d63abdda499c
|
||||
version: 6.24.1
|
||||
resolution: "undici@npm:6.24.1"
|
||||
checksum: 10c0/53fdbaa357139a2c12deed34f67d67fc6ad269630ba85a1507e7717f53ad2d3a02c95fbd17d3ab321e34c60b6f0a716cdc2f7e2eca1e07178702dc89cc3a73c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18964,10 +19263,10 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"vite@npm:^6.0.0 || ^7.0.0":
|
||||
version: 7.2.7
|
||||
resolution: "vite@npm:7.2.7"
|
||||
version: 7.3.2
|
||||
resolution: "vite@npm:7.3.2"
|
||||
dependencies:
|
||||
esbuild: "npm:^0.25.0"
|
||||
esbuild: "npm:^0.27.0"
|
||||
fdir: "npm:^6.5.0"
|
||||
fsevents: "npm:~2.3.3"
|
||||
picomatch: "npm:^4.0.3"
|
||||
@@ -19014,7 +19313,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
vite: bin/vite.js
|
||||
checksum: 10c0/0c502d9eb898d9c05061dbd8fd199f280b524bbb4c12ab5f88c7b12779947386684a269e4dd0aa424aa35bcd857f1aa44aadb9ea764702a5043af433052455b5
|
||||
checksum: 10c0/74be36907e208916f18bfec81c8eba18b869f0a170f1ece0a4dcb14874d0f0e7c022fb6c2ad896e3ee6c973fe88f53ac23b4078879ada340d8b263260868b8d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19159,6 +19458,7 @@ __metadata:
|
||||
"@tiptap/starter-kit": "npm:^3.11.0"
|
||||
"@tiptap/suggestion": "npm:^3.11.0"
|
||||
"@types/dagre": "npm:^0.7.53"
|
||||
"@types/dompurify": "npm:^3.2.0"
|
||||
"@types/node": "npm:^24.10.0"
|
||||
"@types/react": "npm:^19.2.7"
|
||||
"@types/react-dom": "npm:^19.2.3"
|
||||
@@ -19166,10 +19466,11 @@ __metadata:
|
||||
"@xyflow/react": "npm:^12.9.3"
|
||||
dagre: "npm:^0.8.5"
|
||||
dayjs: "npm:^1.11.19"
|
||||
dompurify: "npm:^3.4.0"
|
||||
framer-motion: "npm:^12.23.24"
|
||||
juice: "npm:^11.0.3"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.1.7"
|
||||
next: "npm:^16.2.3"
|
||||
next-seo: "npm:^6.6.0"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
nuqs: "npm:^2.7.3"
|
||||
@@ -19366,7 +19667,7 @@ __metadata:
|
||||
fumadocs-openapi: "npm:^10.0.11"
|
||||
fumadocs-ui: "npm:16.0.8"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.1.7"
|
||||
next: "npm:^16.2.3"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
postcss: "npm:^8.4.33"
|
||||
react: "npm:^19.2.3"
|
||||
|
||||
Reference in New Issue
Block a user