feat: Improved createdAt and updatedAt visualisation

This commit is contained in:
Dries Augustyns
2025-12-11 10:57:56 +01:00
parent 6b25bbe2f8
commit 1019ba0d82
15 changed files with 320 additions and 245 deletions
+8 -14
View File
@@ -1,4 +1,4 @@
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
import {memo, useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {BillingLimitSchemas} from '@plunk/shared';
@@ -23,7 +23,7 @@ import {
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
import {AnimatePresence, motion} from 'framer-motion';
import type {z} from 'zod';
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
import {type BillingLimitsData, type CategoryLimit, useBillingLimits} from '../lib/hooks/useBillingLimits';
import {network} from '../lib/network';
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
@@ -50,7 +50,7 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(costInCents / 100);
} catch (error) {
} catch {
// Fallback if currency is invalid
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
}
@@ -167,8 +167,8 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
You&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for
unlimited emails or custom limits.
You&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited
emails or custom limits.
</p>
</div>
</Alert>
@@ -254,9 +254,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
</FormControl>
<FormDescription>
Maximum workflow emails per month. Leave empty for unlimited.
{estimatedCost && (
<span className="text-neutral-500"> {estimatedCost}/month</span>
)}
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
@@ -286,9 +284,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
</FormControl>
<FormDescription>
Maximum campaign emails per month. Leave empty for unlimited.
{estimatedCost && (
<span className="text-neutral-500"> {estimatedCost}/month</span>
)}
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
@@ -318,9 +314,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
</FormControl>
<FormDescription>
Maximum transactional emails per month. Leave empty for unlimited.
{estimatedCost && (
<span className="text-neutral-500"> {estimatedCost}/month</span>
)}
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
@@ -13,10 +13,9 @@ import {
DialogHeader,
DialogTitle,
Input,
Label,
Label
} from '@plunk/ui';
import type {Template} from '@plunk/db';
import {network} from '../lib/network';
import {ArrowLeft, FileText, Search} from 'lucide-react';
import {useState} from 'react';
import useSWR from 'swr';
@@ -127,168 +126,168 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
{step === 'select' ? (
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* Search & Filters */}
<div className="space-y-3">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit" size="sm">
Search
</Button>
{search && (
{/* Search & Filters */}
<div className="space-y-3">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit" size="sm">
Search
</Button>
{search && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSearch('');
setSearchInput('');
setPage(1);
}}
>
Clear
</Button>
)}
</form>
{/* Type Filter */}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSearch('');
setSearchInput('');
setTypeFilter('ALL');
setPage(1);
}}
variant={typeFilter === 'ALL' ? 'default' : 'secondary'}
size="sm"
>
Clear
All Templates
</Button>
)}
</form>
{/* Type Filter */}
<div className="flex gap-2">
<Button
type="button"
onClick={() => {
setTypeFilter('ALL');
setPage(1);
}}
variant={typeFilter === 'ALL' ? 'default' : 'secondary'}
size="sm"
>
All Templates
</Button>
<Button
type="button"
onClick={() => {
setTypeFilter('MARKETING');
setPage(1);
}}
variant={typeFilter === 'MARKETING' ? 'default' : 'secondary'}
size="sm"
>
Marketing
</Button>
<Button
type="button"
onClick={() => {
setTypeFilter('TRANSACTIONAL');
setPage(1);
}}
variant={typeFilter === 'TRANSACTIONAL' ? 'default' : 'secondary'}
size="sm"
>
Transactional
</Button>
<Button
type="button"
onClick={() => {
setTypeFilter('MARKETING');
setPage(1);
}}
variant={typeFilter === 'MARKETING' ? 'default' : 'secondary'}
size="sm"
>
Marketing
</Button>
<Button
type="button"
onClick={() => {
setTypeFilter('TRANSACTIONAL');
setPage(1);
}}
variant={typeFilter === 'TRANSACTIONAL' ? 'default' : 'secondary'}
size="sm"
>
Transactional
</Button>
</div>
</div>
</div>
{/* Templates List */}
<div className="flex-1 overflow-y-auto space-y-3">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{/* Templates List */}
<div className="flex-1 overflow-y-auto space-y-3">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
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>
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
</div>
</div>
)}
{!isLoading && data?.templates.length === 0 && (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
<p className="text-neutral-500">
{search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'}
</p>
</div>
)}
{data?.templates.map(template => (
<Card
key={template.id}
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
onClick={() => handleTemplateClick(template)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base truncate">{template.name}</CardTitle>
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : 'success'}>
{template.type.toLowerCase()}
</Badge>
</div>
{template.description && (
<CardDescription className="text-xs line-clamp-1">{template.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-xs">
<div>
<span className="text-neutral-500">Subject:</span>{' '}
<span className="font-medium text-neutral-900">{template.subject}</span>
</div>
<div>
<span className="text-neutral-500">From:</span>{' '}
<span className="text-neutral-700">{template.from}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between pt-3 border-t">
<p className="text-sm text-neutral-500">
Page {page} of {data.totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page === data.totalPages}
>
<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>
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
Next
</Button>
</div>
</div>
)}
{!isLoading && data?.templates.length === 0 && (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
<p className="text-neutral-500">
{search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'}
</p>
</div>
)}
{data?.templates.map(template => (
<Card
key={template.id}
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
onClick={() => handleTemplateClick(template)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base truncate">{template.name}</CardTitle>
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : 'success'}>
{template.type.toLowerCase()}
</Badge>
</div>
{template.description && (
<CardDescription className="text-xs line-clamp-1">{template.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-xs">
<div>
<span className="text-neutral-500">Subject:</span>{' '}
<span className="font-medium text-neutral-900">{template.subject}</span>
</div>
<div>
<span className="text-neutral-500">From:</span>{' '}
<span className="text-neutral-700">{template.from}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between pt-3 border-t">
<p className="text-sm text-neutral-500">
Page {page} of {data.totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page === data.totalPages}
>
Next
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Scrollable Content */}
@@ -411,11 +410,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<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>
+7
View File
@@ -10,6 +10,13 @@ import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
import {useProjects} from '../lib/hooks/useProject';
import {useUser} from '../lib/hooks/useUser';
import {network} from '../lib/network';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import advancedFormat from 'dayjs/plugin/advancedFormat';
// Configure dayjs plugins globally
dayjs.extend(relativeTime);
dayjs.extend(advancedFormat);
// Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset'];
+9 -12
View File
@@ -397,7 +397,9 @@ export default function CampaignDetailsPage() {
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Send Test Email</span>
<span className="text-xs text-neutral-500 leading-snug">Preview in your inbox before sending</span>
<span className="text-xs text-neutral-500 leading-snug">
Preview in your inbox before sending
</span>
</div>
</div>
</DropdownMenuItem>
@@ -406,7 +408,9 @@ export default function CampaignDetailsPage() {
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Send Now</span>
<span className="text-xs text-neutral-500 leading-snug">Send immediately to all recipients</span>
<span className="text-xs text-neutral-500 leading-snug">
Send immediately to all recipients
</span>
</div>
</div>
</DropdownMenuItem>
@@ -576,7 +580,8 @@ export default function CampaignDetailsPage() {
<div className="flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may differ if contacts subscribe, unsubscribe, or segment membership changes.
This count will be recalculated right before sending to ensure accuracy. The final number may
differ if contacts subscribe, unsubscribe, or segment membership changes.
</p>
</div>
</div>
@@ -1004,9 +1009,7 @@ export default function CampaignDetailsPage() {
{c.status === CampaignStatus.SCHEDULED && (
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded">
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">
Recipient count will be recalculated at send time
</p>
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
</div>
)}
</div>
@@ -1026,12 +1029,6 @@ export default function CampaignDetailsPage() {
</div>
</div>
)}
{/* Created */}
<div>
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
</CardContent>
</Card>
</div>
+2 -2
View File
@@ -78,7 +78,7 @@ export default function CreateCampaignPage() {
setBody(template.data.body);
toast.success('Template loaded successfully');
} catch (error) {
} catch {
toast.error('Failed to load template');
} finally {
setLoadingTemplate(false);
@@ -104,7 +104,7 @@ export default function CreateCampaignPage() {
setBody(campaign.data.body);
toast.success('Campaign loaded successfully');
} catch (error) {
} catch {
toast.error('Failed to load campaign');
} finally {
setLoadingTemplate(false);
+20
View File
@@ -30,6 +30,7 @@ import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
interface PaginatedCampaigns {
campaigns: Campaign[];
@@ -465,6 +466,25 @@ export default function CampaignsPage() {
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {dayjs(campaign.createdAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {dayjs(campaign.updatedAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t border-neutral-100">
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
+23 -30
View File
@@ -7,20 +7,21 @@ import {
CardTitle,
ConfirmDialog,
Input,
Label,
Label
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {ActivityFeed} from '../../components/ActivityFeed';
import {network} from '../../lib/network';
import {ArrowLeft, Calendar, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
import {ArrowLeft, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
export default function ContactDetailPage() {
const router = useRouter();
@@ -250,34 +251,6 @@ export default function ContactDetailPage() {
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">
{new Date(contact.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
@@ -285,6 +258,26 @@ export default function ContactDetailPage() {
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
+15 -6
View File
@@ -13,7 +13,7 @@ import {
DialogTitle,
Input,
Label,
Switch,
Switch
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
@@ -32,7 +32,7 @@ import {
Search,
Trash2,
Upload,
XCircle,
XCircle
} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
@@ -40,6 +40,7 @@ import {useEffect, useRef, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
@@ -279,7 +280,12 @@ export default function ContactsPage() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
<div className="group relative inline-block cursor-help">
{dayjs(contact.createdAt).fromNow()}
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
@@ -324,9 +330,12 @@ export default function ContactsPage() {
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
</span>
<div className="group relative inline-block cursor-help">
<span className="text-xs text-neutral-500">{dayjs(contact.createdAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('Do MMMM YYYY, h:mm A')}
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
+22 -17
View File
@@ -12,7 +12,7 @@ import {
import type {Contact, Segment} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {ArrowLeft, Calendar, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
@@ -21,6 +21,7 @@ import useSWR from 'swr';
import type {FilterCondition} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
@@ -388,22 +389,6 @@ export default function SegmentDetailPage() {
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
@@ -411,6 +396,26 @@ export default function SegmentDetailPage() {
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(segment.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(segment.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
+18 -3
View File
@@ -13,12 +13,13 @@ import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
// Helper function to count total filters in a condition
function countFiltersInCondition(condition: unknown): number {
@@ -194,8 +195,22 @@ export default function SegmentsPage() {
</div>
{/* Metadata */}
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
Created {new Date(segment.createdAt).toLocaleDateString()}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {dayjs(segment.createdAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {dayjs(segment.updatedAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</div>
</CardContent>
+18 -3
View File
@@ -12,12 +12,13 @@ import {
import type {Template} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
interface PaginatedTemplates {
templates: Template[];
@@ -252,8 +253,22 @@ export default function TemplatesPage() {
<p className="text-xs text-neutral-500 mb-1">From</p>
<p className="text-sm text-neutral-700">{template.from}</p>
</div>
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
<div>Created {new Date(template.createdAt).toLocaleDateString()}</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {dayjs(template.createdAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {dayjs(template.updatedAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
{template.replyTo && (
<div>
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
+7 -1
View File
@@ -57,6 +57,7 @@ import useSWR from 'swr';
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
import {ReactFlowProvider} from '@xyflow/react';
import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface WorkflowWithDetails extends Workflow {
steps: (WorkflowStep & {
@@ -611,7 +612,12 @@ export default function WorkflowEditorPage() {
{execution.currentStep?.name ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(execution.startedAt).toLocaleString()}
<div className="group relative inline-block cursor-help">
{dayjs(execution.startedAt).fromNow()}
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
{dayjs(execution.startedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
+19 -2
View File
@@ -23,13 +23,14 @@ import {
import type {Workflow} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface PaginatedWorkflows {
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
@@ -252,7 +253,23 @@ export default function WorkflowsPage() {
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
executions
</div>
<div>Created {new Date(workflow.createdAt).toLocaleDateString()}</div>
</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {dayjs(workflow.createdAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {dayjs(workflow.updatedAt).fromNow()}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>