Initial push of Plunk Next
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user