diff --git a/apps/web/package.json b/apps/web/package.json index 8d31720..7a996bc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "@uiw/react-codemirror": "^4.25.3", "@xyflow/react": "^12.9.3", "dagre": "^0.8.5", + "dayjs": "^1.11.19", "framer-motion": "^12.23.24", "juice": "^11.0.3", "lucide-react": "^0.553.0", diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx index eb1ce43..5d757e2 100644 --- a/apps/web/src/components/BillingLimits.tsx +++ b/apps/web/src/components/BillingLimits.tsx @@ -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

- You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for - unlimited emails or custom limits. + You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited + emails or custom limits.

@@ -254,9 +254,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill Maximum workflow emails per month. Leave empty for unlimited. - {estimatedCost && ( - ≈ {estimatedCost}/month - )} + {estimatedCost && ≈ {estimatedCost}/month} @@ -286,9 +284,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill Maximum campaign emails per month. Leave empty for unlimited. - {estimatedCost && ( - ≈ {estimatedCost}/month - )} + {estimatedCost && ≈ {estimatedCost}/month} @@ -318,9 +314,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill Maximum transactional emails per month. Leave empty for unlimited. - {estimatedCost && ( - ≈ {estimatedCost}/month - )} + {estimatedCost && ≈ {estimatedCost}/month} diff --git a/apps/web/src/components/TemplateSelectionDialog.tsx b/apps/web/src/components/TemplateSelectionDialog.tsx index cc2b08e..b9a5f96 100644 --- a/apps/web/src/components/TemplateSelectionDialog.tsx +++ b/apps/web/src/components/TemplateSelectionDialog.tsx @@ -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' ? (
- {/* Search & Filters */} -
-
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( + {/* Search & Filters */} +
+ +
+ + setSearchInput(e.target.value)} + className="pl-10" + /> +
+ + {search && ( + + )} + + + {/* Type Filter */} +
- )} - - - {/* Type Filter */} -
- - - + + +
-
- {/* Templates List */} -
- {isLoading && ( -
-
- + {isLoading && ( +
+
+ + + + +

Loading templates...

+
+
+ )} + + {!isLoading && data?.templates.length === 0 && ( +
+ +

No templates found

+

+ {search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'} +

+
+ )} + + {data?.templates.map(template => ( + handleTemplateClick(template)} + > + +
+
+
+ {template.name} + + {template.type.toLowerCase()} + +
+ {template.description && ( + {template.description} + )} +
+
+
+ +
+
+ Subject:{' '} + {template.subject} +
+
+ From:{' '} + {template.from} +
+
+
+
+ ))} +
+ + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+

+ Page {page} of {data.totalPages} +

+
+ +
)} - - {!isLoading && data?.templates.length === 0 && ( -
- -

No templates found

-

- {search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'} -

-
- )} - - {data?.templates.map(template => ( - handleTemplateClick(template)} - > - -
-
-
- {template.name} - - {template.type.toLowerCase()} - -
- {template.description && ( - {template.description} - )} -
-
-
- -
-
- Subject:{' '} - {template.subject} -
-
- From:{' '} - {template.from} -
-
-
-
- ))}
- - {/* Pagination */} - {data && data.totalPages > 1 && ( -
-

- Page {page} of {data.totalPages} -

-
- - -
-
- )} -
) : (
{/* Scrollable Content */} @@ -411,11 +410,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: -
diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 3397519..62023c4 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -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']; diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index b466f86..cb7f53c 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -397,7 +397,9 @@ export default function CampaignDetailsPage() {
Send Test Email - Preview in your inbox before sending + + Preview in your inbox before sending +
@@ -406,7 +408,9 @@ export default function CampaignDetailsPage() {
Send Now - Send immediately to all recipients + + Send immediately to all recipients +
@@ -576,7 +580,8 @@ export default function CampaignDetailsPage() {

- 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.

@@ -1004,9 +1009,7 @@ export default function CampaignDetailsPage() { {c.status === CampaignStatus.SCHEDULED && (
-

- Recipient count will be recalculated at send time -

+

Recipient count will be recalculated at send time

)} @@ -1026,12 +1029,6 @@ export default function CampaignDetailsPage() { )} - - {/* Created */} -
-

Created

-

{new Date(c.createdAt).toLocaleDateString()}

-
diff --git a/apps/web/src/pages/campaigns/create.tsx b/apps/web/src/pages/campaigns/create.tsx index 8b8ba4e..a6f48ef 100644 --- a/apps/web/src/pages/campaigns/create.tsx +++ b/apps/web/src/pages/campaigns/create.tsx @@ -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); diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx index 5183364..f2cb9f3 100644 --- a/apps/web/src/pages/campaigns/index.tsx +++ b/apps/web/src/pages/campaigns/index.tsx @@ -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() { )} + {/* Metadata */} +
+
+ +
+ Created {dayjs(campaign.createdAt).fromNow()} +
+ {dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+
+ • Updated {dayjs(campaign.updatedAt).fromNow()} +
+ {dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+ {/* Actions */}
diff --git a/apps/web/src/pages/contacts/[id].tsx b/apps/web/src/pages/contacts/[id].tsx index b3ae5b5..bc732da 100644 --- a/apps/web/src/pages/contacts/[id].tsx +++ b/apps/web/src/pages/contacts/[id].tsx @@ -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() {
-
- -
-

Created

-

- {new Date(contact.createdAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} -

-
-
- -
- -
-

Last Updated

-

- {new Date(contact.updatedAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} -

-
-
-
@@ -285,6 +258,26 @@ export default function ContactDetailPage() {

{contact.id}

+ +
+

Created

+
+

{dayjs(contact.createdAt).fromNow()}

+
+ {dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+ +
+

Last Updated

+
+

{dayjs(contact.updatedAt).fromNow()}

+
+ {dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+
diff --git a/apps/web/src/pages/contacts/index.tsx b/apps/web/src/pages/contacts/index.tsx index 2e62298..ad3e2a2 100644 --- a/apps/web/src/pages/contacts/index.tsx +++ b/apps/web/src/pages/contacts/index.tsx @@ -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() { - {new Date(contact.createdAt).toLocaleDateString()} +
+ {dayjs(contact.createdAt).fromNow()} +
+ {dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')} +
+
@@ -324,9 +330,12 @@ export default function ContactsPage() {
- - {new Date(contact.createdAt).toLocaleDateString()} - +
+ {dayjs(contact.createdAt).fromNow()} +
+ {dayjs(contact.createdAt).format('Do MMMM YYYY, h:mm A')} +
+
diff --git a/apps/web/src/pages/segments/index.tsx b/apps/web/src/pages/segments/index.tsx index 52c7bd2..b34cf90 100644 --- a/apps/web/src/pages/segments/index.tsx +++ b/apps/web/src/pages/segments/index.tsx @@ -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() {
{/* Metadata */} -
- Created {new Date(segment.createdAt).toLocaleDateString()} +
+
+ +
+ Created {dayjs(segment.createdAt).fromNow()} +
+ {dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+
+ • Updated {dayjs(segment.updatedAt).fromNow()} +
+ {dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx index 076b3f5..dc1d22b 100644 --- a/apps/web/src/pages/templates/index.tsx +++ b/apps/web/src/pages/templates/index.tsx @@ -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() {

From

{template.from}

-
-
Created {new Date(template.createdAt).toLocaleDateString()}
+
+
+ +
+ Created {dayjs(template.createdAt).fromNow()} +
+ {dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+
+ • Updated {dayjs(template.updatedAt).fromNow()} +
+ {dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
{template.replyTo && (
Reply to: {template.replyTo} diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 324e149..6b6d443 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -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 ?? '-'} - {new Date(execution.startedAt).toLocaleString()} +
+ {dayjs(execution.startedAt).fromNow()} +
+ {dayjs(execution.startedAt).format('DD MMMM YYYY, hh:mm')} +
+
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && ( diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 081711e..062b06f 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -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() { {workflow._count?.executions ?? 0}{' '} executions
-
Created {new Date(workflow.createdAt).toLocaleDateString()}
+
+
+
+ +
+ Created {dayjs(workflow.createdAt).fromNow()} +
+ {dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+
+ • Updated {dayjs(workflow.updatedAt).fromNow()} +
+ {dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
diff --git a/yarn.lock b/yarn.lock index 5130783..09a37ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19026,6 +19026,7 @@ __metadata: "@uiw/react-codemirror": "npm:^4.25.3" "@xyflow/react": "npm:^12.9.3" dagre: "npm:^0.8.5" + dayjs: "npm:^1.11.19" framer-motion: "npm:^12.23.24" juice: "npm:^11.0.3" lucide-react: "npm:^0.553.0"