types: Abstract inline interfaces to @plunk/types
This commit is contained in:
@@ -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,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,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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SelectItemWithDescription,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea
|
||||
Textarea,
|
||||
} from '@plunk/ui';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {CampaignAudienceType} from '@plunk/db';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Label
|
||||
Label,
|
||||
} from '@plunk/ui';
|
||||
import type {Contact} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user