diff --git a/apps/landing/.env.example b/apps/landing/.env.example index 7645ef920..6e8274d5f 100644 --- a/apps/landing/.env.example +++ b/apps/landing/.env.example @@ -5,6 +5,9 @@ RESEND_API_KEY=replace-me-with-resend-api-key # Create at https://resend.com/segments → copy the ID RESEND_SEGMENT_ID= +# Resend webhook signing secret for /api/resend-webhook +RESEND_WEBHOOK_SECRET= + # Paddle Billing sandbox checkout # Server-side secret. In production, set PADDLE_ENVIRONMENT=production and use the live key. PADDLE_ENVIRONMENT=sandbox diff --git a/apps/landing/README.md b/apps/landing/README.md index fd48c9625..b626bf6eb 100644 --- a/apps/landing/README.md +++ b/apps/landing/README.md @@ -17,6 +17,7 @@ Built with React 19, TypeScript, Tailwind CSS 4, and Framer Motion. Deployed on | UI | Radix UI primitives | | API | Vercel Serverless Functions | | Email | Resend | +| Analytics | PostHog | | Fonts | Satoshi, Inter, JetBrains Mono | ## Getting Started @@ -28,15 +29,16 @@ cp apps/landing/.env.example apps/landing/.env.local Fill in your environment variables: -| Variable | Required | Description | -| ------------------- | -------- | ----------------------------------------- | -| `RESEND_API_KEY` | Yes | API key from [Resend](https://resend.com) | -| `RESEND_SEGMENT_ID` | No | Segment ID to group waitlist contacts | +| Variable | Required | Description | +| ----------------------- | -------- | ------------------------------------------------ | +| `RESEND_API_KEY` | Yes | API key from [Resend](https://resend.com) | +| `RESEND_SEGMENT_ID` | No | Segment ID to group waitlist contacts | +| `RESEND_WEBHOOK_SECRET` | Yes | Signing secret for Resend event webhook delivery | PostHog analytics is optional in local development. Production should set the public project key -and host so landing pageviews, explicit CTA/demo/waitlist events, and privacy-masked session -replays are captured. Replay masks all inputs and text, blocks private selectors, and strips network -bodies/headers before capture. +and host so landing pageviews, explicit CTA/demo/waitlist events, UTM attribution, Resend webhook +events, and privacy-masked session replays are captured. Replay masks all inputs and text, blocks +private selectors, and strips network bodies/headers before capture. | Variable | Required | Description | | ------------------- | -------- | --------------------------------------------------- | @@ -88,6 +90,7 @@ functions locally — no separate backend needed. ``` ├── api/ │ ├── paddle-checkout.ts # Vercel serverless — Paddle transaction checkout +│ ├── resend-webhook.ts # Vercel serverless — Resend event webhook │ └── waitlist.ts # Vercel serverless — Resend waitlist signup ├── src/ │ ├── components/ @@ -113,6 +116,7 @@ set the Vercel project Root Directory to `apps/landing` so Vercel reads this pac `package.json`, `vite.config.ts`, `api/`, and `vercel.json`. - `api/waitlist.ts` runs as a serverless function +- `api/resend-webhook.ts` receives Resend delivery/open/click/unsubscribe events for PostHog - SPA is served as static output from `vite build` - Domain redirects configured in `vercel.json` (www + .ai variants → memrynote.com) diff --git a/apps/landing/api/paddle-checkout.ts b/apps/landing/api/paddle-checkout.ts index 73dcf513d..a578a7644 100644 --- a/apps/landing/api/paddle-checkout.ts +++ b/apps/landing/api/paddle-checkout.ts @@ -39,6 +39,22 @@ function getPostHogClient(): PostHog | null { return new PostHog(key, { host }) } +async function captureCheckoutInitiated(transactionId: string, plan: string, cadence: string) { + const posthog = getPostHogClient() + if (!posthog) return + + try { + posthog.capture({ + distinctId: transactionId, + event: 'checkout_initiated', + properties: { plan, cadence, transactionId } + }) + await posthog.shutdown() + } catch { + console.error('[paddle-checkout] PostHog capture failed') + } +} + export default async function handler(req: VercelRequest, res: VercelResponse) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }) @@ -74,15 +90,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { checkout: checkoutUrl ? { url: checkoutUrl } : undefined }) - const posthog = getPostHogClient() - if (posthog) { - posthog.capture({ - distinctId: transaction.id, - event: 'checkout_initiated', - properties: { plan: intent.plan, cadence: intent.cadence, transactionId: transaction.id } - }) - await posthog.shutdown() - } + await captureCheckoutInitiated(transaction.id, intent.plan, intent.cadence) return res.status(200).json({ environment: paddleEnvironment, diff --git a/apps/landing/api/resend-webhook-support.test.ts b/apps/landing/api/resend-webhook-support.test.ts new file mode 100644 index 000000000..ffcad7c5a --- /dev/null +++ b/apps/landing/api/resend-webhook-support.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { toMarketingEmailEvent } from './resend-webhook-support.ts' + +describe('resend webhook PostHog mapping', () => { + it('maps clicked email webhooks without raw recipient, IP, or query secrets', () => { + assert.deepEqual( + toMarketingEmailEvent({ + type: 'email.clicked', + created_at: '2026-05-20T12:00:00.000Z', + data: { + broadcast_id: 'broadcast_123', + email_id: 'email_123', + to: ['Private@Example.com'], + subject: 'MemryNote ships end of June', + click: { + ipAddress: '203.0.113.10', + link: 'https://memrynote.com/?utm_source=waitlist&utm_medium=email&utm_campaign=waitlist_01_launch_plain&utm_content=primary_cta&token=secret#waitlist', + timestamp: '2026-05-20T12:01:00.000Z', + userAgent: 'Mozilla/5.0' + } + } + }), + { + distinctId: + 'marketing_email:8172a023f8733c1c6377deccd97aefc669393f2a8f077b5bee2d1682d9bc307e', + event: 'marketing_email_clicked', + properties: { + broadcast_id: 'broadcast_123', + campaign_path: '/', + click_host: 'memrynote.com', + email_id: 'email_123', + resend_event_type: 'email.clicked', + subject: 'MemryNote ships end of June', + utm_campaign: 'waitlist_01_launch_plain', + utm_content: 'primary_cta', + utm_medium: 'email', + utm_source: 'waitlist' + }, + timestamp: '2026-05-20T12:00:00.000Z' + } + ) + }) +}) diff --git a/apps/landing/api/resend-webhook-support.ts b/apps/landing/api/resend-webhook-support.ts new file mode 100644 index 000000000..4d2b9a95f --- /dev/null +++ b/apps/landing/api/resend-webhook-support.ts @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto' + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } + +export type PostHogMarketingEmailEvent = { + distinctId: string + event: string + properties: Record + timestamp?: string +} + +export type ResendWebhookPayload = { + type: string + created_at?: string + data?: Record +} + +const EVENT_NAME_BY_TYPE: Record = { + 'email.sent': 'marketing_email_sent', + 'email.delivered': 'marketing_email_delivered', + 'email.opened': 'marketing_email_opened', + 'email.clicked': 'marketing_email_clicked', + 'email.bounced': 'marketing_email_bounced', + 'email.complained': 'marketing_email_complained', + 'email.delivery_delayed': 'marketing_email_delivery_delayed', + 'contact.unsubscribed': 'marketing_email_unsubscribed' +} +const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +function firstString(value: unknown): string | undefined { + if (Array.isArray(value)) return stringValue(value[0]) + return stringValue(value) +} + +function hashRecipient(value: string): string { + return createHash('sha256').update(value.trim().toLowerCase()).digest('hex') +} + +function setIfPresent( + properties: Record, + key: string, + value: string | undefined +): void { + if (value) properties[key] = value +} + +function addClickProperties( + properties: Record, + data: Record +): void { + const click = data.click + if (!click || typeof click !== 'object') return + + const link = stringValue((click as Record).link) + if (!link) return + + try { + const url = new URL(link) + properties.click_host = url.host + properties.campaign_path = url.pathname || '/' + + for (const key of UTM_KEYS) { + const value = url.searchParams.get(key) + if (value) properties[key] = value.slice(0, 120) + } + } catch { + return + } +} + +export function toMarketingEmailEvent( + payload: ResendWebhookPayload +): PostHogMarketingEmailEvent | null { + const event = EVENT_NAME_BY_TYPE[payload.type] + const data = payload.data + if (!event || !data) return null + + const recipient = firstString(data.to) ?? stringValue(data.email) + const distinctId = recipient + ? `marketing_email:${hashRecipient(recipient)}` + : `marketing_email_event:${stringValue(data.email_id) ?? payload.type}` + + const properties: Record = { + resend_event_type: payload.type + } + + setIfPresent(properties, 'broadcast_id', stringValue(data.broadcast_id)) + setIfPresent(properties, 'email_id', stringValue(data.email_id)) + setIfPresent(properties, 'template_id', stringValue(data.template_id)) + setIfPresent(properties, 'subject', stringValue(data.subject)) + addClickProperties(properties, data) + + return { + distinctId, + event, + properties, + timestamp: payload.created_at + } +} diff --git a/apps/landing/api/resend-webhook.ts b/apps/landing/api/resend-webhook.ts new file mode 100644 index 000000000..93ae82c6e --- /dev/null +++ b/apps/landing/api/resend-webhook.ts @@ -0,0 +1,90 @@ +import { PostHog } from 'posthog-node' +import { Resend } from 'resend' +import type { VercelRequest, VercelResponse } from '@vercel/node' + +import { toMarketingEmailEvent, type ResendWebhookPayload } from './resend-webhook-support.js' + +function getPostHogClient(): PostHog | null { + const key = process.env.POSTHOG_API_KEY + const host = process.env.POSTHOG_HOST + if (!key || !host) return null + return new PostHog(key, { host }) +} + +function getHeaderValue(req: VercelRequest, name: string): string | undefined { + const value = req.headers[name.toLowerCase()] + if (Array.isArray(value)) return value[0] + return typeof value === 'string' && value ? value : undefined +} + +async function readRawBody(req: VercelRequest): Promise { + if (typeof req.body === 'string') return req.body + if (req.body && typeof req.body === 'object') return JSON.stringify(req.body) + + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks).toString('utf8') +} + +async function verifyResendWebhook(req: VercelRequest): Promise { + const apiKey = process.env.RESEND_API_KEY + const webhookSecret = process.env.RESEND_WEBHOOK_SECRET + if (!apiKey) throw new Error('Missing RESEND_API_KEY') + if (!webhookSecret) throw new Error('Missing RESEND_WEBHOOK_SECRET') + + const resend = new Resend(apiKey) + const payload = await readRawBody(req) + const id = getHeaderValue(req, 'svix-id') + const timestamp = getHeaderValue(req, 'svix-timestamp') + const signature = getHeaderValue(req, 'svix-signature') + if (!id || !timestamp || !signature) throw new Error('Missing Resend webhook signature') + + const verifiedPayload = await resend.webhooks.verify({ + payload, + headers: { + id, + timestamp, + signature + }, + webhookSecret + }) + + return verifiedPayload as unknown as ResendWebhookPayload +} + +function parseEventTimestamp(value: string | undefined): Date | undefined { + if (!value) return undefined + const timestamp = new Date(value) + return Number.isNaN(timestamp.valueOf()) ? undefined : timestamp +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + let payload: ResendWebhookPayload + try { + payload = await verifyResendWebhook(req) + } catch { + return res.status(400).json({ error: 'Invalid webhook' }) + } + + const event = toMarketingEmailEvent(payload) + const posthog = getPostHogClient() + + if (event && posthog) { + posthog.capture({ + distinctId: event.distinctId, + event: event.event, + properties: event.properties, + timestamp: parseEventTimestamp(event.timestamp) + }) + await posthog.shutdown() + } + + return res.status(200).json({ received: true }) +} diff --git a/apps/landing/api/waitlist.test.ts b/apps/landing/api/waitlist.test.ts new file mode 100644 index 000000000..72caaf034 --- /dev/null +++ b/apps/landing/api/waitlist.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { normalizeWaitlistAttribution } from './waitlist.ts' + +describe('waitlist attribution', () => { + it('keeps only safe campaign fields for server-side signup capture', () => { + assert.deepEqual( + normalizeWaitlistAttribution({ + utm_source: 'waitlist', + utm_medium: 'email', + utm_campaign: 'waitlist_01_launch_plain', + utm_content: 'primary_cta', + email: 'private@example.com', + token: 'secret' + }), + { + utm_source: 'waitlist', + utm_medium: 'email', + utm_campaign: 'waitlist_01_launch_plain', + utm_content: 'primary_cta' + } + ) + }) +}) diff --git a/apps/landing/api/waitlist.ts b/apps/landing/api/waitlist.ts index 8da773b96..7c9930966 100644 --- a/apps/landing/api/waitlist.ts +++ b/apps/landing/api/waitlist.ts @@ -1,6 +1,18 @@ import { PostHog } from 'posthog-node' import type { VercelRequest, VercelResponse } from '@vercel/node' +const ATTRIBUTION_KEYS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term' +] as const +const ATTRIBUTION_VALUE_LIMIT = 120 + +type WaitlistAttributionKey = (typeof ATTRIBUTION_KEYS)[number] +type WaitlistAttribution = Partial> + function hasControlWhitespace(value: string): boolean { for (const char of value) { if (char <= ' ' || char === '\u007f') { @@ -57,6 +69,73 @@ function getPostHogClient(): PostHog | null { return new PostHog(key, { host }) } +function getHeaderValue(req: VercelRequest, name: string): string | undefined { + const value = req.headers[name.toLowerCase()] + if (Array.isArray(value)) return value[0] + return typeof value === 'string' && value ? value : undefined +} + +function getRequestBody(req: VercelRequest): unknown { + if (typeof req.body !== 'string') return req.body + + try { + return JSON.parse(req.body) + } catch { + return null + } +} + +function normalizeAttributionValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + + const normalized = value.trim() + if (!normalized || /[\r\n]/.test(normalized)) return undefined + + return normalized.slice(0, ATTRIBUTION_VALUE_LIMIT) +} + +export function normalizeWaitlistAttribution(input: unknown): WaitlistAttribution { + if (!input || typeof input !== 'object') return {} + + const attribution: WaitlistAttribution = {} + + for (const key of ATTRIBUTION_KEYS) { + const value = normalizeAttributionValue((input as Record)[key]) + if (value) attribution[key] = value + } + + return attribution +} + +async function captureWaitlistSignup( + req: VercelRequest, + contactId: string, + resendSegmentConfigured: boolean, + attribution: WaitlistAttribution +): Promise { + const posthog = getPostHogClient() + if (!posthog) return + + try { + const distinctId = getHeaderValue(req, 'x-posthog-distinct-id') ?? contactId + const sessionId = getHeaderValue(req, 'x-posthog-session-id') + + posthog.capture({ + distinctId, + event: 'waitlist_signup_success', + properties: { + contact_id: contactId, + resend_segment_configured: resendSegmentConfigured, + ...(sessionId ? { $session_id: sessionId } : {}), + ...attribution + } + }) + await posthog.shutdown() + } catch (error) { + console.error('[waitlist] PostHog capture failed:', sanitizeLogValue(error)) + } +} + export default async function handler(req: VercelRequest, res: VercelResponse) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }) @@ -70,7 +149,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(500).json({ error: 'Server configuration error' }) } - const { email } = req.body + const body = getRequestBody(req) + const email = + body && typeof body === 'object' && 'email' in body ? (body as { email?: unknown }).email : null if (!email || typeof email !== 'string') { return res.status(400).json({ error: 'Email is required' }) @@ -80,6 +161,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(400).json({ error: 'Invalid email format' }) } + const attribution = normalizeWaitlistAttribution( + body && typeof body === 'object' && 'attribution' in body + ? (body as { attribution?: unknown }).attribution + : null + ) + const headers = { Authorization: `Bearer ${RESEND_API_KEY}`, 'Content-Type': 'application/json' @@ -114,11 +201,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { }) } - const posthog = getPostHogClient() - if (posthog && contactId) { - posthog.capture({ distinctId: contactId, event: 'waitlist_signup_success' }) - await posthog.shutdown() - } + if (contactId) + await captureWaitlistSignup(req, contactId, Boolean(RESEND_SEGMENT_ID), attribution) return res.status(200).json({ success: true, diff --git a/apps/landing/package.json b/apps/landing/package.json index bc1233b50..9784780ad 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -23,11 +23,12 @@ "lenis": "^1.3.23", "lucide-react": "^0.562.0", "posthog-js": "^1.374.2", - "posthog-node": "^4.8.1", + "posthog-node": "^5.34.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-helmet-async": "^3.0.0", "react-router-dom": "^7.11.0", + "resend": "^6.12.3", "tailwind-merge": "^3.4.0", "vercel": "^52.0.0" }, diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index 2c652d251..568b62e2f 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -98,11 +98,11 @@ function ScrollDepthAnalytics() { } function PageViewAnalytics() { - const { pathname } = useLocation() + const { pathname, search } = useLocation() useEffect(() => { - trackLandingPageView(pathname) - }, [pathname]) + trackLandingPageView(pathname, search) + }, [pathname, search]) return null } diff --git a/apps/landing/src/components/shared/WaitlistForm.tsx b/apps/landing/src/components/shared/WaitlistForm.tsx index 05f6f9f7e..218b36b10 100644 --- a/apps/landing/src/components/shared/WaitlistForm.tsx +++ b/apps/landing/src/components/shared/WaitlistForm.tsx @@ -4,7 +4,11 @@ import { ArrowRight, Check, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { trackLandingEvent } from '@/lib/analytics' +import { + getLandingAnalyticsHeaders, + readLandingCampaignParams, + trackLandingEvent +} from '@/lib/analytics' interface WaitlistFormProps { variant?: 'hero' | 'inline' | 'centered' @@ -25,12 +29,17 @@ export function WaitlistForm({ variant = 'hero', className }: WaitlistFormProps) setErrorMessage('') try { + const analyticsHeaders = await getLandingAnalyticsHeaders() const response = await fetch('/api/waitlist', { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...analyticsHeaders }, - body: JSON.stringify({ email }) + body: JSON.stringify({ + email, + attribution: readLandingCampaignParams(window.location.search) + }) }) const data = await response.json() diff --git a/apps/landing/src/lib/analytics.test.ts b/apps/landing/src/lib/analytics.test.ts index dec3e0089..627f1d039 100644 --- a/apps/landing/src/lib/analytics.test.ts +++ b/apps/landing/src/lib/analytics.test.ts @@ -5,6 +5,7 @@ import { createLandingEventData, createLandingPageViewData, createLandingPostHogConfig, + readLandingCampaignParams, sanitizeCapturedNetworkRequest, sanitizePostHogEvent } from './analytics.ts' @@ -21,11 +22,30 @@ describe('landing analytics event data', () => { assert.deepEqual( createLandingEventData( 'download:https://github.com/memrynote/memry/releases?token=secret', - '/pricing?checkout=success#plans' + '/pricing?checkout=success#plans', + '?utm_source=waitlist&utm_medium=email&utm_campaign=waitlist_01_launch_plain&utm_content=primary_cta&email=private@example.com' ), { page: '/pricing', - target: 'download:https://github.com/memrynote/memry/releases' + target: 'download:https://github.com/memrynote/memry/releases', + utm_source: 'waitlist', + utm_medium: 'email', + utm_campaign: 'waitlist_01_launch_plain', + utm_content: 'primary_cta' + } + ) + }) + + it('keeps only safe campaign params for attribution', () => { + assert.deepEqual( + readLandingCampaignParams( + '?utm_source=waitlist&utm_medium=email&utm_campaign=waitlist_01&utm_term=launch&token=secret' + ), + { + utm_source: 'waitlist', + utm_medium: 'email', + utm_campaign: 'waitlist_01', + utm_term: 'launch' } ) }) diff --git a/apps/landing/src/lib/analytics.ts b/apps/landing/src/lib/analytics.ts index e56cf65e1..9bae9bbfa 100644 --- a/apps/landing/src/lib/analytics.ts +++ b/apps/landing/src/lib/analytics.ts @@ -26,14 +26,23 @@ export type LandingEventName = | 'landing_demo_expand' | 'landing_calculator_bundle_select' +export type LandingCampaignKey = + | 'utm_source' + | 'utm_medium' + | 'utm_campaign' + | 'utm_content' + | 'utm_term' + +export type LandingCampaignData = Partial> + export type LandingEventData = { page: string target: string -} +} & LandingCampaignData export type LandingPageViewData = { page: string -} +} & LandingCampaignData type PostHogClient = typeof import('posthog-js/dist/module.full.no-external').default type PostHogConfig = NonNullable[1]> @@ -41,6 +50,14 @@ type PostHogConfig = NonNullable[1]> const POSTHOG_KEY = import.meta.env?.VITE_POSTHOG_KEY const POSTHOG_HOST = import.meta.env?.VITE_POSTHOG_HOST const PRIVATE_REPLAY_SELECTOR = '[data-private], [data-sensitive], [data-ph-no-capture]' +const CAMPAIGN_PARAM_KEYS: readonly LandingCampaignKey[] = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term' +] +const CAMPAIGN_VALUE_LIMIT = 120 const URL_PROPERTY_KEYS = [ '$current_url', '$pathname', @@ -55,16 +72,43 @@ function stripQueryAndHash(value: string): string { return value.split(/[?#]/)[0] || 'unknown' } -export function createLandingEventData(target: string, pathname: string): LandingEventData { +function normalizeCampaignValue(value: string): string | undefined { + const normalized = value.trim() + if (!normalized || /[\r\n]/.test(normalized)) return undefined + return normalized.slice(0, CAMPAIGN_VALUE_LIMIT) +} + +export function readLandingCampaignParams(search: string): LandingCampaignData { + const campaign: LandingCampaignData = {} + const params = new URLSearchParams(search) + + for (const key of CAMPAIGN_PARAM_KEYS) { + const value = params.get(key) + if (!value) continue + + const normalized = normalizeCampaignValue(value) + if (normalized) campaign[key] = normalized + } + + return campaign +} + +export function createLandingEventData( + target: string, + pathname: string, + search = '' +): LandingEventData { return { page: stripQueryAndHash(pathname), - target: stripQueryAndHash(target) + target: stripQueryAndHash(target), + ...readLandingCampaignParams(search) } } -export function createLandingPageViewData(pathname: string): LandingPageViewData { +export function createLandingPageViewData(pathname: string, search = ''): LandingPageViewData { return { - page: stripQueryAndHash(pathname) + page: stripQueryAndHash(pathname), + ...readLandingCampaignParams(search) } } @@ -141,18 +185,35 @@ function getPostHogClient(): Promise { return posthogClient } -export function trackLandingPageView(pathname: string): void { +export function trackLandingPageView(pathname: string, search = ''): void { if (typeof window === 'undefined') return void getPostHogClient().then((posthog) => { - posthog?.capture('$pageview', createLandingPageViewData(pathname)) + posthog?.capture('$pageview', createLandingPageViewData(pathname, search)) }) } +export async function getLandingAnalyticsHeaders(): Promise> { + const posthog = await getPostHogClient() + if (!posthog) return {} + + const distinctId = posthog.get_distinct_id() + const sessionId = posthog.get_session_id() + const headers: Record = {} + + if (distinctId) headers['X-POSTHOG-DISTINCT-ID'] = String(distinctId) + if (sessionId) headers['X-POSTHOG-SESSION-ID'] = String(sessionId) + + return headers +} + export function trackLandingEvent(name: LandingEventName, target: string): void { if (typeof window === 'undefined') return void getPostHogClient().then((posthog) => { - posthog?.capture(name, createLandingEventData(target, window.location.pathname)) + posthog?.capture( + name, + createLandingEventData(target, window.location.pathname, window.location.search) + ) }) } diff --git a/apps/landing/src/pages/Privacy.tsx b/apps/landing/src/pages/Privacy.tsx index 69d81a22a..82928567a 100644 --- a/apps/landing/src/pages/Privacy.tsx +++ b/apps/landing/src/pages/Privacy.tsx @@ -125,7 +125,8 @@ export function PrivacyPage() {

If you join the waitlist or contact us, we store the email address you submit so we can - reply or send the messages you opted into. + reply or send the messages you opted into. We may track delivery, opens, clicks, and + unsubscribes for those emails so we can understand whether the campaign is working.

6. How we use the data we have

@@ -174,8 +175,12 @@ export function PrivacyPage() { (name, billing address, payment method).
  • - Postmark or a similar transactional email provider — delivers sign-up, - billing, and security emails. + Resend or a similar transactional email provider — delivers sign-up, + waitlist, billing, and security emails. +
  • +
  • + PostHog — collects privacy-masked website analytics, session replay, + and campaign events.
  • diff --git a/apps/landing/tsconfig.node.json b/apps/landing/tsconfig.node.json index 98b411fc6..241b8a9e4 100644 --- a/apps/landing/tsconfig.node.json +++ b/apps/landing/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts", "api/paddle*.ts", "api/github-stars.ts"] + "include": ["vite.config.ts", "api/*.ts"] } diff --git a/apps/marketing-emails/.gitignore b/apps/marketing-emails/.gitignore new file mode 100644 index 000000000..1fcb1529f --- /dev/null +++ b/apps/marketing-emails/.gitignore @@ -0,0 +1 @@ +out diff --git a/apps/marketing-emails/PLAYBOOK.md b/apps/marketing-emails/PLAYBOOK.md new file mode 100644 index 000000000..05c8880d2 --- /dev/null +++ b/apps/marketing-emails/PLAYBOOK.md @@ -0,0 +1,157 @@ +# MemryNote Waitlist Launch Campaign Playbook + +Operator manual for the 11-email waitlist program around the end-of-June launch. +Manual send via Resend dashboard. + +Launch date: **Tuesday June 30, 2026**. + +## Commands + +```bash +pnpm --filter @memry/marketing-emails test:copy +pnpm build:emails +``` + +Open the rendered HTML from `apps/marketing-emails/out/`, send a Resend test to yourself, +then schedule the broadcast. + +## Pre-flight + +| check | how | blocks send | +| ---------------------------------- | -------------------------------------------------------------------- | --------------- | +| Domain verified | Resend -> Domains -> `memrynote.com` has DKIM, SPF, DMARC green | yes | +| Waitlist audience ready | Resend -> Audiences -> "MemryNote Waitlist" has the current contacts | yes | +| Unsubscribe works | Send a Resend test and click `{{{RESEND_UNSUBSCRIBE_URL}}}` | yes | +| Resend webhook active | Resend -> Webhooks sends email events to `/api/resend-webhook` | yes | +| PostHog env configured | Landing production has `VITE_POSTHOG_*` and server `POSTHOG_*` vars | yes | +| Sender identity set | `Kaan ` with reply-to `kaan@memrynote.com` | yes | +| Physical address ready | Add postal address to footer before the first production send | yes | +| Discount ready | `WAITLIST25`, 25% off annual MemryNote Sync | before email #7 | +| Download and checkout smoke tested | `memrynote.com/download` and `memrynote.com/sync` both work | before email #7 | + +## Schedule + +| # | template file | send date | ET / Istanbul | job | +| --- | -------------------------------- | ---------- | ---------------- | --------------------------------- | +| 1 | `01-waitlist-launch-plain` | Wed May 20 | 11:00am / 6:00pm | Plain founder note | +| 2 | `02-waitlist-scattered-workflow` | Wed May 27 | 11:00am / 6:00pm | Problem framing | +| 3 | `03-waitlist-product-preview` | Wed Jun 3 | 11:00am / 6:00pm | Editor and product preview | +| 4 | `04-waitlist-workflow` | Wed Jun 10 | 11:00am / 6:00pm | Tasks, journal, calendar workflow | +| 5 | `05-waitlist-local-first-ai` | Wed Jun 17 | 11:00am / 6:00pm | Privacy, offline, AI agent trust | +| 6 | `06-waitlist-launch-week` | Wed Jun 24 | 11:00am / 6:00pm | Waitlist perk and launch-day plan | +| 7 | `07-waitlist-launch-day` | Tue Jun 30 | 10:30am / 5:30pm | Download link and waitlist code | +| 8 | `08-waitlist-getting-started` | Thu Jul 2 | 11:00am / 6:00pm | First 10 minutes after install | +| 9 | `09-waitlist-use-cases` | Tue Jul 7 | 11:00am / 6:00pm | Concrete use cases | +| 10 | `10-waitlist-feedback` | Tue Jul 14 | 11:00am / 6:00pm | Feedback and bug collection | +| 11 | `11-waitlist-last-call` | Tue Jul 21 | 2:30pm / 9:30pm | Final discount reminder | + +Istanbul is UTC+3. US Eastern is EDT during this window, so Istanbul = ET + 7h. + +## Email Notes + +### #1 Plain Founder Note + +- Subject: `MemryNote ships end of June` +- Asset: none. +- Goal: founder note, simple plan, ask for replies. + +### #2 Scattered Workflow + +- Subject: `Your notes, tasks, calendar, and journal should not live in four places` +- Asset: none. +- Goal: make the product problem obvious in the same plain-text tone as #1. + +### #3 Product Preview + +- Subject: `what MemryNote actually looks like` +- Asset: real editor screenshot, 560x360 hosted PNG or JPG under 200KB. +- Goal: show the app surface and ask which area people want to see next. + +### #4 Workflow + +- Subject: `How tasks, journal, and calendar connect in MemryNote` +- Asset: none. +- Goal: explain the daily operating loop in the same plain-text tone as #1. + +### #5 Local-first + AI + +- Subject: `Local-first, private by default, AI when useful` +- Asset: none. +- Goal: build trust before asking people to download, with the same direct founder voice. + +### #6 Launch Week + +- Subject: `MemryNote launches next week` +- Asset: none required. +- Goal: tell waitlist members what they will receive on launch day. + +### #7 Launch Day + +- Subject: `MemryNote is live` +- Asset: launched app hero screenshot. +- Morning-of smoke test: + - `memrynote.com/download` works on Mac and Windows. + - `memrynote.com/sync` checkout loads. + - `WAITLIST25` applies 25% off annual Sync. +- Send only after smoke test is green. + +### #8 Getting Started + +- Subject: `First 10 minutes in MemryNote` +- Asset: none required. +- Goal: reduce activation friction after download. + +### #9 Use Cases + +- Subject: `Four ways to use MemryNote` +- Asset: none required. +- Goal: help different waitlist groups map the product to real work. + +### #10 Feedback + +- Subject: `What should I fix next?` +- Asset: none required. +- Goal: collect bugs, objections, and sharper product language. + +### #11 Last Call + +- Subject: `Your MemryNote waitlist code expires tonight` +- Asset: none required. +- Goal: close the waitlist annual discount. + +## Behavior Branches + +Keep the primary 11 emails as the canonical sequence. Add branches only when they reduce noise. + +| segment | action | +| ----------------------------------- | ------------------------------------------------------------------------------- | +| Opened but did not click | Resend a shorter subject variant after 48 hours, once per key email | +| Clicked but did not download or buy | Send a short objection email with one CTA | +| Downloaded or paid | Stop sales emails and send onboarding only | +| No opens after three emails | Reduce frequency and skip discount pressure until launch day | +| Replied | Answer manually; do not put them into automated nudges until the thread is done | + +## Public Companion Posts + +Post after the email, not at the same time. Email gets first touch. + +| email | post timing | asset | +| ------ | -------------------------------------------------- | ----------------------------------------- | +| #1-#6 | 90 minutes after the email | match the email asset if useful | +| #7 | 90 minutes after launch email | launch thread with hero image or 30s demo | +| #8-#10 | same day, only if there is a useful product lesson | optional | +| #11 | 30 minutes after the email | plain-text final reminder | + +## Metrics + +Track only decisions you can act on: + +- reply themes from #1-#6 +- PostHog `marketing_email_*` delivery/open/click events by `utm_campaign` +- click-through on #7 download and Sync buttons via `utm_content` +- waitlist and checkout events: `waitlist_signup_success`, `checkout_initiated` +- conversion on `WAITLIST25` +- onboarding questions after #8 +- bug and objection themes from #10 + +Do not over-read open rate. Apple Mail privacy and old waitlist behavior make it noisy. diff --git a/apps/marketing-emails/README.md b/apps/marketing-emails/README.md new file mode 100644 index 000000000..2ffb0c87a --- /dev/null +++ b/apps/marketing-emails/README.md @@ -0,0 +1,49 @@ +# MemryNote Marketing Emails + +React Email templates for the MemryNote waitlist launch campaign. + +## Commands + +```bash +pnpm dev:emails +pnpm --filter @memry/marketing-emails test:copy +pnpm typecheck:emails +pnpm build:emails +``` + +## Sequence + +| # | template | subject | +| --- | -------------------------------- | ------------------------------------------------------------------------- | +| 1 | `01-waitlist-launch-plain` | `MemryNote ships end of June` | +| 2 | `02-waitlist-scattered-workflow` | `Your notes, tasks, calendar, and journal should not live in four places` | +| 3 | `03-waitlist-product-preview` | `what MemryNote actually looks like` | +| 4 | `04-waitlist-workflow` | `How tasks, journal, and calendar connect in MemryNote` | +| 5 | `05-waitlist-local-first-ai` | `Local-first, private by default, AI when useful` | +| 6 | `06-waitlist-launch-week` | `MemryNote launches next week` | +| 7 | `07-waitlist-launch-day` | `MemryNote is live` | +| 8 | `08-waitlist-getting-started` | `First 10 minutes in MemryNote` | +| 9 | `09-waitlist-use-cases` | `Four ways to use MemryNote` | +| 10 | `10-waitlist-feedback` | `What should I fix next?` | +| 11 | `11-waitlist-last-call` | `Your MemryNote waitlist code expires tonight` | + +The first email is intentionally plain-text style. Use that voice for the whole campaign: +short founder note, simple bullets, direct reply CTA. + +The first email does not reveal a discount code. It says waitlist members will receive the +launch-day perk later. + +Media placeholders: replace `heroImageUrl` or `screenshotUrl` with absolute hosted screenshot URLs +before sending emails that include screenshots. + +## Tracking + +Campaign links are built in `src/tracking-links.ts`. Each email uses: + +- `utm_source=waitlist` +- `utm_medium=email` +- `utm_campaign=waitlist_XX` +- `utm_content=` + +Do not paste raw `memrynote.com` CTA links into templates; use `trackedMemryUrl` so Resend click +webhooks can be attributed in PostHog without storing recipient email addresses. diff --git a/apps/marketing-emails/emails/01-waitlist-launch-plain.tsx b/apps/marketing-emails/emails/01-waitlist-launch-plain.tsx new file mode 100644 index 000000000..1dc6f7669 --- /dev/null +++ b/apps/marketing-emails/emails/01-waitlist-launch-plain.tsx @@ -0,0 +1 @@ +export { default } from '../src/waitlist-launch-plain-email' diff --git a/apps/marketing-emails/emails/02-waitlist-scattered-workflow.tsx b/apps/marketing-emails/emails/02-waitlist-scattered-workflow.tsx new file mode 100644 index 000000000..878c8af41 --- /dev/null +++ b/apps/marketing-emails/emails/02-waitlist-scattered-workflow.tsx @@ -0,0 +1 @@ +export { WaitlistScatteredWorkflowEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/03-waitlist-product-preview.tsx b/apps/marketing-emails/emails/03-waitlist-product-preview.tsx new file mode 100644 index 000000000..9d0d0375d --- /dev/null +++ b/apps/marketing-emails/emails/03-waitlist-product-preview.tsx @@ -0,0 +1 @@ +export { default } from '../src/waitlist-product-preview-email' diff --git a/apps/marketing-emails/emails/04-waitlist-workflow.tsx b/apps/marketing-emails/emails/04-waitlist-workflow.tsx new file mode 100644 index 000000000..22c40f997 --- /dev/null +++ b/apps/marketing-emails/emails/04-waitlist-workflow.tsx @@ -0,0 +1 @@ +export { WaitlistWorkflowEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/05-waitlist-local-first-ai.tsx b/apps/marketing-emails/emails/05-waitlist-local-first-ai.tsx new file mode 100644 index 000000000..ebf4d1ed4 --- /dev/null +++ b/apps/marketing-emails/emails/05-waitlist-local-first-ai.tsx @@ -0,0 +1 @@ +export { WaitlistLocalFirstAiEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/06-waitlist-launch-week.tsx b/apps/marketing-emails/emails/06-waitlist-launch-week.tsx new file mode 100644 index 000000000..cfaa02e6e --- /dev/null +++ b/apps/marketing-emails/emails/06-waitlist-launch-week.tsx @@ -0,0 +1 @@ +export { WaitlistLaunchWeekEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/07-waitlist-launch-day.tsx b/apps/marketing-emails/emails/07-waitlist-launch-day.tsx new file mode 100644 index 000000000..29fce7593 --- /dev/null +++ b/apps/marketing-emails/emails/07-waitlist-launch-day.tsx @@ -0,0 +1 @@ +export { default } from '../src/waitlist-launch-day-email' diff --git a/apps/marketing-emails/emails/08-waitlist-getting-started.tsx b/apps/marketing-emails/emails/08-waitlist-getting-started.tsx new file mode 100644 index 000000000..9e4742ef9 --- /dev/null +++ b/apps/marketing-emails/emails/08-waitlist-getting-started.tsx @@ -0,0 +1 @@ +export { WaitlistGettingStartedEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/09-waitlist-use-cases.tsx b/apps/marketing-emails/emails/09-waitlist-use-cases.tsx new file mode 100644 index 000000000..2117dc5f2 --- /dev/null +++ b/apps/marketing-emails/emails/09-waitlist-use-cases.tsx @@ -0,0 +1 @@ +export { WaitlistUseCasesEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/10-waitlist-feedback.tsx b/apps/marketing-emails/emails/10-waitlist-feedback.tsx new file mode 100644 index 000000000..f74972d22 --- /dev/null +++ b/apps/marketing-emails/emails/10-waitlist-feedback.tsx @@ -0,0 +1 @@ +export { WaitlistFeedbackEmail as default } from '../src/waitlist-program-email' diff --git a/apps/marketing-emails/emails/11-waitlist-last-call.tsx b/apps/marketing-emails/emails/11-waitlist-last-call.tsx new file mode 100644 index 000000000..554cf7376 --- /dev/null +++ b/apps/marketing-emails/emails/11-waitlist-last-call.tsx @@ -0,0 +1 @@ +export { default } from '../src/waitlist-last-call-email' diff --git a/apps/marketing-emails/package.json b/apps/marketing-emails/package.json new file mode 100644 index 000000000..e64ca2d7d --- /dev/null +++ b/apps/marketing-emails/package.json @@ -0,0 +1,24 @@ +{ + "name": "@memry/marketing-emails", + "version": "0.1.0", + "private": true, + "license": "GPL-3.0", + "type": "module", + "scripts": { + "dev": "email dev --dir emails --port 3006", + "test:copy": "node scripts/check-campaign-copy.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "email export --dir emails --outDir out" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-email": "^6.1.4" + }, + "devDependencies": { + "@memry/typescript-config": "workspace:*", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "typescript": "~5.9.3" + } +} diff --git a/apps/marketing-emails/scripts/check-campaign-copy.mjs b/apps/marketing-emails/scripts/check-campaign-copy.mjs new file mode 100644 index 000000000..7314c0574 --- /dev/null +++ b/apps/marketing-emails/scripts/check-campaign-copy.mjs @@ -0,0 +1,100 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const appDir = path.resolve(fileURLToPath(new URL('..', import.meta.url))) + +const expectedSequence = [ + ['01-waitlist-launch-plain.tsx', 'Wed May 20'], + ['02-waitlist-scattered-workflow.tsx', 'Wed May 27'], + ['03-waitlist-product-preview.tsx', 'Wed Jun 3'], + ['04-waitlist-workflow.tsx', 'Wed Jun 10'], + ['05-waitlist-local-first-ai.tsx', 'Wed Jun 17'], + ['06-waitlist-launch-week.tsx', 'Wed Jun 24'], + ['07-waitlist-launch-day.tsx', 'Tue Jun 30'], + ['08-waitlist-getting-started.tsx', 'Thu Jul 2'], + ['09-waitlist-use-cases.tsx', 'Tue Jul 7'], + ['10-waitlist-feedback.tsx', 'Tue Jul 14'], + ['11-waitlist-last-call.tsx', 'Tue Jul 21'] +] + +const forbiddenCopy = [ + /launch moved/i, + /launch move/i, + /changed date/i, + /earlier than planned/i, + /adjust dates if launch moves/i, + /planned to open/i +] + +const playbook = readFileSync(path.join(appDir, 'PLAYBOOK.md'), 'utf8') +const trackingLinks = readFileSync(path.join(appDir, 'src', 'tracking-links.ts'), 'utf8') +const sourceFiles = collectFiles(path.join(appDir, 'src'), /\.tsx?$/) +const checkedCopy = [ + playbook, + readFileSync(path.join(appDir, 'README.md'), 'utf8'), + ...sourceFiles.map((file) => readFileSync(file, 'utf8')) +].join('\n') + +const failures = [] + +for (const [fileName, sendDate] of expectedSequence) { + const emailPath = path.join(appDir, 'emails', fileName) + if (!existsSync(emailPath)) { + failures.push(`missing template ${fileName}`) + } + + const playbookName = fileName.replace(/\.tsx$/, '') + if (!playbook.includes(`\`${playbookName}\``)) { + failures.push(`playbook missing ${playbookName}`) + } + + if (!playbook.includes(sendDate)) { + failures.push(`playbook missing send date ${sendDate}`) + } +} + +for (const pattern of forbiddenCopy) { + if (pattern.test(checkedCopy)) { + failures.push(`forbidden reactive copy matched ${pattern}`) + } +} + +if (!checkedCopy.includes('MemryNote ships end of June')) { + failures.push('missing intentional end-of-June launch framing') +} + +if (!checkedCopy.includes('25% off your first year on an annual plan')) { + failures.push('missing waitlist annual discount framing') +} + +for (let index = 1; index <= expectedSequence.length; index += 1) { + const campaign = `waitlist_${String(index).padStart(2, '0')}` + if (!trackingLinks.includes(`'${campaign}'`)) { + failures.push(`missing tracked email campaign ${campaign}`) + } +} + +if ( + !trackingLinks.includes("'utm_source', 'waitlist'") || + !trackingLinks.includes("'utm_medium', 'email'") +) { + failures.push('missing waitlist email UTM source/medium tracking') +} + +if (failures.length > 0) { + console.error(failures.map((failure) => `- ${failure}`).join('\n')) + process.exit(1) +} + +function collectFiles(directory, pattern) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(directory, entry.name) + + if (entry.isDirectory()) { + return collectFiles(fullPath, pattern) + } + + return pattern.test(entry.name) ? [fullPath] : [] + }) +} diff --git a/apps/marketing-emails/src/campaign-content.ts b/apps/marketing-emails/src/campaign-content.ts new file mode 100644 index 000000000..d4e90b8ec --- /dev/null +++ b/apps/marketing-emails/src/campaign-content.ts @@ -0,0 +1,14 @@ +export const waitlistLaunchAnnouncementContent = { + subject: 'MemryNote launches at the end of June', + preview: 'You are on the waitlist. Here is what happens before launch.', + brandName: 'MemryNote', + eyebrow: 'Waitlist note', + heading: 'MemryNote launches at the end of June', + heroPlaceholder: 'Product screenshot placeholder: MemryNote inbox, notes, tasks, or daily view', + heroHint: 'Replace with a 1200x760 hosted PNG/JPG before sending.', + primaryCta: 'Visit MemryNote', + defaultBrandIconUrl: 'https://memrynote.com/favicon.svg', + defaultLaunchWindow: 'end of June', + defaultDiscountLabel: '25% off your first year on an annual plan', + defaultWaitlistCount: '350' +} as const diff --git a/apps/marketing-emails/src/tracking-links.ts b/apps/marketing-emails/src/tracking-links.ts new file mode 100644 index 000000000..a1e7af0cc --- /dev/null +++ b/apps/marketing-emails/src/tracking-links.ts @@ -0,0 +1,30 @@ +export const WAITLIST_CAMPAIGNS = { + launchPlain: 'waitlist_01', + scatteredWorkflow: 'waitlist_02', + productPreview: 'waitlist_03', + workflow: 'waitlist_04', + localFirstAi: 'waitlist_05', + launchWeek: 'waitlist_06', + launchDay: 'waitlist_07', + gettingStarted: 'waitlist_08', + useCases: 'waitlist_09', + feedback: 'waitlist_10', + lastCall: 'waitlist_11' +} as const + +export type WaitlistCampaignId = (typeof WAITLIST_CAMPAIGNS)[keyof typeof WAITLIST_CAMPAIGNS] + +export function trackedMemryUrl( + path: string, + campaign: WaitlistCampaignId, + content: string +): string { + const url = new URL(path, 'https://memrynote.com') + + url.searchParams.set('utm_source', 'waitlist') + url.searchParams.set('utm_medium', 'email') + url.searchParams.set('utm_campaign', campaign) + url.searchParams.set('utm_content', content) + + return url.toString() +} diff --git a/apps/marketing-emails/src/waitlist-last-call-email.tsx b/apps/marketing-emails/src/waitlist-last-call-email.tsx new file mode 100644 index 000000000..706cb169e --- /dev/null +++ b/apps/marketing-emails/src/waitlist-last-call-email.tsx @@ -0,0 +1,179 @@ +import type { CSSProperties, ReactElement } from 'react' +import { Body, Button, Container, Head, Hr, Html, Link, Preview, Text } from 'react-email' +import { trackedMemryUrl, WAITLIST_CAMPAIGNS } from './tracking-links' + +export const waitlistLastCallContent = { + subject: 'Your MemryNote waitlist code expires tonight', + preview: 'Last reminder for the waitlist annual discount.' +} as const + +export type WaitlistLastCallEmailProps = { + firstName?: string + daysLeft?: string + discountCode?: string + discountExpiry?: string + checkoutUrl?: string + replyToEmail?: string + unsubscribeUrl?: string +} + +const defaultProps = { + firstName: '', + daysLeft: 'tonight', + discountCode: 'WAITLIST25', + discountExpiry: 'July 21', + checkoutUrl: trackedMemryUrl('/sync', WAITLIST_CAMPAIGNS.lastCall, 'discount_cta'), + replyToEmail: 'kaan@memrynote.com', + unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}' +} satisfies Required + +type EmailComponent = { + (props: WaitlistLastCallEmailProps): ReactElement + PreviewProps?: WaitlistLastCallEmailProps +} + +export const WaitlistLastCallEmail: EmailComponent = (props) => { + const { + firstName, + daysLeft, + discountCode, + discountExpiry, + checkoutUrl, + replyToEmail, + unsubscribeUrl + } = { + ...defaultProps, + ...props + } + + const greeting = firstName ? `Hey ${firstName},` : 'Hey,' + + return ( + + + {waitlistLastCallContent.preview} + + + {greeting} + + Your MemryNote waitlist code expires in {daysLeft}. + + + {discountCode} — 25% off MemryNote Sync annual, + lifetime renewals. + + + After {discountExpiry}, the code is gone. + + + + + If MemryNote isn't for you, no hard feelings. Reply and tell me what's missing + — I read every one. + + + + — Kaan +
    + + memrynote.com + +
    + +


    + + + You're getting this because you joined the MemryNote waitlist. Reply to me at{' '} + + {replyToEmail} + {' '} + or{' '} + + unsubscribe + + . + + + + + ) +} + +WaitlistLastCallEmail.PreviewProps = defaultProps + +export default WaitlistLastCallEmail + +const styles = { + body: { + margin: 0, + backgroundColor: '#ffffff', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', + color: '#1a1a1a' + }, + container: { + width: '100%', + maxWidth: '560px', + margin: '0 auto', + padding: '40px 24px' + }, + paragraph: { + margin: '0 0 20px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + inlineCode: { + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + fontWeight: 700, + fontSize: '15px', + color: '#1a1a1a' + }, + button: { + display: 'block', + width: '100%', + boxSizing: 'border-box', + margin: '8px 0 28px', + padding: '14px 20px', + borderRadius: '8px', + backgroundColor: '#1a1a1a', + color: '#ffffff', + fontSize: '16px', + fontWeight: 600, + lineHeight: '20px', + textAlign: 'center', + textDecoration: 'none' + }, + signature: { + margin: '8px 0 36px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signatureLink: { + color: '#888888', + fontSize: '15px', + textDecoration: 'underline' + }, + hr: { + margin: '0 0 16px', + border: 'none', + borderTop: '1px solid #ebebeb' + }, + footer: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + footerLink: { + color: '#555555', + textDecoration: 'underline' + } +} satisfies Record diff --git a/apps/marketing-emails/src/waitlist-launch-announcement-email.tsx b/apps/marketing-emails/src/waitlist-launch-announcement-email.tsx new file mode 100644 index 000000000..e2e11e824 --- /dev/null +++ b/apps/marketing-emails/src/waitlist-launch-announcement-email.tsx @@ -0,0 +1,312 @@ +import type { CSSProperties, ReactElement } from 'react' +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text +} from 'react-email' +import { waitlistLaunchAnnouncementContent } from './campaign-content' +import { trackedMemryUrl, WAITLIST_CAMPAIGNS } from './tracking-links' + +export type WaitlistLaunchAnnouncementEmailProps = { + firstName?: string + brandIconUrl?: string + launchWindow?: string + discountLabel?: string + waitlistCount?: string + heroImageUrl?: string + landingPageUrl?: string + replyToEmail?: string + unsubscribeUrl?: string +} + +type EmailComponent = { + (props: WaitlistLaunchAnnouncementEmailProps): ReactElement + PreviewProps?: WaitlistLaunchAnnouncementEmailProps +} + +const defaultProps = { + firstName: '', + brandIconUrl: waitlistLaunchAnnouncementContent.defaultBrandIconUrl, + launchWindow: waitlistLaunchAnnouncementContent.defaultLaunchWindow, + discountLabel: waitlistLaunchAnnouncementContent.defaultDiscountLabel, + waitlistCount: waitlistLaunchAnnouncementContent.defaultWaitlistCount, + heroImageUrl: '', + landingPageUrl: trackedMemryUrl('/', WAITLIST_CAMPAIGNS.launchPlain, 'primary_cta'), + replyToEmail: 'kaan@memrynote.com', + unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}' +} satisfies Required + +export const WaitlistLaunchAnnouncementEmail: EmailComponent = (props) => { + const { + firstName, + brandIconUrl, + launchWindow, + discountLabel, + waitlistCount, + heroImageUrl, + landingPageUrl, + replyToEmail, + unsubscribeUrl + } = { + ...defaultProps, + ...props + } + + const greeting = firstName ? `Hi ${firstName},` : 'Hi,' + + return ( + + + {waitlistLaunchAnnouncementContent.preview} + + +
    + MemryNote + {waitlistLaunchAnnouncementContent.brandName} +
    + + {waitlistLaunchAnnouncementContent.eyebrow} + {waitlistLaunchAnnouncementContent.heading} + + {greeting} + + You joined the MemryNote waitlist, so I wanted to send a short note: MemryNote launches + at the {launchWindow}. + + + I am building it for people who are tired of splitting their thinking across notes, + tasks, inboxes, calendars, and journals. MemryNote keeps those pieces in one local-first + workspace, with AI available when it helps. + + + + +
    + Waitlist thank-you + + There are {waitlistCount} people on the list right now. On launch day, waitlist + members get {discountLabel}. I will send the link when checkout opens. + +
    + + + Over the next few weeks, I will send short updates showing the core parts of MemryNote: + inbox, notes, tasks, journal, calendar, and the agent. + + + One useful thing you can do now: reply and tell me what you hope MemryNote replaces for + you. I read every reply. + + + + + + Kaan +
    + Founder, MemryNote +
    + +
    + + You are receiving this because you joined the MemryNote waitlist. Reply directly at{' '} + + {replyToEmail} + + .{' '} + + Unsubscribe + + . + +
    + + + ) +} + +function MediaSlot({ imageUrl }: { imageUrl: string }) { + if (imageUrl) { + return ( + MemryNote product preview + ) + } + + return ( +
    + + {waitlistLaunchAnnouncementContent.heroPlaceholder} + + {waitlistLaunchAnnouncementContent.heroHint} +
    + ) +} + +WaitlistLaunchAnnouncementEmail.PreviewProps = defaultProps + +export default WaitlistLaunchAnnouncementEmail + +const styles = { + body: { + margin: 0, + backgroundColor: '#f6f1e8', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', + color: '#24211d' + }, + container: { + width: '100%', + maxWidth: '600px', + margin: '0 auto', + padding: '40px 20px' + }, + brandRow: { + margin: '0 0 28px', + lineHeight: '32px' + }, + brandIcon: { + display: 'inline-block', + width: '32px', + height: '32px', + margin: '0 10px 0 0', + verticalAlign: 'middle' + }, + brandName: { + display: 'inline-block', + margin: 0, + color: '#24211d', + fontSize: '17px', + lineHeight: '32px', + fontWeight: 750, + verticalAlign: 'middle' + }, + eyebrow: { + margin: '0 0 12px', + color: '#7a5b35', + fontSize: '13px', + fontWeight: 700, + letterSpacing: '0.4px', + textTransform: 'uppercase' + }, + heading: { + margin: '0 0 28px', + color: '#1f1b16', + fontSize: '34px', + lineHeight: '40px', + fontWeight: 750 + }, + paragraph: { + margin: '0 0 18px', + color: '#39342d', + fontSize: '16px', + lineHeight: '26px' + }, + placeholder: { + margin: '30px 0', + padding: '42px 28px', + border: '1px dashed #b99a6b', + borderRadius: '8px', + backgroundColor: '#fbf7ef', + textAlign: 'center' + }, + placeholderLabel: { + margin: '0 0 8px', + color: '#4d4337', + fontSize: '15px', + lineHeight: '22px', + fontWeight: 700 + }, + placeholderHint: { + margin: 0, + color: '#7f7163', + fontSize: '13px', + lineHeight: '20px' + }, + image: { + width: '100%', + maxWidth: '560px', + height: 'auto', + margin: '30px 0', + borderRadius: '8px', + border: '1px solid #e4d8c8' + }, + callout: { + margin: '28px 0', + padding: '20px', + border: '1px solid #e3d2bc', + borderRadius: '8px', + backgroundColor: '#fffaf2' + }, + calloutLabel: { + margin: '0 0 8px', + color: '#7a4f17', + fontSize: '13px', + lineHeight: '18px', + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '0.3px' + }, + calloutText: { + margin: 0, + color: '#3d352c', + fontSize: '15px', + lineHeight: '24px' + }, + button: { + display: 'block', + width: '100%', + boxSizing: 'border-box', + margin: '28px 0', + padding: '14px 20px', + borderRadius: '8px', + backgroundColor: '#24211d', + color: '#ffffff', + fontSize: '15px', + fontWeight: 700, + lineHeight: '20px', + textAlign: 'center', + textDecoration: 'none' + }, + signature: { + margin: '0 0 28px', + color: '#39342d', + fontSize: '16px', + lineHeight: '25px' + }, + hr: { + margin: '0 0 18px', + borderColor: '#ded2c2' + }, + footer: { + margin: 0, + color: '#877a6a', + fontSize: '12px', + lineHeight: '19px' + }, + footerLink: { + color: '#5f4529', + textDecoration: 'underline' + } +} satisfies Record diff --git a/apps/marketing-emails/src/waitlist-launch-day-email.tsx b/apps/marketing-emails/src/waitlist-launch-day-email.tsx new file mode 100644 index 000000000..2cb431b94 --- /dev/null +++ b/apps/marketing-emails/src/waitlist-launch-day-email.tsx @@ -0,0 +1,310 @@ +import type { CSSProperties, ReactElement } from 'react' +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text +} from 'react-email' +import { trackedMemryUrl, WAITLIST_CAMPAIGNS } from './tracking-links' + +export const waitlistLaunchDayContent = { + subject: 'MemryNote is live', + preview: 'Download link, waitlist code, and a note from me.' +} as const + +export type WaitlistLaunchDayEmailProps = { + firstName?: string + heroImageUrl?: string + downloadUrl?: string + discountCode?: string + discountExpiry?: string + replyToEmail?: string + unsubscribeUrl?: string +} + +const defaultProps = { + firstName: '', + heroImageUrl: '', + downloadUrl: trackedMemryUrl('/download/desktop', WAITLIST_CAMPAIGNS.launchDay, 'download_cta'), + discountCode: 'WAITLIST25', + discountExpiry: 'July 21', + replyToEmail: 'kaan@memrynote.com', + unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}' +} satisfies Required + +type EmailComponent = { + (props: WaitlistLaunchDayEmailProps): ReactElement + PreviewProps?: WaitlistLaunchDayEmailProps +} + +export const WaitlistLaunchDayEmail: EmailComponent = (props) => { + const { + firstName, + heroImageUrl, + downloadUrl, + discountCode, + discountExpiry, + replyToEmail, + unsubscribeUrl + } = { + ...defaultProps, + ...props + } + + const greeting = firstName ? `Hey ${firstName},` : 'Hey,' + + return ( + + + {waitlistLaunchDayContent.preview} + + + {greeting} + + MemryNote is live. + + + + The desktop app is free. Download it here: + + + + + Sync across devices is the paid plan. As a waitlist member, you get 25% off MemryNote + Sync annual — for the life of your subscription — if you claim before {discountExpiry}. + + +
    + Your waitlist code + {discountCode} + + 25% off MemryNote Sync annual, lifetime renewals. + + Expires {discountExpiry}. +
    + + What Sync gets you: + + + — Your notes across every device, end-to-end encrypted. + + — Unlimited devices. + — First access to anything new I build for Sync. + + + If you hit a bug or have questions, hit reply. I'm watching the inbox all day. + + + Six months of work. Now it's yours. + + + — Kaan +
    + + memrynote.com + +
    + +
    + + + You're getting this because you joined the MemryNote waitlist. Reply to me at{' '} + + {replyToEmail} + {' '} + or{' '} + + unsubscribe + + . + +
    + + + ) +} + +function Hero({ url }: { url: string }) { + if (url) { + return ( + MemryNote running on a desktop — the editor with a real note open + ) + } + + return ( +
    + + Launch hero: MemryNote running on macOS or Windows + + + 560×360 hosted PNG or JPG, under 200KB. Replace before sending. + +
    + ) +} + +WaitlistLaunchDayEmail.PreviewProps = defaultProps + +export default WaitlistLaunchDayEmail + +const styles = { + body: { + margin: 0, + backgroundColor: '#ffffff', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', + color: '#1a1a1a' + }, + container: { + width: '100%', + maxWidth: '560px', + margin: '0 auto', + padding: '40px 24px' + }, + paragraph: { + margin: '0 0 20px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + listItem: { + margin: '0 0 6px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + paragraphAfterList: { + margin: '20px 0', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + image: { + display: 'block', + width: '100%', + maxWidth: '560px', + height: 'auto', + margin: '12px 0 28px', + border: '1px solid #e8e8e8', + borderRadius: '6px' + }, + placeholder: { + margin: '12px 0 28px', + padding: '40px 24px', + border: '1px dashed #d0d0d0', + borderRadius: '6px', + backgroundColor: '#fafafa', + textAlign: 'center' + }, + placeholderLabel: { + margin: '0 0 6px', + color: '#555555', + fontSize: '14px', + lineHeight: '20px', + fontWeight: 600 + }, + placeholderHint: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + button: { + display: 'block', + width: '100%', + boxSizing: 'border-box', + margin: '8px 0 28px', + padding: '14px 20px', + borderRadius: '8px', + backgroundColor: '#1a1a1a', + color: '#ffffff', + fontSize: '16px', + fontWeight: 600, + lineHeight: '20px', + textAlign: 'center', + textDecoration: 'none' + }, + callout: { + margin: '12px 0 28px', + padding: '24px', + border: '1px solid #e8e8e8', + borderRadius: '8px', + backgroundColor: '#fafafa', + textAlign: 'center' + }, + calloutLabel: { + margin: '0 0 8px', + color: '#555555', + fontSize: '12px', + lineHeight: '16px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.6px' + }, + calloutCode: { + margin: '0 0 12px', + color: '#1a1a1a', + fontSize: '24px', + lineHeight: '32px', + fontWeight: 700, + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + letterSpacing: '1.5px' + }, + calloutDetail: { + margin: '0 0 4px', + color: '#1a1a1a', + fontSize: '14px', + lineHeight: '20px' + }, + calloutExpiry: { + margin: 0, + color: '#888888', + fontSize: '13px', + lineHeight: '18px' + }, + signature: { + margin: '8px 0 36px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signatureLink: { + color: '#888888', + fontSize: '15px', + textDecoration: 'underline' + }, + hr: { + margin: '0 0 16px', + border: 'none', + borderTop: '1px solid #ebebeb' + }, + footer: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + footerLink: { + color: '#555555', + textDecoration: 'underline' + } +} satisfies Record diff --git a/apps/marketing-emails/src/waitlist-launch-plain-email.tsx b/apps/marketing-emails/src/waitlist-launch-plain-email.tsx new file mode 100644 index 000000000..2f3d817f3 --- /dev/null +++ b/apps/marketing-emails/src/waitlist-launch-plain-email.tsx @@ -0,0 +1,158 @@ +import type { CSSProperties, ReactElement } from 'react' +import { Body, Container, Head, Hr, Html, Link, Preview, Text } from 'react-email' +import { trackedMemryUrl, WAITLIST_CAMPAIGNS } from './tracking-links' + +export const waitlistLaunchPlainContent = { + subject: 'MemryNote ships end of June', + preview: '6 weeks out. Here is the plan.' +} as const + +export type WaitlistLaunchPlainEmailProps = { + firstName?: string + replyToEmail?: string + unsubscribeUrl?: string +} + +const defaultProps = { + firstName: '', + replyToEmail: 'kaan@memrynote.com', + unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}' +} satisfies Required + +type EmailComponent = { + (props: WaitlistLaunchPlainEmailProps): ReactElement + PreviewProps?: WaitlistLaunchPlainEmailProps +} + +export const WaitlistLaunchPlainEmail: EmailComponent = (props) => { + const { firstName, replyToEmail, unsubscribeUrl } = { + ...defaultProps, + ...props + } + + const greeting = firstName ? `Hey ${firstName},` : 'Hey,' + + return ( + + + {waitlistLaunchPlainContent.preview} + + + {greeting} + + + Thanks for joining the MemryNote waitlist. Quick update: we're shipping end of + June. + + + + MemryNote is the notes app I wanted but couldn't find. Local-first, end-to-end + encrypted, your data stays on your machine. Optional sync across devices if you want it. + + + + I'll send a few more emails between now and launch: + + + — A look at what we built, with screenshots + — Early access details for waitlist folks + — Launch day, with a perk for being on this list + + + That's it for today. Hit reply if you have questions or if there's a specific + thing you want MemryNote to do. I read every reply. + + + + — Kaan +
    + + memrynote.com + +
    + +
    + + + You're getting this because you joined the MemryNote waitlist. Reply to me at{' '} + + {replyToEmail} + {' '} + or{' '} + + unsubscribe + + . + +
    + + + ) +} + +WaitlistLaunchPlainEmail.PreviewProps = defaultProps + +export default WaitlistLaunchPlainEmail + +const styles = { + body: { + margin: 0, + backgroundColor: '#ffffff', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', + color: '#1a1a1a' + }, + container: { + width: '100%', + maxWidth: '560px', + margin: '0 auto', + padding: '40px 24px' + }, + paragraph: { + margin: '0 0 20px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + listItem: { + margin: '0 0 6px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + paragraphAfterList: { + margin: '20px 0', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signature: { + margin: '8px 0 36px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signatureLink: { + color: '#888888', + fontSize: '15px', + textDecoration: 'underline' + }, + hr: { + margin: '0 0 16px', + border: 'none', + borderTop: '1px solid #ebebeb' + }, + footer: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + footerLink: { + color: '#555555', + textDecoration: 'underline' + } +} satisfies Record diff --git a/apps/marketing-emails/src/waitlist-product-preview-email.tsx b/apps/marketing-emails/src/waitlist-product-preview-email.tsx new file mode 100644 index 000000000..d539dea99 --- /dev/null +++ b/apps/marketing-emails/src/waitlist-product-preview-email.tsx @@ -0,0 +1,223 @@ +import type { CSSProperties, ReactElement } from 'react' +import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from 'react-email' +import { trackedMemryUrl, WAITLIST_CAMPAIGNS } from './tracking-links' + +export const waitlistProductPreviewContent = { + subject: 'what MemryNote actually looks like', + preview: 'The editor, the local-first foundation, and a question for you.' +} as const + +export type WaitlistProductPreviewEmailProps = { + firstName?: string + screenshotUrl?: string + replyToEmail?: string + unsubscribeUrl?: string +} + +const defaultProps = { + firstName: '', + screenshotUrl: '', + replyToEmail: 'kaan@memrynote.com', + unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}' +} satisfies Required + +type EmailComponent = { + (props: WaitlistProductPreviewEmailProps): ReactElement + PreviewProps?: WaitlistProductPreviewEmailProps +} + +export const WaitlistProductPreviewEmail: EmailComponent = (props) => { + const { firstName, screenshotUrl, replyToEmail, unsubscribeUrl } = { + ...defaultProps, + ...props + } + + const greeting = firstName ? `Hey ${firstName},` : 'Hey,' + + return ( + + + {waitlistProductPreviewContent.preview} + + + {greeting} + + + MemryNote launches at the end of June. This week I want to show the editor. + + + Here's what the editor looks like: + + + + It's a markdown editor with a few opinions: + + + — Local-first. Your notes live on your machine, not in a vendor cloud. + + + — End-to-end encrypted (XChaCha20). Even with sync on, the server never sees plaintext. + + + — Works offline. Always. No "reconnecting..." spinners. + + + + That's the whole pitch. No new file format, no lock-in, no telemetry, no AI + training data going anywhere. + + + + If there's one part you want to see before launch — tasks, the daily journal, the + graph, the agent — hit reply with which one. I'll write the next email about + whatever the most people ask for. + + + + — Kaan +
    + + memrynote.com + +
    + +
    + + + You're getting this because you joined the MemryNote waitlist. Reply to me at{' '} + + {replyToEmail} + {' '} + or{' '} + + unsubscribe + + . + +
    + + + ) +} + +function Screenshot({ url }: { url: string }) { + if (url) { + return ( + The MemryNote editor with a real note open + ) + } + + return ( +
    + + Screenshot of the MemryNote editor with a real note open + + + 560×360 hosted PNG or JPG, under 200KB. Replace before sending. + +
    + ) +} + +WaitlistProductPreviewEmail.PreviewProps = defaultProps + +export default WaitlistProductPreviewEmail + +const styles = { + body: { + margin: 0, + backgroundColor: '#ffffff', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', + color: '#1a1a1a' + }, + container: { + width: '100%', + maxWidth: '560px', + margin: '0 auto', + padding: '40px 24px' + }, + paragraph: { + margin: '0 0 20px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + listItem: { + margin: '0 0 6px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + paragraphAfterList: { + margin: '20px 0', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + image: { + display: 'block', + width: '100%', + maxWidth: '560px', + height: 'auto', + margin: '12px 0 28px', + border: '1px solid #e8e8e8', + borderRadius: '6px' + }, + placeholder: { + margin: '12px 0 28px', + padding: '40px 24px', + border: '1px dashed #d0d0d0', + borderRadius: '6px', + backgroundColor: '#fafafa', + textAlign: 'center' + }, + placeholderLabel: { + margin: '0 0 6px', + color: '#555555', + fontSize: '14px', + lineHeight: '20px', + fontWeight: 600 + }, + placeholderHint: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + signature: { + margin: '8px 0 36px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signatureLink: { + color: '#888888', + fontSize: '15px', + textDecoration: 'underline' + }, + hr: { + margin: '0 0 16px', + border: 'none', + borderTop: '1px solid #ebebeb' + }, + footer: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + footerLink: { + color: '#555555', + textDecoration: 'underline' + } +} satisfies Record diff --git a/apps/marketing-emails/src/waitlist-program-content.ts b/apps/marketing-emails/src/waitlist-program-content.ts new file mode 100644 index 000000000..d4545f428 --- /dev/null +++ b/apps/marketing-emails/src/waitlist-program-content.ts @@ -0,0 +1,154 @@ +import type { WaitlistProgramEmailContent } from './waitlist-program-email' +import { WAITLIST_CAMPAIGNS } from './tracking-links' + +export const scatteredWorkflowContent = { + subject: 'Your notes, tasks, calendar, and journal should not live in four places', + preview: 'The problem MemryNote is built around.', + intro: [ + 'Second email from me before launch.', + 'The thing I kept running into: notes in one app, tasks in another, calendar somewhere else, journal in a different place. Then I would spend half my day remembering where I put the work.', + 'MemryNote is built around a simpler idea: capture the thing once, then keep it connected.' + ], + listTitle: 'The first version focuses on a few pieces:', + bullets: [ + 'Inbox for quick capture', + 'Notes that can hold real tasks', + 'Daily journal pages for what happened today', + 'Calendar so dates do not disappear' + ], + outro: [ + 'That is the product shipping end of June. Simple surface, local-first foundation, fewer places to check.', + 'Hit reply and tell me which part of your current workflow breaks most often. I read every reply.' + ], + campaign: WAITLIST_CAMPAIGNS.scatteredWorkflow +} as const satisfies WaitlistProgramEmailContent + +export const workflowContent = { + subject: 'How tasks, journal, and calendar connect in MemryNote', + preview: 'A practical look at the daily workflow.', + intro: [ + 'This week I want to show the daily loop.', + 'In MemryNote, tasks can live inside notes. Daily journal entries can point back to real work. Calendar dates stay visible without becoming another app you have to maintain.' + ], + listTitle: 'The flow I care about:', + bullets: [ + 'Write something down', + 'Turn part of it into a task if needed', + 'See it again from today or from the calendar', + 'Search later and find the note, task, or journal entry from the same vault' + ], + outro: [ + 'That is it. No big productivity system, just fewer places to check before you start working.', + 'If your day starts differently, reply and tell me. I want the launch defaults to match real workflows.' + ], + campaign: WAITLIST_CAMPAIGNS.workflow +} as const satisfies WaitlistProgramEmailContent + +export const localFirstAiContent = { + subject: 'Local-first, private by default, AI when useful', + preview: 'How MemryNote handles privacy, offline work, and the agent.', + intro: [ + 'A lot of people on the waitlist asked the same privacy question, so I want to answer it clearly.', + 'MemryNote is local-first. Your vault starts on your machine. It works offline. Sync is optional and end-to-end encrypted when you turn it on.' + ], + listTitle: 'The shape is:', + bullets: [ + 'Local by default', + 'Encrypted before sync leaves the device', + 'Server does not need plaintext notes', + 'AI agent only when you ask for it' + ], + outro: [ + 'I want MemryNote to feel boring in the right ways: fast, private, offline, no cloud lock-in.', + 'Next week I will send launch-week details for waitlist folks.' + ], + campaign: WAITLIST_CAMPAIGNS.localFirstAi +} as const satisfies WaitlistProgramEmailContent + +export const launchWeekContent = { + subject: 'MemryNote launches next week', + preview: 'What waitlist members get on launch day.', + intro: [ + 'MemryNote launches next week.', + 'The desktop app is free to download. The paid plan is for encrypted sync across devices. Waitlist members get 25% off your first year on an annual plan.' + ], + listTitle: 'On launch day I will send:', + bullets: [ + 'Download link', + 'Waitlist discount code', + 'Sync checkout link', + 'Short getting-started path' + ], + outro: [ + 'No code to save today. I will send it when checkout opens.', + 'If you already know what you want to test first, reply with it.', + 'See you next week.' + ], + campaign: WAITLIST_CAMPAIGNS.launchWeek +} as const satisfies WaitlistProgramEmailContent + +export const gettingStartedContent = { + subject: 'First 10 minutes in MemryNote', + preview: 'What to do after download.', + intro: [ + 'If you downloaded MemryNote, start small.', + 'The app gets useful fastest when you bring one real workflow into it instead of trying to migrate everything.', + 'Here is the path I recommend for the first 10 minutes.' + ], + listTitle: 'Start here:', + bullets: [ + 'create one note for something active', + 'drop three tasks into that note', + 'open today in the journal and write what matters', + 'add one date so the calendar has something real to show', + 'search for the note and make sure it feels fast' + ], + outro: [ + 'That is enough. You will learn more from one real day in the app than from a big import.', + 'Hit reply if the first-run flow feels confusing. Those fixes are high priority.' + ], + campaign: WAITLIST_CAMPAIGNS.gettingStarted +} as const satisfies WaitlistProgramEmailContent + +export const useCasesContent = { + subject: 'Four ways to use MemryNote', + preview: 'Builders, founders, researchers, and students use the same core loop.', + intro: [ + 'A few people asked what MemryNote is best for after the first day.', + 'The honest answer: it is strongest when notes and action keep touching each other.', + 'Here are four good starting patterns.' + ], + listTitle: 'Use it for:', + bullets: [ + 'builders tracking decisions, bugs, and product notes in one vault', + 'founders keeping customer calls, tasks, and launch ideas connected', + 'researchers linking reading notes to projects and follow-up questions', + 'students keeping lecture notes, assignments, and exam prep searchable offline' + ], + outro: [ + 'You do not need a perfect system. Pick one active project and let the structure grow from real work.', + 'Reply with your use case if it is different. I want the examples on the site to match how people actually use it.' + ], + campaign: WAITLIST_CAMPAIGNS.useCases +} as const satisfies WaitlistProgramEmailContent + +export const feedbackContent = { + subject: 'What should I fix next?', + preview: 'Reply with bugs, sharp edges, or the thing you wanted most.', + intro: [ + "Now that MemryNote is in people's hands, direct feedback is the most useful thing.", + 'I am especially interested in anything that made you hesitate: confusing setup, missing import path, rough sync flow, unclear pricing, or a feature you expected to find but did not.' + ], + listTitle: 'Send me:', + bullets: [ + 'one bug that blocked you', + 'one workflow that felt slower than your current app', + 'one feature that would make MemryNote easier to recommend', + 'one sentence I should use to explain the product better' + ], + outro: [ + 'Short replies are perfect. Screenshots are even better.', + 'I will use this batch to decide what gets fixed before the next public update.' + ], + campaign: WAITLIST_CAMPAIGNS.feedback +} as const satisfies WaitlistProgramEmailContent diff --git a/apps/marketing-emails/src/waitlist-program-email.tsx b/apps/marketing-emails/src/waitlist-program-email.tsx new file mode 100644 index 000000000..4653a72bb --- /dev/null +++ b/apps/marketing-emails/src/waitlist-program-email.tsx @@ -0,0 +1,184 @@ +import type { CSSProperties, ReactElement } from 'react' +import { Body, Container, Head, Hr, Html, Link, Preview, Text } from 'react-email' +import { + feedbackContent, + gettingStartedContent, + launchWeekContent, + localFirstAiContent, + scatteredWorkflowContent, + useCasesContent, + workflowContent +} from './waitlist-program-content' +import { trackedMemryUrl } from './tracking-links' +import type { WaitlistCampaignId } from './tracking-links' + +export type WaitlistProgramEmailContent = { + subject: string + preview: string + intro: readonly string[] + listTitle?: string + bullets?: readonly string[] + outro?: readonly string[] + campaign: WaitlistCampaignId +} + +export type WaitlistProgramEmailProps = { + firstName?: string + replyToEmail?: string + unsubscribeUrl?: string +} + +const defaultProps = { + firstName: '', + replyToEmail: 'kaan@memrynote.com', + unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}' +} satisfies Required + +type EmailComponent = { + (props: WaitlistProgramEmailProps): ReactElement + PreviewProps?: WaitlistProgramEmailProps +} + +function createWaitlistProgramEmail(content: WaitlistProgramEmailContent): EmailComponent { + const Email: EmailComponent = (props) => { + const { firstName, replyToEmail, unsubscribeUrl } = { + ...defaultProps, + ...props + } + + const greeting = firstName ? `Hey ${firstName},` : 'Hey,' + + return ( + + + {content.preview} + + + {greeting} + + {content.intro.map((paragraph) => ( + + {paragraph} + + ))} + + {content.listTitle ? {content.listTitle} : null} + + {content.bullets?.map((bullet) => ( + + — {bullet} + + ))} + + {content.outro?.map((paragraph, index) => ( + + {paragraph} + + ))} + + + — Kaan +
    + + memrynote.com + +
    + +
    + + + You're getting this because you joined the MemryNote waitlist. Reply to me at{' '} + + {replyToEmail} + {' '} + or{' '} + + unsubscribe + + . + +
    + + + ) + } + + Email.PreviewProps = defaultProps + return Email +} + +export const WaitlistScatteredWorkflowEmail = createWaitlistProgramEmail(scatteredWorkflowContent) +export const WaitlistWorkflowEmail = createWaitlistProgramEmail(workflowContent) +export const WaitlistLocalFirstAiEmail = createWaitlistProgramEmail(localFirstAiContent) +export const WaitlistLaunchWeekEmail = createWaitlistProgramEmail(launchWeekContent) +export const WaitlistGettingStartedEmail = createWaitlistProgramEmail(gettingStartedContent) +export const WaitlistUseCasesEmail = createWaitlistProgramEmail(useCasesContent) +export const WaitlistFeedbackEmail = createWaitlistProgramEmail(feedbackContent) + +const styles = { + body: { + margin: 0, + backgroundColor: '#ffffff', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', + color: '#1a1a1a' + }, + container: { + width: '100%', + maxWidth: '560px', + margin: '0 auto', + padding: '40px 24px' + }, + paragraph: { + margin: '0 0 20px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + listItem: { + margin: '0 0 6px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + paragraphAfterList: { + margin: '20px 0', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signature: { + margin: '8px 0 36px', + color: '#1a1a1a', + fontSize: '16px', + lineHeight: '26px' + }, + signatureLink: { + color: '#888888', + fontSize: '15px', + textDecoration: 'underline' + }, + hr: { + margin: '0 0 16px', + border: 'none', + borderTop: '1px solid #ebebeb' + }, + footer: { + margin: 0, + color: '#888888', + fontSize: '12px', + lineHeight: '18px' + }, + footerLink: { + color: '#555555', + textDecoration: 'underline' + } +} satisfies Record diff --git a/apps/marketing-emails/tsconfig.json b/apps/marketing-emails/tsconfig.json new file mode 100644 index 000000000..3cb473467 --- /dev/null +++ b/apps/marketing-emails/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@memry/typescript-config/web.json", + "compilerOptions": { + "allowImportingTsExtensions": true + }, + "include": ["emails", "src"] +} diff --git a/package.json b/package.json index 77e407e7b..bfafce848 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:desktop": "node scripts/run-turbo.js run dev --filter=@memry/desktop", "dev:landing": "pnpm --filter @memry/landing dev", "dev:sync-server": "node scripts/run-turbo.js run dev --filter=@memry/sync-server", + "dev:emails": "pnpm --filter @memry/marketing-emails dev", "staging": "MEMRY_ENV=staging pnpm --filter @memry/desktop dev:staging", "deploy:sync:staging": "pnpm --filter @memry/sync-server deploy:staging", "deploy:sync:production": "pnpm --filter @memry/sync-server deploy:production", @@ -24,11 +25,13 @@ "build": "pnpm build:desktop", "build:desktop": "pnpm --filter @memry/desktop build", "build:landing": "pnpm --filter @memry/landing build", + "build:emails": "pnpm --filter @memry/marketing-emails build", "typecheck": "pnpm repair:links && node scripts/run-turbo.js run typecheck --filter=@memry/contracts --filter=@memry/db-schema --filter=@memry/domain-inbox --filter=@memry/domain-notes --filter=@memry/domain-tasks --filter=@memry/storage-data --filter=@memry/storage-vault --filter=@memry/app-core --filter=@memry/rpc --filter=@memry/sync-core --filter=@memry/shared --filter=@memry/cli --filter=@memry/desktop --filter=@memry/sync-server", "typecheck:packages": "pnpm repair:links && node scripts/run-turbo.js run typecheck --filter=@memry/contracts --filter=@memry/db-schema --filter=@memry/domain-inbox --filter=@memry/domain-notes --filter=@memry/domain-tasks --filter=@memry/storage-data --filter=@memry/storage-vault --filter=@memry/app-core --filter=@memry/rpc --filter=@memry/sync-core --filter=@memry/shared --filter=@memry/cli", "typecheck:desktop": "pnpm --filter @memry/desktop typecheck", "typecheck:landing": "pnpm --filter @memry/landing typecheck", "typecheck:sync-server": "pnpm --filter @memry/sync-server typecheck", + "typecheck:emails": "pnpm --filter @memry/marketing-emails typecheck", "test": "pnpm repair:links && node scripts/run-turbo.js run test --filter=@memry/app-core --filter=@memry/cli --filter=@memry/desktop --filter=@memry/sync-server", "test:cli": "pnpm --filter @memry/app-core test && pnpm --filter @memry/cli test", "test:release": "node --test scripts/release-utils.test.mjs scripts/release-notes-utils.test.mjs scripts/reddit-release-utils.test.mjs scripts/pre-push-policy.test.mjs scripts/package-scripts.test.mjs apps/desktop/scripts/build-packaged-app-utils.test.cjs apps/desktop/scripts/check-packaged-runtime-deps-utils.test.cjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47caeca22..fbbc761b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -722,8 +722,8 @@ importers: specifier: ^1.374.2 version: 1.374.2 posthog-node: - specifier: ^4.8.1 - version: 4.18.0 + specifier: ^5.34.6 + version: 5.34.6 react: specifier: ^19.2.0 version: 19.2.4 @@ -736,6 +736,9 @@ importers: react-router-dom: specifier: ^7.11.0 version: 7.15.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + resend: + specifier: ^6.12.3 + version: 6.12.3(@react-email/render@2.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -798,6 +801,31 @@ importers: specifier: ^7.3.2 version: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + apps/marketing-emails: + dependencies: + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + react-email: + specifier: ^6.1.4 + version: 6.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@memry/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@types/react': + specifier: ^19.2.5 + version: 19.2.13 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.13) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + apps/sync-server: dependencies: '@memry/contracts': @@ -1299,6 +1327,11 @@ packages: resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} @@ -1360,6 +1393,10 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} @@ -3585,6 +3622,13 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-email/render@2.0.8': + resolution: {integrity: sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-sigma/core@5.0.6': resolution: {integrity: sha512-Xu2qXyvDZIhmvGC1n8d7Kcxm5Ntcz4HbPIM7CPDD2e4h3s/oxVpVPX7wtsNreJRRPj9mK+3oqB6SWXNI4mTqVg==} peerDependencies: @@ -3844,6 +3888,9 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -3933,9 +3980,15 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4432,6 +4485,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -5044,6 +5100,10 @@ packages: resolution: {integrity: sha512-CW2gKbJFTuX1feMvOrvsVMmijAOgI9kg2Ie9Dq3gOcMt/dVVoVmqNlLcEUCT13NxHFMEajcUcVBIplbyDroDiw==} engines: {node: '>=18'} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -5329,6 +5389,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -5528,6 +5592,10 @@ packages: resolution: {integrity: sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==} engines: {node: '>= 14.16.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -5554,6 +5622,9 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@1.2.3: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} @@ -5646,6 +5717,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -5768,6 +5843,10 @@ packages: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -6005,6 +6084,10 @@ packages: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} + debounce@2.2.0: + resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} + engines: {node: '>=18'} + debug-logfmt@1.4.7: resolution: {integrity: sha512-NzGmPp2Fru8KerWcg4zfiPCC1rspLUPqfH5Duz/ZF49CqO97odSx7eFjBNiOQzNQYfvpEEPrxNjyA436lITQkQ==} engines: {node: '>= 8'} @@ -6373,6 +6456,14 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.8: + resolution: {integrity: sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -6682,6 +6773,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -6946,6 +7040,10 @@ packages: resolution: {integrity: sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==} engines: {node: 20 || >=22} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -6954,6 +7052,10 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -7150,6 +7252,10 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -7162,6 +7268,9 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -7590,6 +7699,10 @@ packages: engines: {node: '>=10'} hasBin: true + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -7720,6 +7833,9 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + lenis@1.3.23: resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==} peerDependencies: @@ -7978,6 +8094,10 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -8066,6 +8186,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -8139,6 +8264,9 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -8450,6 +8578,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -8558,6 +8690,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -8643,6 +8779,10 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -8665,6 +8805,11 @@ packages: nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + nypm@0.6.6: + resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8858,6 +9003,9 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -8895,6 +9043,10 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -8917,6 +9069,9 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -8936,6 +9091,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picospinner@3.0.0: + resolution: {integrity: sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg==} + engines: {node: '>=18.0.0'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -8970,6 +9129,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -8984,9 +9146,14 @@ packages: posthog-js@1.374.2: resolution: {integrity: sha512-6z1xGlVocd3NmSZlJNFfpedLIHLcejuuQPxvrpHDvtyVI9tN1NPqbM7T7coXw2It6gdZ/nAgDuZkNxfIut+Spw==} - posthog-node@4.18.0: - resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==} - engines: {node: '>=15.0.0'} + posthog-node@5.34.6: + resolution: {integrity: sha512-oDjagFRkmCbWJBxG1FVU3kOGC6dxNpR849q8ARrZSBK3zWz4zJox6V5EjrATKM9RXKvAmbCSFoxYaOYTzp3phA==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} @@ -9031,6 +9198,10 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -9261,6 +9432,14 @@ packages: peerDependencies: react: ^19.2.4 + react-email@6.1.5: + resolution: {integrity: sha512-f4I7Y+9kEMjALvcL5dn1TAsDJG7VgksrcR4x9PMQiaCCud4iUefJMBQAcOhvoBpk1k75lGhR/p75d7WMF24PfA==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -9518,6 +9697,15 @@ packages: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} + resend@6.12.3: + resolution: {integrity: sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -9666,6 +9854,9 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -9823,6 +10014,17 @@ packages: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} + socket.io-adapter@2.5.7: + resolution: {integrity: sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -9912,6 +10114,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + stat-mode@0.3.0: resolution: {integrity: sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==} @@ -10079,6 +10284,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.92.2: + resolution: {integrity: sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==} + swr@2.4.1: resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} peerDependencies: @@ -10874,6 +11082,18 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -11330,6 +11550,10 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -11397,6 +11621,18 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/traverse@7.27.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -13728,6 +13964,13 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-email/render@2.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@react-sigma/core@5.0.6(graphology@0.26.0(graphology-types@0.24.8))(react@19.2.4)(sigma@3.0.2(graphology-types@0.24.8))': dependencies: graphology: 0.26.0(graphology-types@0.24.8) @@ -13888,6 +14131,11 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@shikijs/core@2.5.0': dependencies: '@shikijs/engine-javascript': 2.5.0 @@ -14010,8 +14258,12 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@socket.io/component-emitter@3.1.2': {} + '@speed-highlight/core@1.2.14': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@streamdown/cjk@1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)': @@ -14518,6 +14770,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.2.2 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -15454,6 +15710,11 @@ snapshots: maybe-combine-errors: 1.0.0 module-error: 1.0.2 + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -15474,6 +15735,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true agent-base@7.1.4: {} @@ -15744,6 +16006,7 @@ snapshots: transitivePeerDependencies: - debug - supports-color + optional: true b4a@1.8.1: {} @@ -15785,6 +16048,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.9.19: {} basic-ftp@5.3.1: {} @@ -16055,6 +16320,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -16071,6 +16340,8 @@ snapshots: ci-info@4.4.0: {} + citty@0.2.2: {} + cjs-module-lexer@1.2.3: {} class-variance-authority@0.7.1: @@ -16167,6 +16438,8 @@ snapshots: commander@11.1.0: {} + commander@13.1.0: {} + commander@14.0.3: {} commander@5.1.0: {} @@ -16276,6 +16549,11 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} css.escape@1.5.1: {} @@ -16563,6 +16841,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + debounce@2.2.0: {} + debug-logfmt@1.4.7: dependencies: '@kikobeats/time-span': 1.0.11 @@ -16893,6 +17173,25 @@ snapshots: dependencies: once: 1.4.0 + engine.io-parser@5.2.3: {} + + engine.io@6.6.8: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 25.2.2 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -17352,6 +17651,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -17424,7 +17725,8 @@ snapshots: dependencies: tabbable: 6.4.0 - follow-redirects@1.16.0: {} + follow-redirects@1.16.0: + optional: true for-each@0.3.5: dependencies: @@ -17622,6 +17924,12 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -17640,6 +17948,8 @@ snapshots: semver: 7.7.4 serialize-error: 7.0.1 + globals@11.12.0: {} + globals@14.0.0: {} globals@16.5.0: {} @@ -17958,6 +18268,14 @@ snapshots: dependencies: void-elements: 3.1.0 + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -17971,6 +18289,13 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-cache-semantics@4.2.0: {} http-errors@1.7.3: @@ -18007,6 +18332,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -18357,6 +18683,8 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 + jiti@2.4.2: {} + jiti@2.6.1: {} jose@5.9.6: {} @@ -18505,6 +18833,8 @@ snapshots: lazy-val@1.0.5: {} + leac@0.6.0: {} + lenis@1.3.23(react@19.2.4)(vue@3.5.34(typescript@5.9.3)): optionalDependencies: react: 19.2.4 @@ -18708,6 +19038,11 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -18815,6 +19150,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@16.4.2: {} marked@17.0.1: {} @@ -18994,6 +19331,8 @@ snapshots: mdn-data@2.12.2: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} media-chrome@4.16.1(react@19.2.4): @@ -19486,6 +19825,8 @@ snapshots: minipass@7.1.2: {} + minipass@7.1.3: {} + minisearch@7.2.0: {} minizlib@3.1.0: @@ -19573,6 +19914,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} netmask@2.1.1: {} @@ -19661,6 +20004,8 @@ snapshots: dependencies: abbrev: 4.0.0 + normalize-path@3.0.0: {} + normalize-url@6.1.0: {} npm-run-path@4.0.1: @@ -19680,6 +20025,12 @@ snapshots: nwsapi@2.2.23: {} + nypm@0.6.6: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.1.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -19940,6 +20291,11 @@ snapshots: dependencies: entities: 6.0.1 + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -19966,6 +20322,11 @@ snapshots: lru-cache: 11.2.5 minipass: 7.1.2 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.5 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} path-to-regexp@8.4.2: {} @@ -19983,6 +20344,8 @@ snapshots: pe-library@0.4.1: {} + peberminta@0.9.0: {} + pend@1.2.0: {} perfect-debounce@1.0.0: {} @@ -19995,6 +20358,8 @@ snapshots: picomatch@4.0.4: {} + picospinner@3.0.0: {} + pkce-challenge@5.0.1: {} platform@1.3.6: {} @@ -20028,6 +20393,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.4: {} + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -20057,12 +20424,9 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.2.0 - posthog-node@4.18.0: + posthog-node@5.34.6: dependencies: - axios: 1.16.1 - transitivePeerDependencies: - - debug - - supports-color + '@posthog/core': 1.29.5 postject@1.0.0-alpha.6: dependencies: @@ -20110,6 +20474,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + prismjs@1.30.0: {} + proc-log@5.0.0: {} proc-log@6.1.0: {} @@ -20291,7 +20657,8 @@ snapshots: proxy-from-env@1.1.0: {} - proxy-from-env@2.1.0: {} + proxy-from-env@2.1.0: + optional: true pump@3.0.3: dependencies: @@ -20428,6 +20795,37 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-email@6.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/parser': 7.27.0 + '@babel/traverse': 7.27.0 + '@react-email/render': 2.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + chokidar: 4.0.3 + commander: 13.1.0 + conf: 15.1.0 + css-tree: 3.2.1 + debounce: 2.2.0 + esbuild: 0.27.3 + glob: 13.0.6 + jiti: 2.4.2 + log-symbols: 7.0.1 + marked: 15.0.12 + mime-types: 3.0.2 + normalize-path: 3.0.0 + nypm: 0.6.6 + picospinner: 3.0.0 + prismjs: 1.30.0 + prompts: 2.4.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + socket.io: 4.8.3 + tailwindcss: 4.1.18 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + react-fast-compare@3.2.2: {} react-helmet-async@3.0.0(react@19.2.4): @@ -20763,6 +21161,13 @@ snapshots: dependencies: pe-library: 0.4.1 + resend@6.12.3(@react-email/render@2.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + postal-mime: 2.7.4 + svix: 1.92.2 + optionalDependencies: + '@react-email/render': 2.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -20960,6 +21365,10 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semver-compare@1.0.0: {} semver@5.7.2: {} @@ -21231,6 +21640,36 @@ snapshots: smol-toml@1.5.2: {} + socket.io-adapter@2.5.7: + dependencies: + debug: 4.4.3 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.8 + socket.io-adapter: 2.5.7 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -21312,6 +21751,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stat-mode@0.3.0: {} stat-mode@1.0.0: {} @@ -21517,6 +21961,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.92.2: + dependencies: + standardwebhooks: 1.0.0 + swr@2.4.1(react@19.2.4): dependencies: dequal: 2.0.3 @@ -22378,6 +22826,8 @@ snapshots: ws@8.19.0: {} + ws@8.20.1: {} + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.0