types: Abstract inline interfaces to @plunk/types

This commit is contained in:
Dries Augustyns
2026-01-01 10:52:43 +01:00
parent 38da58e5e9
commit 85c992a9f9
95 changed files with 1370 additions and 1350 deletions
+6 -34
View File
@@ -1,39 +1,11 @@
import {Button} from '@plunk/ui';
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
import {network} from '../lib/network';
import {ActivityItem} from './ActivityItem';
import {Loader2} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
export enum ActivityType {
EVENT_TRIGGERED = 'event.triggered',
EMAIL_SENT = 'email.sent',
EMAIL_DELIVERED = 'email.delivered',
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
WORKFLOW_COMPLETED = 'workflow.completed',
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
}
export interface Activity {
id: string;
type: ActivityType;
timestamp: string;
contactEmail?: string;
contactId?: string;
metadata: Record<string, unknown>;
}
interface PaginatedActivities {
activities: Activity[];
nextCursor?: string;
hasMore: boolean;
}
interface ActivityFeedProps {
export interface ActivityFeedProps {
typeFilter?: string;
dateRangeDays?: number;
contactId?: string;
@@ -84,17 +56,17 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
params.set('contactId', contactId);
}
const result = await network.fetch<PaginatedActivities>('GET', `/activity?${params.toString()}`);
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
if (cursor) {
// Append to existing activities
setActivities(prev => [...prev, ...result.activities]);
setActivities(prev => [...prev, ...result.data]);
} else {
// Replace activities
setActivities(result.activities);
setActivities(result.data);
}
setNextCursor(result.nextCursor);
setNextCursor(result.cursor);
setHasMore(result.hasMore);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load activities');
+1 -1
View File
@@ -1,5 +1,5 @@
import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import type {Activity} from './ActivityFeed';
import type {Activity} from '@plunk/types';
import {memo, useState} from 'react';
import {EmailPreviewModal} from './EmailPreviewModal';
import {
@@ -1,5 +1,5 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from '@plunk/ui';
import {AlertCircle, TrendingUp, Coins} from 'lucide-react';
import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {AlertCircle, Coins, TrendingUp} from 'lucide-react';
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
import {useConfig} from '../lib/hooks/useConfig';
import {useCallback} from 'react';
+1 -1
View File
@@ -1,4 +1,4 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert, Badge, Button} from '@plunk/ui';
import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
@@ -63,9 +63,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
});
const {data, isLoading} = useSWR<PaginatedCampaigns>(
open
? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`
: null,
open ? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}` : null,
{revalidateOnFocus: false},
);
@@ -408,11 +406,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
<Button variant="outline" onClick={handleBack} className="flex-1">
Back
</Button>
<Button
onClick={handleConfirm}
className="flex-1"
disabled={!Object.values(selectedFields).some(v => v)}
>
<Button onClick={handleConfirm} className="flex-1" disabled={!Object.values(selectedFields).some(v => v)}>
Create Campaign
</Button>
</div>
@@ -27,7 +27,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
SelectValue,
} from '@plunk/ui';
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
import {network} from '../../lib/network';
@@ -298,7 +298,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`,
manageUrl: `${window.location.origin}/manage/${contact.id}`,
data: contact.data || {},
...(contact.data as Record<string, unknown> | null || {}),
...((contact.data as Record<string, unknown> | null) || {}),
};
return replaceVariables(currentHtml, contactData);
@@ -317,7 +317,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`,
manageUrl: `${window.location.origin}/manage/${contact.id}`,
data: contact.data || {},
...(contact.data as Record<string, unknown> | null || {}),
...((contact.data as Record<string, unknown> | null) || {}),
};
return replaceVariables(subject, contactData);
@@ -64,7 +64,7 @@ export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
theme,
EditorView.lineWrapping, // Enable line wrapping for long lines
],
[theme]
[theme],
);
return (
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {Node, mergeAttributes} from '@tiptap/core';
import {ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps} from '@tiptap/react';
import {mergeAttributes, Node} from '@tiptap/core';
import {NodeViewWrapper, type ReactNodeViewProps, ReactNodeViewRenderer} from '@tiptap/react';
import {useEffect, useRef, useState} from 'react';
interface ImageAttrs {
@@ -78,11 +78,14 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
setShowLinkInput(false);
}, [editor]);
const setColor = useCallback((color: string) => {
if (!editor) return;
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
}, [editor]);
const setColor = useCallback(
(color: string) => {
if (!editor) return;
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
},
[editor],
);
const applyCustomColor = useCallback(() => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
@@ -31,7 +31,7 @@ const detectCustomHtmlPatterns = (html: string): boolean => {
const classValue = match[1];
if (!classValue) continue;
// Split by whitespace to get individual classes
const classes = classValue.split(/\s+/).filter((c) => c.length > 0);
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
// Check if any class is NOT in the allowed list
const allowedPrefixes = [
'prose',
@@ -42,7 +42,7 @@ const detectCustomHtmlPatterns = (html: string): boolean => {
'selected',
'resize-handle',
];
const hasDisallowedClass = classes.some((cls) => !allowedPrefixes.some((prefix) => cls.startsWith(prefix)));
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
if (hasDisallowedClass) {
hasCustomClasses = true;
break;
+1 -4
View File
@@ -234,10 +234,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
<p className="text-xs text-neutral-600 leading-relaxed">{step.description}</p>
</div>
<Link href={step.link} className="flex-shrink-0">
<Button
size="sm"
variant={step.isCompleted ? 'outline' : 'default'}
>
<Button size="sm" variant={step.isCompleted ? 'outline' : 'default'}>
{step.linkText}
</Button>
</Link>
@@ -30,9 +30,7 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-2 flex-1">
<p className="text-sm font-medium">
Your project has exceeded the following security thresholds:
</p>
<p className="text-sm font-medium">Your project has exceeded the following security thresholds:</p>
<ul className={`list-disc list-inside space-y-1 text-sm ${messageColor}`}>
{issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
@@ -13,7 +13,7 @@ import {
DialogHeader,
DialogTitle,
Input,
Label
Label,
} from '@plunk/ui';
import type {Template} from '@plunk/db';
import {ArrowLeft, FileText, Search} from 'lucide-react';
+2 -2
View File
@@ -12,7 +12,7 @@ import {
ReactFlow,
useEdgesState,
useNodesState,
useReactFlow
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
@@ -29,7 +29,7 @@ import {
Timer,
Trash2,
UserCog,
Webhook
Webhook,
} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import dagre from 'dagre';
@@ -10,7 +10,7 @@ import {
Position,
ReactFlow,
useEdgesState,
useNodesState
useNodesState,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
-1
View File
@@ -47,7 +47,6 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
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 */
+4 -7
View File
@@ -19,13 +19,10 @@ export function useContacts(options: UseContactsOptions = {}) {
params.set('search', search);
}
const {data, error, mutate, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
`/contacts?${params.toString()}`,
{
revalidateOnFocus: false,
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
},
);
const {data, error, mutate, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(`/contacts?${params.toString()}`, {
revalidateOnFocus: false,
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
});
return {
contacts: data?.data || [],
+15 -3
View File
@@ -20,13 +20,25 @@ export interface DashboardStats {
*/
export function useDashboardStats(): DashboardStats {
// Fetch activity stats (last 30 days by default)
const {data: activityStats, error: activityError, isLoading: isLoadingActivity} = useSWR<ActivityStats>('/activity/stats');
const {
data: activityStats,
error: activityError,
isLoading: isLoadingActivity,
} = useSWR<ActivityStats>('/activity/stats');
// Fetch contacts (only need the total count)
const {data: contactsData, error: contactsError, isLoading: isLoadingContacts} = useSWR<ContactsResponse>('/contacts?limit=1');
const {
data: contactsData,
error: contactsError,
isLoading: isLoadingContacts,
} = useSWR<ContactsResponse>('/contacts?limit=1');
// Fetch campaigns (only need the total count)
const {data: campaignsData, error: campaignsError, isLoading: isLoadingCampaigns} = useSWR<CampaignsResponse>('/campaigns?page=1&limit=1');
const {
data: campaignsData,
error: campaignsError,
isLoading: isLoadingCampaigns,
} = useSWR<CampaignsResponse>('/campaigns?page=1&limit=1');
// Still loading if ANY of the requests are still in progress
const isLoading = isLoadingActivity || isLoadingContacts || isLoadingCampaigns;
@@ -17,9 +17,7 @@ export interface SetupStateResponse {
* 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,
);
const {data, error, isLoading} = useSWR<SetupStateResponse>(projectId ? `/projects/${projectId}/setup-state` : null);
return {
setupState: data?.data,
+9 -1
View File
@@ -19,7 +19,15 @@ dayjs.extend(relativeTime);
dayjs.extend(advancedFormat);
// Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset-password', '/auth/verify-email', '/unsubscribe', '/subscribe', '/manage'];
const PUBLIC_ROUTES = [
'/auth/login',
'/auth/signup',
'/auth/reset-password',
'/auth/verify-email',
'/unsubscribe',
'/subscribe',
'/manage',
];
// Routes that don't require a project
const NO_PROJECT_ROUTES = ['/projects/create'];
+85 -84
View File
@@ -76,93 +76,94 @@ export default function ActivityPage() {
<NextSeo title="Activity" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Activity</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Real-time overview of events, emails, and workflow executions across your project.
</p>
</div>
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Activity</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Real-time overview of events, emails, and workflow executions across your project.
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${stat.color}`} />
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${stat.color}`} />
</div>
</div>
</div>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-neutral-500">{stat.description}</p>
</CardContent>
</Card>
);
})}
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-neutral-500">{stat.description}</p>
</CardContent>
</Card>
);
})}
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="All Activity Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Activity Types</SelectItem>
<SelectItem value="event.triggered">Events</SelectItem>
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
Emails
</SelectItem>
<SelectItem value="email.sent">Emails Sent</SelectItem>
<SelectItem value="email.opened">Emails Opened</SelectItem>
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger>
<SelectValue placeholder="Last 30 days" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 24 hours</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Live feed of all activities happening across your project. Updates automatically as new activities
occur.
</CardDescription>
</CardHeader>
<CardContent>
<ActivityFeed
typeFilter={typeFilter === 'ALL' ? undefined : typeFilter}
dateRangeDays={parseInt(dateRange)}
/>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="All Activity Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Activity Types</SelectItem>
<SelectItem value="event.triggered">Events</SelectItem>
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
Emails
</SelectItem>
<SelectItem value="email.sent">Emails Sent</SelectItem>
<SelectItem value="email.opened">Emails Opened</SelectItem>
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger>
<SelectValue placeholder="Last 30 days" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 24 hours</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Live feed of all activities happening across your project. Updates automatically as new activities occur.
</CardDescription>
</CardHeader>
<CardContent>
<ActivityFeed
typeFilter={typeFilter === 'ALL' ? undefined : typeFilter}
dateRangeDays={parseInt(dateRange)}
/>
</CardContent>
</Card>
</div>
</DashboardLayout>
</DashboardLayout>
</>
);
}
+2 -2
View File
@@ -15,7 +15,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
SelectValue,
} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {useAnalytics} from '../../lib/hooks/useAnalytics';
@@ -30,7 +30,7 @@ import {
Megaphone,
MousePointerClick,
Send,
Zap
Zap,
} from 'lucide-react';
import {NextSeo} from 'next-seo';
import {useMemo, useState} from 'react';
+209 -209
View File
@@ -111,224 +111,224 @@ export default function Login() {
<NextSeo title="Login" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
<p className="text-neutral-600">Enter your credentials to access your account</p>
</div>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
<p className="text-neutral-600">Enter your credentials to access your account</p>
</div>
{(oauthConfig.github || oauthConfig.google) && (
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
{(oauthConfig.github || oauthConfig.google) && (
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
)}
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div>
</div>
</>
)}
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="text-xs underline mt-1 text-left text-neutral-500"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
</>
)}
</AnimatePresence>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Login'
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="text-xs underline mt-1 text-left text-neutral-500"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</Button>
</motion.div>
</AnimatePresence>
<div className="text-center text-sm text-neutral-500">
Don&apos;t have an account?{' '}
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
Sign up
</Link>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Login'
)}
</Button>
</motion.div>
<div className="text-center text-sm text-neutral-500">
Don&apos;t have an account?{' '}
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
Sign up
</Link>
</div>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
<Dialog
open={showReset}
onOpenChange={open => {
setShowReset(open);
if (!open) {
setResetStatus('idle');
setResetEmail('');
setResetError(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset your password</DialogTitle>
<DialogDescription>Enter your email to receive a password reset link.</DialogDescription>
</DialogHeader>
<form
onSubmit={e => {
void handleResetPassword(e);
}}
className="flex flex-col gap-3 mt-2"
>
<Input
type="email"
placeholder="Enter your email"
value={resetEmail}
onChange={e => setResetEmail(e.target.value)}
required
/>
<DialogFooter>
<div className={'w-full space-y-2'}>
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
</Button>
{resetStatus === 'success' && (
<p className="text-green-600 text-sm">
If an account exists, a reset link has been sent to your email.
</p>
)}
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Dialog
open={showReset}
onOpenChange={open => {
setShowReset(open);
if (!open) {
setResetStatus('idle');
setResetEmail('');
setResetError(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset your password</DialogTitle>
<DialogDescription>Enter your email to receive a password reset link.</DialogDescription>
</DialogHeader>
<form
onSubmit={e => {
void handleResetPassword(e);
}}
className="flex flex-col gap-3 mt-2"
>
<Input
type="email"
placeholder="Enter your email"
value={resetEmail}
onChange={e => setResetEmail(e.target.value)}
required
/>
<DialogFooter>
<div className={'w-full space-y-2'}>
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
</Button>
{resetStatus === 'success' && (
<p className="text-green-600 text-sm">
If an account exists, a reset link has been sent to your email.
</p>
)}
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</>
);
}
+13 -7
View File
@@ -1,6 +1,17 @@
import {zodResolver} from '@hookform/resolvers/zod';
import {AuthenticationSchemas} from '@plunk/shared';
import {Button, Card, CardContent, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input} from '@plunk/ui';
import {
Button,
Card,
CardContent,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
@@ -115,12 +126,7 @@ export default function ResetPassword() {
</div>
</motion.div>
) : (
<motion.div
key="form"
initial={{opacity: 1}}
exit={{opacity: 0}}
className="p-8"
>
<motion.div key="form" initial={{opacity: 1}} exit={{opacity: 0}} className="p-8">
<Form {...form}>
<form
onSubmit={e => {
+155 -155
View File
@@ -78,170 +78,170 @@ export default function Signup() {
<NextSeo title="Sign Up" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
<p className="text-neutral-600">Get started with Plunk today</p>
</div>
<Card>
<CardContent className="p-0">
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
<p className="text-neutral-600">Get started with Plunk today</p>
</div>
{(oauthConfig.github || oauthConfig.google) && (
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
{(oauthConfig.github || oauthConfig.google) && (
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
)}
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div>
</div>
</>
)}
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
</>
)}
</AnimatePresence>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Sign up'
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</Button>
</motion.div>
</AnimatePresence>
<div className="text-center text-sm text-neutral-500">
Already have an account?{' '}
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Login
</Link>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Sign up'
)}
</Button>
</motion.div>
<div className="text-center text-sm text-neutral-500">
Already have an account?{' '}
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Login
</Link>
</div>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
</div>
</>
);
}
+3 -5
View File
@@ -26,7 +26,7 @@ import {
SelectItemWithDescription,
SelectTrigger,
SelectValue,
StickySaveBar
StickySaveBar,
} from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
@@ -50,7 +50,7 @@ import {
Trash2,
TrendingUp,
Users,
XCircle
XCircle,
} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
@@ -1023,9 +1023,7 @@ export default function CampaignDetailsPage() {
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p>
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-neutral-400" />
<p className="text-sm font-medium text-neutral-900">
{formatFullDateTime(new Date(c.sentAt))}
</p>
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(c.sentAt))}</p>
</div>
</div>
)}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
SelectItemWithDescription,
SelectTrigger,
SelectValue,
Textarea
Textarea,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import {CampaignAudienceType} from '@plunk/db';
+1 -1
View File
@@ -7,7 +7,7 @@ import {
CardTitle,
ConfirmDialog,
Input,
Label
Label,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
+4 -10
View File
@@ -17,6 +17,7 @@ import {
Switch,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import type {CursorPaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
@@ -44,13 +45,6 @@ import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
total: number;
cursor?: string;
hasMore: boolean;
}
export default function ContactsPage() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]);
@@ -68,7 +62,7 @@ export default function ContactsPage() {
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
const pageSize = 50;
const {data, mutate, isLoading} = useSWR<PaginatedContacts>(
const {data, mutate, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
`/contacts?limit=${pageSize}${cursor ? `&cursor=${cursor}` : ''}${search ? `&search=${search}` : ''}`,
{revalidateOnFocus: false},
);
@@ -76,9 +70,9 @@ export default function ContactsPage() {
// Update contacts when data changes
useEffect(() => {
if (data) {
setContacts(data.contacts);
setContacts(data.data);
if (!cursor) {
setTotalCount(data.total || data.contacts.length);
setTotalCount(data.total || data.data.length);
}
}
}, [data, cursor]);
+4 -11
View File
@@ -10,6 +10,7 @@ import {
Label,
} from '@plunk/ui';
import type {Contact, Segment} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
@@ -23,14 +24,6 @@ import {SegmentSchemas} from '@plunk/shared';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// Count total filters in a condition (recursive)
function countFilters(condition: FilterCondition): number {
let count = 0;
@@ -49,7 +42,7 @@ export default function SegmentDetailPage() {
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
const [contactsPage, setContactsPage] = useState(1);
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedContacts>(
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
);
@@ -289,7 +282,7 @@ export default function SegmentDetailPage() {
<div className="text-center py-8">
<p className="text-sm text-neutral-500">Loading contacts...</p>
</div>
) : contactsData?.contacts.length === 0 ? (
) : contactsData?.data.length === 0 ? (
<div className="text-center py-8">
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500">No contacts match this segment</p>
@@ -297,7 +290,7 @@ export default function SegmentDetailPage() {
) : (
<>
<div className="space-y-2">
{contactsData?.contacts.map(contact => (
{contactsData?.data.map(contact => (
<div key={contact.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-2">
{contact.subscribed ? (
+6 -12
View File
@@ -38,16 +38,7 @@ import {
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import {
AlertTriangle,
CreditCard,
Database,
Globe,
Mail,
Settings as SettingsIcon,
Shield,
Users,
} from 'lucide-react';
import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Shield, Users} from 'lucide-react';
import type {z} from 'zod';
import {useRouter} from 'next/router';
import {DashboardLayout} from '../../components/DashboardLayout';
@@ -480,7 +471,7 @@ export default function Settings() {
</SelectTrigger>
</FormControl>
<SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => (
{SUPPORTED_LANGUAGES.map(lang => (
<SelectItem key={lang.code} value={lang.code}>
<div className="flex items-center gap-2">
<span>{lang.flag}</span>
@@ -712,7 +703,10 @@ export default function Settings() {
<div className="flex flex-col gap-2">
<div className="flex justify-start">
<Button onClick={() => handleStartSubscription(selectedCurrency)} disabled={isLoadingBilling}>
<Button
onClick={() => handleStartSubscription(selectedCurrency)}
disabled={isLoadingBilling}
>
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
</Button>
</div>
+3 -1
View File
@@ -178,7 +178,9 @@ export default function Unsubscribe() {
<path d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<h1 className="text-2xl font-bold text-neutral-900">{translator.t('pages.unsubscribe.successTitle')}</h1>
<h1 className="text-2xl font-bold text-neutral-900">
{translator.t('pages.unsubscribe.successTitle')}
</h1>
<p className="text-neutral-500">
{translator.t('pages.unsubscribe.successDescription', {email: contact?.email || ''})}
</p>
+2 -2
View File
@@ -26,7 +26,7 @@ import {
SelectItemWithDescription,
SelectTrigger,
SelectValue,
Switch
Switch,
} from '@plunk/ui';
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
@@ -47,7 +47,7 @@ import {
Trash2,
UserCog,
Users,
Webhook
Webhook,
} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
+1 -5
View File
@@ -221,11 +221,7 @@ export default function WorkflowsPage() {
size="sm"
onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)}
>
{workflow.enabled ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
{workflow.enabled ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
</Button>
<Link href={`/workflows/${workflow.id}`}>
<Button variant="ghost" size="sm">