Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+8
View File
@@ -0,0 +1,8 @@
// Environment configuration
// In development: Uses NEXT_PUBLIC_* env vars from .env file
// In production (Docker): Build-time values are replaced at container startup via sed
export const API_URI = process.env.NEXT_PUBLIC_API_URI || 'http://localhost:8080';
export const DASHBOARD_URI = process.env.NEXT_PUBLIC_DASHBOARD_URI || 'http://localhost:3000';
export const LANDING_URI = process.env.NEXT_PUBLIC_LANDING_URI || 'http://localhost:4000';
export const WIKI_URI = process.env.NEXT_PUBLIC_WIKI_URI || 'http://localhost:1000';
@@ -0,0 +1,74 @@
import type {Project} from '@plunk/db';
import {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
import {useSWRConfig} from 'swr';
import {useProjects} from '../hooks/useProject';
interface ActiveProjectContextValue {
activeProject: Project | null;
setActiveProject: (project: Project) => void;
availableProjects: Project[];
isLoading: boolean;
}
const ActiveProjectContext = createContext<ActiveProjectContextValue | undefined>(undefined);
export function ActiveProjectProvider({children}: {children: ReactNode}) {
const {data: projects, isLoading} = useProjects();
const {mutate} = useSWRConfig();
// State is null until projects load, but localStorage is read synchronously by network.ts
// This ensures consistent behavior: either all calls use stored ID, or all fall back to projects[0]
const [activeProject, setActiveProjectState] = useState<Project | null>(null);
// Initialize active project from localStorage or use first project
useEffect(() => {
if (!projects || projects.length === 0) return;
const storedProjectId = localStorage.getItem('activeProjectId');
if (storedProjectId) {
// Find the stored project in available projects
const project = projects.find(p => p.id === storedProjectId);
if (project) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveProjectState(project);
} else if (projects[0]) {
// Stored project not found (user might have been removed), use first project
setActiveProjectState(projects[0]);
localStorage.setItem('activeProjectId', projects[0].id);
}
} else if (projects[0]) {
// No stored project, initialize with first one
setActiveProjectState(projects[0]);
localStorage.setItem('activeProjectId', projects[0].id);
}
}, [projects]);
const setActiveProject = (project: Project) => {
setActiveProjectState(project);
localStorage.setItem('activeProjectId', project.id);
// Invalidate all SWR cache to refetch data for new project
void mutate(() => true, undefined, {revalidate: true});
};
const value: ActiveProjectContextValue = {
activeProject,
setActiveProject,
availableProjects: projects ?? [],
isLoading,
};
return <ActiveProjectContext.Provider value={value}>{children}</ActiveProjectContext.Provider>;
}
export function useActiveProject() {
const context = useContext(ActiveProjectContext);
if (context === undefined) {
throw new Error('useActiveProject must be used within an ActiveProjectProvider');
}
return context;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Shared date formatting and manipulation utilities
*/
/**
* Get the user's timezone
*/
export function getUserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/**
* Format a Date to datetime-local input format (YYYY-MM-DDTHH:mm)
*/
export function formatDateTimeLocal(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* Schedule presets for quick date/time selection
*/
export const schedulePresets = {
/**
* In 1 hour from now
*/
inOneHour: (): string => {
const date = new Date();
date.setHours(date.getHours() + 1);
return formatDateTimeLocal(date);
},
/**
* In 3 hours from now
*/
inThreeHours: (): string => {
const date = new Date();
date.setHours(date.getHours() + 3);
return formatDateTimeLocal(date);
},
/**
* Tomorrow at 9 AM
*/
tomorrowAt9AM: (): string => {
const date = new Date();
date.setDate(date.getDate() + 1);
date.setHours(9, 0, 0, 0);
return formatDateTimeLocal(date);
},
/**
* Tomorrow at 2 PM
*/
tomorrowAt2PM: (): string => {
const date = new Date();
date.setDate(date.getDate() + 1);
date.setHours(14, 0, 0, 0);
return formatDateTimeLocal(date);
},
/**
* Next Monday at 9 AM
*/
nextMonday: (): string => {
const date = new Date();
const dayOfWeek = date.getDay();
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
date.setDate(date.getDate() + daysUntilMonday);
date.setHours(9, 0, 0, 0);
return formatDateTimeLocal(date);
},
/**
* In 1 week at 9 AM
*/
inOneWeek: (): string => {
const date = new Date();
date.setDate(date.getDate() + 7);
date.setHours(9, 0, 0, 0);
return formatDateTimeLocal(date);
},
};
/**
* Format a date for display (full date and time)
*/
export function formatFullDateTime(date: Date): string {
return date.toLocaleString(undefined, {
dateStyle: 'full',
timeStyle: 'short',
});
}
/**
* Format a date for display in UTC
*/
export function formatUTCDateTime(date: Date): string {
return date.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC',
});
}
+83
View File
@@ -0,0 +1,83 @@
import {useMemo} from 'react';
import useSWR from 'swr';
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
export interface TimeSeriesDataPoint {
date: string;
emails: number;
opens: number;
clicks: number;
bounces: number;
}
export interface AnalyticsData {
stats: ActivityStats | null;
timeSeries: TimeSeriesDataPoint[] | null;
isLoading: boolean;
error: Error | undefined;
}
interface UseAnalyticsOptions {
startDate?: string;
endDate?: string;
days?: number;
}
/**
* Hook to fetch analytics data including activity stats and time series data
*/
export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
const {days = 30} = options;
// Calculate date range - memoized to prevent infinite re-renders
// Only recalculate when days or explicit dates change
/* eslint-disable react-hooks/purity */
const {startDate, endDate} = useMemo(() => {
const end = options.endDate || new Date().toISOString();
const now = Date.now();
const start = options.startDate || new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
return {startDate: start, endDate: end};
}, [days, options.startDate, options.endDate]);
/* eslint-enable react-hooks/purity */
// Fetch activity stats
const {
data: stats,
error: statsError,
isLoading: statsLoading,
} = useSWR<ActivityStats>(`/activity/stats?startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000, // Refresh every 5 minutes
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
});
// Fetch time series data (if endpoint exists)
const {
data: timeSeries,
error: timeSeriesError,
isLoading: timeSeriesLoading,
} = useSWR<TimeSeriesDataPoint[]>(`/analytics/timeseries?startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000, // Refresh every 5 minutes
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
shouldRetryOnError: false, // Don't error out if endpoint doesn't exist yet
});
return {
stats: stats || null,
timeSeries: timeSeries || null,
isLoading: statsLoading || timeSeriesLoading,
error: statsError || timeSeriesError,
};
}
@@ -0,0 +1,49 @@
import useSWR from 'swr';
export interface BillingPeriod {
start: string;
end: string;
}
export interface UsageRecord {
period: BillingPeriod;
totalUsage: number;
}
export interface UpcomingInvoice {
amountDue: number;
currency: string;
periodStart: string;
periodEnd: string;
subtotal: number;
total: number;
}
export interface BillingConsumptionData {
period: BillingPeriod;
usage: {
total: number;
records: UsageRecord[];
};
upcomingInvoice: UpcomingInvoice | null;
}
/**
* Hook to fetch current month billing consumption from Stripe
*/
export function useBillingConsumption(projectId: string | undefined, hasSubscription: boolean) {
const {data, error, mutate, isLoading} = useSWR<BillingConsumptionData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-consumption` : null,
{
revalidateOnFocus: false,
refreshInterval: 60000, // Refresh every minute
},
);
return {
consumptionData: data,
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,53 @@
import useSWR from 'swr';
export interface Invoice {
id: string;
number: string | null;
status: string;
amountDue: number;
amountPaid: number;
currency: string;
created: string;
periodStart: string | null;
periodEnd: string | null;
hostedInvoiceUrl: string | null;
invoicePdf: string | null;
subtotal: number;
total: number;
paid: boolean;
}
export interface UnpaidInvoice {
id: string;
number: string | null;
amountDue: number;
currency: string;
dueDate: string | null;
hostedInvoiceUrl: string | null;
}
export interface BillingInvoicesData {
invoices: Invoice[];
hasUnpaidInvoices: boolean;
unpaidInvoices: UnpaidInvoice[];
}
/**
* Hook to fetch billing invoices from Stripe
*/
export function useBillingInvoices(projectId: string | undefined, hasSubscription: boolean) {
const {data, error, mutate, isLoading} = useSWR<BillingInvoicesData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-invoices` : null,
{
revalidateOnFocus: true,
refreshInterval: 300000, // Refresh every 5 minutes
},
);
return {
invoicesData: data,
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,35 @@
import useSWR from 'swr';
export interface CategoryLimit {
usage: number;
limit: number | null;
percentage: number;
isWarning: boolean;
isBlocked: boolean;
}
export interface BillingLimitsData {
workflows: CategoryLimit;
campaigns: CategoryLimit;
transactional: CategoryLimit;
}
/**
* Hook to fetch billing limits for a project
*/
export function useBillingLimits(projectId: string | undefined, hasSubscription: boolean) {
const {data, error, mutate, isLoading} = useSWR<BillingLimitsData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-limits` : null,
{
revalidateOnFocus: false,
refreshInterval: 30000, // Refresh every 30 seconds to keep usage updated
},
);
return {
limitsData: data,
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,36 @@
import {useBeforeUnload} from '@plunk/ui';
import {useRouter} from 'next/router';
import {useEffect} from 'react';
/**
* Custom hook to handle unsaved changes warning
* Warns user before navigating away (browser or Next.js navigation) when there are unsaved changes
*/
export function useChangeTracking(hasChanges: boolean, enabled: boolean = true) {
const router = useRouter();
// Warn before leaving page with unsaved changes (browser navigation)
useBeforeUnload(enabled && hasChanges);
// Warn before Next.js route changes
useEffect(() => {
if (!enabled || !hasChanges) return;
const handleRouteChange = (url: string) => {
// Only show confirmation if navigating to a different page
if (router.asPath !== url) {
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to leave?');
if (!confirmed) {
router.events.emit('routeChangeError');
throw 'Route change aborted by user';
}
}
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, [hasChanges, enabled, router]);
}
+32
View File
@@ -0,0 +1,32 @@
import useSWR from 'swr';
export interface ConfigResponse {
environment: string;
urls: {
api: string;
dashboard: string;
landing: string;
wiki: string | null;
};
features: {
billing: {enabled: boolean};
storage: {s3Enabled: boolean};
authProviders: {github: boolean; google: boolean};
email: {trackingToggleEnabled: boolean};
smtp: {
enabled: boolean;
domain: string | null;
ports: {secure: number; submission: number} | null;
};
};
}
/**
* Fetch global instance configuration and feature flags.
*
* - `data` is undefined while loading, then a ConfigResponse on success.
* - Errors do not retry by default.
*/
export function useConfig() {
return useSWR<ConfigResponse>('/config', {shouldRetryOnError: false});
}
+67
View File
@@ -0,0 +1,67 @@
import useSWR from 'swr';
export interface Contact {
id: string;
email: string;
data?: Record<string, unknown>;
}
export interface ContactsResponse {
contacts: Contact[];
total: number;
}
interface UseContactsOptions {
limit?: number;
search?: string;
}
/**
* Hook to fetch contacts with optional search
*/
export function useContacts(options: UseContactsOptions = {}) {
const {limit = 50, search} = options;
const params = new URLSearchParams();
params.set('limit', limit.toString());
if (search) {
params.set('search', search);
}
const {data, error, mutate, isLoading} = useSWR<ContactsResponse>(
`/contacts?${params.toString()}`,
{
revalidateOnFocus: false,
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
},
);
return {
contacts: data?.contacts || [],
total: data?.total || 0,
error,
isLoading,
mutate,
};
}
/**
* Hook to fetch available contact fields for variable usage
*/
export function useContactFields() {
const {data, error, mutate, isLoading} = useSWR<{fields: string[]}>(
'/contacts/fields',
{
revalidateOnFocus: false,
// Cache fields for longer since they don't change often
dedupingInterval: 60000, // 1 minute
},
);
return {
fields: data?.fields || [],
error,
isLoading,
mutate,
};
}
@@ -0,0 +1,62 @@
import useSWR from 'swr';
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
export interface ContactsResponse {
contacts: unknown[];
total: number;
cursor?: string;
hasMore: boolean;
}
export interface CampaignsResponse {
campaigns: unknown[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface DashboardStats {
totalContacts: number;
totalEmailsSent: number;
totalCampaigns: number;
openRate: number;
isLoading: boolean;
error: Error | undefined;
}
/**
* Hook to fetch dashboard statistics
* Fetches activity stats, contact count, and campaign count in parallel
*/
export function useDashboardStats(): DashboardStats {
// Fetch activity stats (last 30 days by default)
const {data: activityStats, error: activityError} = useSWR<ActivityStats>('/activity/stats');
// Fetch contacts (only need the total count)
const {data: contactsData, error: contactsError} = useSWR<ContactsResponse>('/contacts?limit=1');
// Fetch campaigns (only need the total count)
const {data: campaignsData, error: campaignsError} = useSWR<CampaignsResponse>('/campaigns?pageSize=1');
const isLoading = !activityStats && !contactsData && !campaignsData;
const error = activityError || contactsError || campaignsError;
return {
totalContacts: contactsData?.total ?? 0,
totalEmailsSent: activityStats?.totalEmailsSent ?? 0,
totalCampaigns: campaignsData?.total ?? 0,
openRate: activityStats?.openRate ?? 0,
isLoading,
error,
};
}
+71
View File
@@ -0,0 +1,71 @@
import useSWR from 'swr';
import {DomainSchemas} from '@plunk/shared';
import {network} from '../network';
export interface Domain {
id: string;
domain: string;
verified: boolean;
dkimTokens: string[] | null;
projectId: string;
createdAt: string;
updatedAt: string;
}
export interface DomainVerificationStatus {
domain: string;
tokens: string[];
status: string;
verified: boolean;
}
/**
* Hook to fetch domains for a project
*/
export function useDomains(projectId: string | undefined) {
const {data, error, mutate, isLoading} = useSWR<Domain[]>(projectId ? `/domains/project/${projectId}` : null);
return {
domains: data,
error,
isLoading,
mutate,
};
}
/**
* Hook to add a domain
*/
export function useAddDomain() {
const addDomain = async (projectId: string, domain: string) => {
return network.fetch<Domain, typeof DomainSchemas.create>('POST', '/domains', {
projectId,
domain,
});
};
return {addDomain};
}
/**
* Hook to check domain verification status
*/
export function useCheckDomainVerification() {
const checkVerification = async (domainId: string) => {
return network.fetch<DomainVerificationStatus>('GET', `/domains/${domainId}/verify`);
};
return {checkVerification};
}
/**
* Hook to remove a domain
*/
export function useRemoveDomain() {
const removeDomain = async (domainId: string) => {
return network.fetch<{success: boolean}>('DELETE', `/domains/${domainId}`);
};
return {removeDomain};
}
+9
View File
@@ -0,0 +1,9 @@
import type {Project} from '@plunk/db';
import useSWR from 'swr';
/**
* Fetch all projects for the current user
*/
export function useProjects() {
return useSWR<Project[]>('/users/@me/projects', {shouldRetryOnError: false});
}
@@ -0,0 +1,29 @@
import useSWR from 'swr';
export interface ProjectSetupState {
hasSubscription: boolean;
hasVerifiedDomain: boolean;
contactCount: number;
lastCampaignSentAt: string | null;
hasEnabledWorkflow: boolean;
}
export interface SetupStateResponse {
success: boolean;
data: ProjectSetupState;
}
/**
* Hook to fetch project setup state for dashboard quick start
*/
export function useProjectSetupState(projectId: string | undefined) {
const {data, error, isLoading} = useSWR<SetupStateResponse>(
projectId ? `/projects/${projectId}/setup-state` : null,
);
return {
setupState: data?.data,
isLoading,
error,
};
}
+9
View File
@@ -0,0 +1,9 @@
import useSWR from 'swr';
/**
* Fetch the current user. undefined means loading, null means logged out
*
*/
export function useUser() {
return useSWR('/users/@me', {shouldRetryOnError: false});
}
+92
View File
@@ -0,0 +1,92 @@
import type {infer as ZodInfer, ZodSchema} from 'zod';
import {API_URI} from './constants';
interface Json {
[x: string]: string | number | boolean | Date | Json | JsonArray;
}
type JsonArray = (string | number | boolean | Date | Json | JsonArray)[];
interface TypedSchema extends ZodSchema {
_type: unknown;
}
interface ApiResponse {
message?: string;
[key: string]: unknown;
}
export class network {
public static async fetch<T, Schema extends TypedSchema | void = void>(
method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH',
path: string,
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
): Promise<T> {
const url = path.startsWith('http') ? path : API_URI + path;
// Get active project ID from localStorage
const activeProjectId = typeof window !== 'undefined' ? localStorage.getItem('activeProjectId') : null;
const headers: Record<string, string> = {};
if (body) {
headers['Content-Type'] = 'application/json';
}
if (activeProjectId) {
headers['X-Project-Id'] = activeProjectId;
}
const response = await fetch(url, {
method,
body: body && JSON.stringify(body),
headers,
credentials: 'include',
});
// Handle 204 No Content responses (no body to parse)
if (response.status === 204) {
return {} as T;
}
const res = (await response.json()) as ApiResponse;
if (response.status >= 400) {
throw new Error(res.message ?? 'Something went wrong!');
}
return res as T;
}
/**
* Upload file using FormData (multipart/form-data)
* Used for file uploads where Content-Type must be set by browser
*/
public static async upload<T>(method: 'POST' | 'PUT' | 'PATCH', path: string, formData: FormData): Promise<T> {
const url = path.startsWith('http') ? path : API_URI + path;
// Get active project ID from localStorage
const activeProjectId = typeof window !== 'undefined' ? localStorage.getItem('activeProjectId') : null;
const headers: Record<string, string> = {};
// DO NOT set Content-Type - browser will set it automatically with boundary
if (activeProjectId) {
headers['X-Project-Id'] = activeProjectId;
}
const response = await fetch(url, {
method,
body: formData,
headers,
credentials: 'include',
});
const res = (await response.json()) as ApiResponse;
if (response.status >= 400) {
throw new Error(res.message ?? 'Something went wrong!');
}
return res as T;
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Shared validation utilities for email-related forms
*/
export interface EmailFormValidation {
name?: string;
subject?: string;
body?: string;
from?: string;
segmentId?: string;
}
export class EmailFormValidator {
/**
* Validate email form fields (campaigns, templates)
*/
static validate(fields: EmailFormValidation, options: {requireSegment?: boolean} = {}): string | null {
if (fields.name !== undefined && !fields.name.trim()) {
return 'Name is required';
}
if (fields.subject !== undefined && !fields.subject.trim()) {
return 'Email subject is required';
}
if (fields.body !== undefined && !fields.body.trim()) {
return 'Email body is required';
}
if (fields.from !== undefined && !fields.from.trim()) {
return 'From address is required';
}
if (options.requireSegment && fields.segmentId !== undefined && !fields.segmentId) {
return 'Please select a segment';
}
return null;
}
/**
* Validate campaign-specific fields
*/
static validateCampaign(fields: EmailFormValidation & {segmentId?: string}, audienceType: string): string | null {
const baseError = this.validate(fields);
if (baseError) return baseError;
if (audienceType === 'SEGMENT' && !fields.segmentId) {
return 'Please select a segment';
}
return null;
}
/**
* Validate template-specific fields
*/
static validateTemplate(fields: EmailFormValidation): string | null {
return this.validate(fields);
}
}