From a62001e23c78bf512526d35b495e71f51686ffd7 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 10 Aug 2026 20:48:47 +0200 Subject: [PATCH] refactor(web): rewrite UX copy for clarity and consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy across the dashboard was written screen by screen, so the same concept was phrased several different ways and a lot of text explained things the UI already said. This is a pass over every user-facing string in apps/web against one standard, applied consistently. Systemic patterns removed: - 25 confirmation dialogs opened with "Are you sure you want to...", then repeated the question the title already asked. Titles now name the object ("Delete john@acme.com?"), bodies state only the consequence, and buttons are verb + object. Cancel dialogs previously rendered [Cancel] [Cancel Campaign]; the dismiss button now reads "Keep sending". - 42 success toasts ended in "successfully". The toast is already green. - 87 "Failed to X" strings gave no cause and no next step. Most sites already surfaced error.message and only used these as a fallback, so the fallbacks now name what did not happen and what to do. - ~20 CardDescriptions restated their own CardTitle. Deleted. The ones carrying an actual rule were kept and sharpened instead. - BillingConsumption, BillingInvoices and QuickStart each wrote their header 3-4 times across loading/error/empty branches. Hoisted to one header per component. - 243 Title Case UI strings across 190 distinct labels moved to sentence case. The app previously shipped both "Send To" and "Send to". - Required-field markers used three different mechanisms. Now one. - Terminology: "API Credentials" and "API Keys" were the same card under two names; cancelled/canceled and .../… were both in use; the templates page title disagreed with its nav item. Structural changes: - Removed the analytics "Performance Insights" card. It dispensed advice the product cannot substantiate ("above industry average" against no cited benchmark, "add more compelling calls-to-action") in a product positioned against exactly that tone. Replaced with two counters drawn from real data: events triggered and workflow runs started. - Subscription activation copy was ~75 words over two paragraphs. It is load-bearing (it prevents billing surprises), so it was restructured rather than cut: one summary line plus a "Why two charges?" disclosure. - Consolidated the DKIM instructions that appeared both above and below the DNS record table. - Trimmed the workflow active-executions notice and the HTML-to-visual editor warning to their load-bearing lists. Deliberately untouched: - packages/shared/src/i18n/locales/* and the public subscribe, unsubscribe and manage pages. Their copy is translated into 16 locales and editing English there would desync 15 of them. - The onboarding flows and the dashboard greeting, whose copy is already the voice everything else was moved toward. - console.error strings, which are developer-facing. PRODUCT.md replaces the legacy .impeccable.md, with the register made explicit and a terminology section recording that user-facing nouns (Contact, Event, Campaign, Workflow, Template, Segment) track the API. Typecheck and production build pass. Co-Authored-By: Claude Opus 5 --- .impeccable.md | 17 --- PRODUCT.md | 74 ++++++++++++ apps/web/src/components/ActivityFeed.tsx | 6 +- apps/web/src/components/ActivityItem.tsx | 2 +- .../web/src/components/BillingConsumption.tsx | 102 +++++++--------- apps/web/src/components/BillingInvoices.tsx | 106 +++++++---------- apps/web/src/components/BillingLimits.tsx | 20 ++-- .../components/CampaignSelectionDialog.tsx | 16 +-- apps/web/src/components/CommandPalette.tsx | 8 +- apps/web/src/components/ContactPicker.tsx | 4 +- .../src/components/DataManagementSettings.tsx | 27 +++-- apps/web/src/components/DomainsSettings.tsx | 48 +++----- apps/web/src/components/EmailDomainInput.tsx | 23 +++- .../components/EmailEditor/EmailEditor.tsx | 30 ++--- .../src/components/EmailEditor/Toolbar.tsx | 4 +- apps/web/src/components/EmailPreviewModal.tsx | 2 +- apps/web/src/components/EmailSettings.tsx | 6 +- apps/web/src/components/KeyValueEditor.tsx | 8 +- apps/web/src/components/QuickStart.tsx | 49 ++++---- apps/web/src/components/SecuritySettings.tsx | 20 ++-- .../src/components/SecurityWarningBanner.tsx | 2 +- .../src/components/SegmentFilterBuilder.tsx | 28 ++--- apps/web/src/components/SmtpSettings.tsx | 13 +- apps/web/src/components/TeamSettings.tsx | 31 +++-- .../components/TemplateSelectionDialog.tsx | 16 +-- apps/web/src/components/WorkflowBuilder.tsx | 24 ++-- .../web/src/components/WorkflowVisualizer.tsx | 6 +- .../workflow-steps/ConditionStepDialog.tsx | 18 +-- .../workflow-steps/ExitStepDialog.tsx | 12 +- .../workflow-steps/SendEmailStepDialog.tsx | 8 +- .../workflow-steps/WaitForEventStepDialog.tsx | 2 +- .../workflow-steps/WebhookStepDialog.tsx | 4 +- .../src/components/workflow-steps/shared.tsx | 8 +- apps/web/src/pages/_app.tsx | 4 +- apps/web/src/pages/_document.tsx | 6 +- apps/web/src/pages/activity/index.tsx | 32 +++-- apps/web/src/pages/analytics/index.tsx | 97 +++++---------- apps/web/src/pages/auth/reset-password.tsx | 6 +- apps/web/src/pages/auth/signup.tsx | 2 +- apps/web/src/pages/auth/verify-email.tsx | 12 +- apps/web/src/pages/campaigns/[id].tsx | 75 ++++++------ apps/web/src/pages/campaigns/create.tsx | 38 +++--- apps/web/src/pages/campaigns/index.tsx | 51 ++++---- apps/web/src/pages/contacts/[id].tsx | 36 +++--- apps/web/src/pages/contacts/index.tsx | 56 +++++---- apps/web/src/pages/index.tsx | 28 ++--- apps/web/src/pages/projects/create.tsx | 11 +- apps/web/src/pages/segments/[id].tsx | 44 +++---- apps/web/src/pages/segments/index.tsx | 18 +-- apps/web/src/pages/segments/new.tsx | 22 ++-- apps/web/src/pages/settings/index.tsx | 112 +++++++++--------- apps/web/src/pages/templates/[id].tsx | 43 ++++--- apps/web/src/pages/templates/create.tsx | 35 +++--- apps/web/src/pages/templates/index.tsx | 34 +++--- apps/web/src/pages/workflows/[id].tsx | 91 +++++++------- apps/web/src/pages/workflows/index.tsx | 58 ++++----- 56 files changed, 819 insertions(+), 836 deletions(-) delete mode 100644 .impeccable.md create mode 100644 PRODUCT.md diff --git a/.impeccable.md b/.impeccable.md deleted file mode 100644 index d6257d4b0..000000000 --- a/.impeccable.md +++ /dev/null @@ -1,17 +0,0 @@ -## Design Context - -### Users -Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first. - -### Brand Personality -Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*. - -### Aesthetic Direction -Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous. - -### Design Principles -1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it. -2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration. -3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions. -4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous. -5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..aa969de1f --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,74 @@ +# Product + +## Register + +product + +## Users + +Developer-founders and indie hackers building SaaS products. They use Plunk to handle +transactional email, marketing campaigns, and workflow automation without the complexity of +Mailchimp or Customer.io. They are technically fluent: they read API docs, they know what a +webhook is, they do not need "email marketing" explained to them. + +They notice tiny details. Inconsistent spacing, placeholder text that adds no value, a button +that does not communicate state, the same concept called two different names on two screens. + +Context: professional environment, desktop-first, task-focused. They are usually mid-task +(shipping a signup flow, debugging a bounce, launching a campaign), not browsing. + +## Product Purpose + +Plunk is an open-source email platform that unifies transactional sending, marketing campaigns, +and automation. Self-hostable, $0.001 per email, no contact limits. Success looks like a +developer getting from signup to a first sent email without reading documentation, and never +needing a support ticket to understand what a screen is asking of them. + +## Brand Personality + +Sharp, minimal, confident. The product earns trust by being simple and correct, not by being +flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design". The brand is +*care without noise*. + +Voice: direct, technical peer to technical peer. Short sentences. Says what a thing does, not +how great it is. Never markets to the user inside the app. + +## Anti-references + +- Mailchimp, Customer.io, HubSpot: feature-bloat, tutorial-tone copy, marketing language inside + the product UI. +- Enterprise SaaS onboarding that explains its own navigation in paragraphs. +- Copy that hedges ("you may want to consider..."), apologizes, or over-reassures. + +## Design Principles + +1. **Every pixel earns its place.** If something does not communicate information or provide an + affordance, remove it. +2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning + states, not decoration. +3. **Interaction should feel fast.** Loading states communicate exactly what is happening. No + silent actions. +4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they + add value. Labels are unambiguous. +5. **Consistency is trust.** The same pattern everywhere. One way to show errors, one way to + show success. No creative variation in functional UI. + +## Terminology + +User-facing vocabulary is fixed and matches the API and documentation. Do not rename these in +the UI: Contact, Event, Action, Trigger, Campaign, Workflow, Template, Segment, Project, API key. +Standardize the wording *around* them instead (one verb for delete, one for create, and so on). + +## Aesthetic Direction + +Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors, no +color for decoration. Backgrounds are near-white with subtle texture; auth pages use a dot-grid +(`radial-gradient(#e5e7eb 1px, transparent 1px)` at `20px 20px`). Cards are white with a neutral +border and light shadow. Typography is precise and legible, not editorial. Spacing is +considered, not generous. + +## Accessibility & Inclusion + +Desktop-first but must not break on tablet. Link text carries standalone meaning, icon-only +buttons carry `aria-label`, error text is never conveyed by color alone. Respect +`prefers-reduced-motion`. diff --git a/apps/web/src/components/ActivityFeed.tsx b/apps/web/src/components/ActivityFeed.tsx index 6e018d939..413aff2cc 100644 --- a/apps/web/src/components/ActivityFeed.tsx +++ b/apps/web/src/components/ActivityFeed.tsx @@ -87,7 +87,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi setNextCursor(result.cursor); setHasMore(result.hasMore); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load activities'); + setError(err instanceof Error ? err.message : 'Couldn’t load activity. Try again.'); console.error('Error fetching activities:', err); } finally { setIsLoading(false); @@ -179,7 +179,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi

{error}

); @@ -232,7 +232,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
- Upcoming Scheduled + Upcoming scheduled
)} diff --git a/apps/web/src/components/ActivityItem.tsx b/apps/web/src/components/ActivityItem.tsx index f17e05a92..e70ff19aa 100644 --- a/apps/web/src/components/ActivityItem.tsx +++ b/apps/web/src/components/ActivityItem.tsx @@ -400,7 +400,7 @@ export const ActivityItem = memo(function ActivityItem({activity, status = 'comp - Event Data + Event data
diff --git a/apps/web/src/components/BillingConsumption.tsx b/apps/web/src/components/BillingConsumption.tsx
index ff3c5964e..f3b5e3247 100644
--- a/apps/web/src/components/BillingConsumption.tsx
+++ b/apps/web/src/components/BillingConsumption.tsx
@@ -9,6 +9,19 @@ interface BillingConsumptionProps {
   hasSubscription: boolean;
 }
 
+/** One header for every state, so the title is written once rather than four times. */
+function UsageCard({period, children}: {period?: string; children: React.ReactNode}) {
+  return (
+    
+      
+        Usage this month
+        {period && Billing period: {period}}
+      
+      {children}
+    
+  );
+}
+
 export function BillingConsumption({projectId, hasSubscription}: BillingConsumptionProps) {
   const {data: config} = useConfig();
   const billingEnabled = config?.features.billing.enabled ?? false;
@@ -39,58 +52,37 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
 
   if (!hasSubscription) {
     return (
-      
-        
-          Usage This Month
-          View your current month email consumption and costs
-        
-        
-          
-            
-            
-

- Usage tracking is only available with an active subscription. Start a subscription to track your email - consumption. -

-
-
-
-
+ + + +
+

Usage tracking starts when you start a subscription.

+
+
+
); } if (isLoading) { return ( - - - Usage This Month - View your current month email consumption and costs - - -
- -
-
-
+ +
+ +
+
); } if (error) { return ( - - - Usage This Month - View your current month email consumption and costs - - - - -
-

Failed to load consumption data. Please try again later.

-
-
-
-
+ + + +
+

Couldn’t load usage data. Try again in a moment.

+
+
+
); } @@ -99,14 +91,9 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt } return ( - - - Usage This Month - - Billing period: {formatDate(consumptionData.period.start)} - {formatDate(consumptionData.period.end)} - - - +
{/* Total Usage */}
@@ -115,7 +102,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
-

Total Emails Sent

+

Total emails sent

{consumptionData.usage.total.toLocaleString()}

@@ -129,7 +116,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
-

Account Credits

+

Account credits

{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}

@@ -144,10 +131,10 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt {/* Upcoming Invoice */} {consumptionData.upcomingInvoice && (
-

Upcoming Invoice

+

Upcoming invoice

- Billing Period + Billing period {formatDate(consumptionData.upcomingInvoice.periodStart)} -{' '} {formatDate(consumptionData.upcomingInvoice.periodEnd)} @@ -161,7 +148,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
{consumptionData.credits && consumptionData.credits.hasCredits && (
- Credits Applied + Credits applied -{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)} @@ -169,7 +156,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt )}
- Amount Due + Amount due {formatCurrency( consumptionData.upcomingInvoice.amountDue, @@ -192,7 +179,6 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt )}
- - + ); } diff --git a/apps/web/src/components/BillingInvoices.tsx b/apps/web/src/components/BillingInvoices.tsx index b4f7ed5a4..25a4b93c0 100644 --- a/apps/web/src/components/BillingInvoices.tsx +++ b/apps/web/src/components/BillingInvoices.tsx @@ -1,4 +1,4 @@ -import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, IconSpinner} from '@plunk/ui'; +import {Alert, Badge, Button, Card, CardContent, CardHeader, CardTitle, IconSpinner} from '@plunk/ui'; import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react'; import {useBillingInvoices} from '../lib/hooks/useBillingInvoices'; @@ -8,63 +8,54 @@ interface BillingInvoicesProps { onManageBilling?: () => void; } +/** One header for every state, so the title is written once rather than four times. */ +function InvoicesCard({children}: {children: React.ReactNode}) { + return ( + + + Invoices + + {children} + + ); +} + export function BillingInvoices({projectId, hasSubscription, onManageBilling}: BillingInvoicesProps) { const {invoicesData, isLoading, error} = useBillingInvoices(projectId, hasSubscription); if (!hasSubscription) { return ( - - - Past Invoices - View and download your billing history - - - - -
-

- Invoice history is only available with an active subscription. Start a subscription to view your - invoices. -

-
-
-
-
+ + + +
+

Invoices appear here once you start a subscription.

+
+
+
); } if (isLoading) { return ( - - - Past Invoices - View and download your billing history - - -
- -
-
-
+ +
+ +
+
); } if (error) { return ( - - - Past Invoices - View and download your billing history - - - - -
-

Failed to load invoices. Please try again later.

-
-
-
-
+ + + +
+

Couldn’t load your invoices. Try again in a moment.

+
+
+
); } @@ -119,17 +110,13 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B const hasMoreInvoices = invoicesData.invoices.length > 5; return ( - - - Recent Invoices - View your latest invoices (showing {recentInvoices.length} most recent) - - + + <> {invoicesData.invoices.length === 0 ? (
-

No invoices found. Invoices will appear here after your first billing period.

+

Your first invoice appears here at the end of this billing period.

) : ( @@ -160,7 +147,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B )} {isPaid(invoice) ? (

- Amount Paid:{' '} + Amount paid:{' '} {formatCurrency(invoice.amountPaid, invoice.currency)}

) : ( @@ -170,7 +157,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B {formatCurrency(invoice.total, invoice.currency)}

- Amount Due:{' '} + Amount due:{' '} {formatCurrency(invoice.amountDue, invoice.currency)}

@@ -214,21 +201,20 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B

- Showing 5 most recent invoices. For complete billing history,{' '} + Showing your 5 most recent invoices.{' '} {onManageBilling ? ( <> - visit the{' '} - . + Open the customer portal + {' '} + for your full history. ) : ( - 'use the Manage Billing button above to access the Customer Portal.' + 'Use Manage billing above for your full history.' )}

@@ -236,7 +222,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B )}
)} - - + + ); } diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx index 84dea496e..61030be2d 100644 --- a/apps/web/src/components/BillingLimits.tsx +++ b/apps/web/src/components/BillingLimits.tsx @@ -109,12 +109,12 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr // Revalidate SWR cache await mutate(); setIsEditing(false); - setSuccessMessage('Billing limits updated successfully'); + setSuccessMessage('Billing limits updated'); // Clear success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to update billing limits'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t save your billing limits. Try again.'); } }; @@ -143,7 +143,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr return ( - Billing Limits + Billing limits Set monthly limits for each email category @@ -158,7 +158,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr return ( - Billing Limits + Billing limits {tier === 'paid' ? 'Set monthly limits for each email category. Limits reset on the 1st of each month.' @@ -229,7 +229,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr {canEditLimits && (
- +
)}
@@ -249,7 +249,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr : null; return ( - Workflow Emails Limit + Workflow emails limit - Campaign Emails Limit + Campaign emails limit - Transactional Emails Limit + Transactional emails limit - Inbound Emails Limit + Inbound emails limit
diff --git a/apps/web/src/components/CampaignSelectionDialog.tsx b/apps/web/src/components/CampaignSelectionDialog.tsx index 94f37dcda..6d06a88e9 100644 --- a/apps/web/src/components/CampaignSelectionDialog.tsx +++ b/apps/web/src/components/CampaignSelectionDialog.tsx @@ -147,7 +147,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: - All Campaigns + All campaigns Draft Scheduled Sending @@ -271,7 +271,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: />
{selectedCampaign?.subject && (

{selectedCampaign.subject}

@@ -286,7 +286,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: toggleField('body')} />

Full email content and design

@@ -299,7 +299,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: toggleField('from')} />
{selectedCampaign?.from && (

{selectedCampaign.from}

@@ -318,7 +318,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: />
{selectedCampaign?.fromName && (

{selectedCampaign.fromName}

@@ -337,7 +337,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: />
{selectedCampaign?.replyTo && (

{selectedCampaign.replyTo}

@@ -356,7 +356,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: />
{selectedCampaign && (

{getAudienceLabel(selectedCampaign)}

@@ -372,7 +372,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: Back
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 154e4cd8b..da87e38b2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,10 +56,10 @@ const NAV_ACTIONS: Action[] = [ ]; const CREATE_ACTIONS: Action[] = [ - {label: 'New Campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']}, - {label: 'New Template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']}, - {label: 'New Segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']}, - {label: 'New Workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']}, + {label: 'New campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']}, + {label: 'New template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']}, + {label: 'New segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']}, + {label: 'New workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']}, ]; // Chord map: first-key → second-key → action diff --git a/apps/web/src/components/ContactPicker.tsx b/apps/web/src/components/ContactPicker.tsx index d3037bf45..fbafca8c8 100644 --- a/apps/web/src/components/ContactPicker.tsx +++ b/apps/web/src/components/ContactPicker.tsx @@ -38,7 +38,7 @@ export function ContactPicker({ onChange, onAdd, existing = [], - placeholder = 'Search contacts...', + placeholder = 'Search contacts…', }: ContactPickerProps) { const [mode, setMode] = useState('search'); @@ -154,7 +154,7 @@ export function ContactPicker({
setSearch(e.target.value)} className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm" diff --git a/apps/web/src/components/DataManagementSettings.tsx b/apps/web/src/components/DataManagementSettings.tsx index 0da3fb3a9..6bf4754fb 100644 --- a/apps/web/src/components/DataManagementSettings.tsx +++ b/apps/web/src/components/DataManagementSettings.tsx @@ -101,7 +101,7 @@ export function DataManagementSettings() { setSelectedField(null); mutateFields(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete field'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the field. Try again.'); } finally { setIsDeleting(false); } @@ -122,7 +122,7 @@ export function DataManagementSettings() { setSelectedEvent(null); mutateEvents(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete event'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the event. Try again.'); } finally { setIsDeleting(false); } @@ -145,10 +145,9 @@ export function DataManagementSettings() { {/* Custom Contact Fields */} - Custom Contact Fields + Custom contact fields - Manage custom fields stored in your contact data. You can only delete fields that are not used in any - segments or campaigns. + Fields set on contacts through the API. A field used by a segment or campaign can’t be deleted. @@ -162,7 +161,7 @@ export function DataManagementSettings() { - Field Name + Field name Type Coverage Actions @@ -194,10 +193,10 @@ export function DataManagementSettings() { {/* Custom Events */} - Custom Events + Custom events - Manage custom events tracked in your project. You can only delete events that are not used in any segments - or workflows. System events (email.*, segment.*) cannot be deleted. + Events your contacts have triggered. An event used by a segment or workflow can’t be deleted, and + neither can system events (email.*, segment.*). @@ -211,7 +210,7 @@ export function DataManagementSettings() {
- Event Name + Event name Actions @@ -236,11 +235,13 @@ export function DataManagementSettings() { - {selectedField ? 'Delete Field' : 'Delete Event'} + + {selectedField ? `Delete "${selectedField.replace('data.', '')}"?` : `Delete "${selectedEvent}"?`} + {selectedField - ? `Are you sure you want to delete the field "${selectedField.replace('data.', '')}"?` - : `Are you sure you want to delete the event "${selectedEvent}"?`} + ? 'The field is removed from every contact that has it. This can’t be undone.' + : 'Every recorded instance of this event is deleted. This can’t be undone.'} diff --git a/apps/web/src/components/DomainsSettings.tsx b/apps/web/src/components/DomainsSettings.tsx index 765043aa8..a2740dd2c 100644 --- a/apps/web/src/components/DomainsSettings.tsx +++ b/apps/web/src/components/DomainsSettings.tsx @@ -175,9 +175,9 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { await mutateDomains(); form.reset(); - showMessage('success', `Domain ${values.domain} added successfully. Please configure DNS records.`); + showMessage('success', `Domain ${values.domain} added. Add the DNS records below to verify it.`); } catch (error) { - showMessage('error', error instanceof Error ? error.message : 'Failed to add domain'); + showMessage('error', error instanceof Error ? error.message : 'Couldn’t add the domain. Check the spelling and try again.'); } }; @@ -214,7 +214,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { showMessage('error', `Domain ${status.domain} is not yet verified. Please check your DNS records.`); } } catch (error) { - showMessage('error', error instanceof Error ? error.message : 'Failed to check verification'); + showMessage('error', error instanceof Error ? error.message : 'Couldn’t check verification. Try again in a moment.'); } finally { setCheckingVerification(null); } @@ -229,9 +229,9 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { if (selectedDomain === domainToRemove.id) { setSelectedDomain(null); } - showMessage('success', `Domain ${domainToRemove.name} removed successfully`); + showMessage('success', `Domain ${domainToRemove.name} deleted`); } catch (error) { - showMessage('error', error instanceof Error ? error.message : 'Failed to remove domain'); + showMessage('error', error instanceof Error ? error.message : 'Couldn’t delete the domain. Try again.'); } finally { setDomainToRemove(null); } @@ -260,8 +260,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { {/* Add Domain Form */} - Add Domain - Add a custom domain to send emails from + Add domain + Send from your own address instead of a Plunk one.
@@ -306,7 +306,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
@@ -317,8 +317,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { {/* Domains List */} - Your Domains - Manage your verified domains + Your domains {isLoading ? ( @@ -407,15 +406,15 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { {/* Required DKIM Records for Sending */}
-

Required for Sending

+

Required for sending

REQUIRED
{!status.verified && (

- Add these DKIM records to verify your domain and send emails. DNS changes can take up - to 48 hours to propagate. + Copy each record into your DNS provider, then hit refresh above. DNS changes can take + up to 48 hours to propagate.

)} @@ -617,7 +616,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { {config?.aws?.sesRegion && (
-

Inbound Email

+

Inbound email

OPTIONAL @@ -694,21 +693,6 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
)} -
-
- - - -
-

- Click the copy icon to copy record values. After adding all records to your DNS - provider, use the refresh button above to verify your domain. -

-
)}
@@ -725,9 +709,9 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { open={showRemoveDialog} onOpenChange={setShowRemoveDialog} onConfirm={handleRemoveDomain} - title="Remove Domain" - description={`Are you sure you want to remove ${domainToRemove?.name}?`} - confirmText="Remove" + title={`Delete ${domainToRemove?.name}?`} + description="You'll stop being able to send from this address until you add and verify it again." + confirmText="Delete domain" variant="destructive" /> diff --git a/apps/web/src/components/EmailDomainInput.tsx b/apps/web/src/components/EmailDomainInput.tsx index 56a3e729b..3a4f8a4ae 100644 --- a/apps/web/src/components/EmailDomainInput.tsx +++ b/apps/web/src/components/EmailDomainInput.tsx @@ -67,9 +67,14 @@ export function EmailDomainInput({value, onChange, id, placeholder, required, la if (isLoading) { return (
- {label && } + {label && ( + + )}
- +
); @@ -78,7 +83,12 @@ export function EmailDomainInput({value, onChange, id, placeholder, required, la if (verifiedDomains.length === 0) { return (
- {label && } + {label && ( + + )} @@ -108,7 +118,12 @@ export function EmailDomainInput({value, onChange, id, placeholder, required, la return (
- {label && } + {label && ( + + )}
setSelectedContactId(val === 'none' ? '' : val)} > - + @@ -556,7 +556,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT minHeight: '400px', height: '100%', }} - title="Email Preview" + title="Email preview" />
@@ -570,7 +570,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
{selectedContactId && ( @@ -662,7 +662,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT minHeight: '400px', height: '100%', }} - title="Email Preview" + title="Email preview" /> @@ -676,7 +676,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT - Insert Variable + Insert variable
@@ -751,7 +751,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT disabled={!customVariable} className="w-full" > - Insert Variable + Insert variable

@@ -776,15 +776,11 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT > - Custom HTML Detected + Switching to visual mode will change your HTML

-

- Your HTML contains custom formatting, styles, or elements that the visual editor doesn't support. - Switching to visual mode will cause these customizations to be lost or modified. -

-

This may affect:

+

The visual editor may strip or rewrite:

  • Custom HTML elements and attributes
  • Inline styles and CSS classes
  • @@ -795,10 +791,10 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
@@ -809,7 +805,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT - Insert Image + Insert image
diff --git a/apps/web/src/components/EmailEditor/Toolbar.tsx b/apps/web/src/components/EmailEditor/Toolbar.tsx index 7c9ebd5ce..e01eccd2a 100644 --- a/apps/web/src/components/EmailEditor/Toolbar.tsx +++ b/apps/web/src/components/EmailEditor/Toolbar.tsx @@ -432,7 +432,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage > {/* Custom color input */}
- +
e.preventDefault()} onClick={removeLink} > - Remove Link + Remove link
)} diff --git a/apps/web/src/components/EmailPreviewModal.tsx b/apps/web/src/components/EmailPreviewModal.tsx index c4d5bc2ac..ccd64059f 100644 --- a/apps/web/src/components/EmailPreviewModal.tsx +++ b/apps/web/src/components/EmailPreviewModal.tsx @@ -61,7 +61,7 @@ export function EmailPreviewModal({ - Email Preview + Email preview {/* Device Selector */} diff --git a/apps/web/src/components/EmailSettings.tsx b/apps/web/src/components/EmailSettings.tsx index a7a358832..78addb06d 100644 --- a/apps/web/src/components/EmailSettings.tsx +++ b/apps/web/src/components/EmailSettings.tsx @@ -40,7 +40,7 @@ export function EmailSettings({
- +
- +
@@ -99,7 +99,7 @@ export function KeyValueEditor({initialData, onChange}: KeyValueEditorProps) {
updateKey(pair.id, e.target.value)} className="text-sm" @@ -108,7 +108,7 @@ export function KeyValueEditor({initialData, onChange}: KeyValueEditorProps) {
updateValue(pair.id, e.target.value)} className="text-sm" diff --git a/apps/web/src/components/QuickStart.tsx b/apps/web/src/components/QuickStart.tsx index 5cf09a1f6..5c3cd701c 100644 --- a/apps/web/src/components/QuickStart.tsx +++ b/apps/web/src/components/QuickStart.tsx @@ -1,4 +1,4 @@ -import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui'; +import {Button, Card, CardContent, CardHeader, CardTitle} from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {BookOpen, Check, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react'; import Link from 'next/link'; @@ -103,8 +103,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { return ( - Quick Start - Get started with Plunk in minutes + Quick start
@@ -141,10 +140,10 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { allSteps.push({ id: 'domain', icon: Shield, - title: 'Verify Your Domain', - description: 'Essential for email deliverability and avoiding spam', + title: 'Verify your domain', + description: 'Unverified domains land in spam.', link: '/settings?tab=domains', - linkText: 'Add Domain', + linkText: 'Add domain', isCompleted: false, }); } @@ -154,10 +153,10 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { allSteps.push({ id: 'contacts', icon: Users, - title: 'Add Your First Contacts', - description: 'Import your subscriber list to start sending emails', + title: 'Add your first contacts', + description: 'Import a CSV or add them through the API.', link: '/contacts', - linkText: 'Add Contacts', + linkText: 'Add contacts', isCompleted: false, }); } @@ -167,10 +166,10 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { allSteps.push({ id: 'campaign', icon: Mail, - title: 'Send Your First Campaign', - description: 'Create and send your first email campaign', + title: 'Send your first campaign', + description: 'Write it, preview it, send it.', link: '/campaigns', - linkText: 'Create Campaign', + linkText: 'Create campaign', isCompleted: false, }); } @@ -180,10 +179,10 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { allSteps.push({ id: 'workflows', icon: Zap, - title: 'Set Up Automation', - description: 'Create automated workflows to engage your audience', + title: 'Set up automation', + description: 'Send email automatically when contacts trigger an event.', link: '/workflows', - linkText: 'Create Workflow', + linkText: 'Create workflow', isCompleted: false, }); } @@ -193,8 +192,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { allSteps.push({ id: 'subscription', icon: Shield, - title: 'Upgrade Your Plan', - description: 'Remove Plunk branding and unlock more features', + title: 'Upgrade your plan', + description: 'Remove Plunk branding from your emails.', link: '/settings?tab=billing', linkText: 'Upgrade', isCompleted: false, @@ -206,10 +205,10 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { allSteps.push({ id: 'campaign-reengagement', icon: Mail, - title: 'Re-engage Your Audience', - description: `It's been ${daysSinceLastCampaign} days since your last campaign`, + title: 'Re-engage your contacts', + description: `${daysSinceLastCampaign} days since your last campaign.`, link: '/campaigns', - linkText: 'Create Campaign', + linkText: 'Create campaign', isCompleted: false, }); } @@ -219,8 +218,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { return ( - Quick Start - Your project is fully set up + Quick start
@@ -230,7 +228,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {

All set!

- Domain verified, contacts imported, campaigns running. Everything is set up correctly. + Domain verified, contacts imported, campaigns running.

@@ -246,10 +244,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { return ( - Quick Start - - {visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'} - + Quick start
diff --git a/apps/web/src/components/SecuritySettings.tsx b/apps/web/src/components/SecuritySettings.tsx index 02a0dbb9e..d16c93828 100644 --- a/apps/web/src/components/SecuritySettings.tsx +++ b/apps/web/src/components/SecuritySettings.tsx @@ -24,7 +24,7 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { return ( - Security Overview + Security overview Monitor your project's email health and reputation @@ -48,7 +48,7 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
- Security Overview + Security overview {overallLevel === 'healthy' && 'Your project is in good standing'} {overallLevel === 'warning' && 'Your email health needs attention'} @@ -61,7 +61,7 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { {isDisabled && ( - Project Disabled + Project disabled This project has been disabled. Please contact support for more details. @@ -94,7 +94,7 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { - All security metrics are within acceptable levels. Keep up the good work! + Bounce and complaint rates are within limits. )} @@ -104,18 +104,18 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { {/* Bounce Metrics */} - Bounce Rate + Bounce rate Hard bounces indicate invalid or non-existent email addresses
@@ -126,18 +126,18 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { {/* Complaint Metrics */} - Complaint Rate + Complaint rate Complaints occur when recipients mark emails as spam
diff --git a/apps/web/src/components/SecurityWarningBanner.tsx b/apps/web/src/components/SecurityWarningBanner.tsx index 836a7117a..352994959 100644 --- a/apps/web/src/components/SecurityWarningBanner.tsx +++ b/apps/web/src/components/SecurityWarningBanner.tsx @@ -34,7 +34,7 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {

diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx index a62cf6f06..01729f07a 100644 --- a/apps/web/src/components/SegmentFilterBuilder.tsx +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -53,10 +53,10 @@ const TIME_UNITS = [ const STANDARD_FIELDS = [ // Contact fields - {value: 'email', label: 'Email', type: 'string', category: 'Contact Fields'}, - {value: 'subscribed', label: 'Subscribed', type: 'boolean', category: 'Contact Fields'}, - {value: 'createdAt', label: 'Created At', type: 'date', category: 'Contact Fields'}, - {value: 'updatedAt', label: 'Updated At', type: 'date', category: 'Contact Fields'}, + {value: 'email', label: 'Email', type: 'string', category: 'Contact fields'}, + {value: 'subscribed', label: 'Subscribed', type: 'boolean', category: 'Contact fields'}, + {value: 'createdAt', label: 'Created at', type: 'date', category: 'Contact fields'}, + {value: 'updatedAt', label: 'Updated at', type: 'date', category: 'Contact fields'}, ] as const; interface FieldOption { @@ -64,7 +64,7 @@ interface FieldOption { label: string; description?: string; type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email' | 'segment'; - category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity' | 'Segments'; + category: 'Contact fields' | 'Custom data' | 'Events' | 'Email activity' | 'Segments'; } // Hook to fetch available fields, events, and segments @@ -91,7 +91,7 @@ function useAvailableOptions(currentSegmentId?: string) { value: f.field, label: isCustomData ? f.field.replace('data.', '') : f.field, type: f.type, - category: isCustomData ? ('Custom Data' as const) : ('Contact Fields' as const), + category: isCustomData ? ('Custom data' as const) : ('Contact fields' as const), }; }); @@ -108,7 +108,7 @@ function useAvailableOptions(currentSegmentId?: string) { .replace(/([A-Z])/g, ' $1') .trim(), type: 'event' as const, - category: 'Email Activity' as const, + category: 'Email activity' as const, }); } else { // Ensure event has the 'event.' prefix for backend compatibility @@ -393,7 +393,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
setSearch(e.target.value)} className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0" @@ -552,7 +552,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available onChange({...filter, value: val === '' ? 0 : parseFloat(val) || 0}); }} className="text-sm bg-white" - placeholder="Enter number" + placeholder="e.g., 30" /> ) : fieldType === 'date' ? ( onChange({...filter, value: e.target.value})} className="text-sm bg-white" - placeholder="Enter value" + placeholder="e.g., pro" /> )}
@@ -675,7 +675,7 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi {group.conditions && (
- Nested Conditions + Nested conditions {!group.conditions && ( )}
@@ -794,7 +794,7 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
); diff --git a/apps/web/src/components/SmtpSettings.tsx b/apps/web/src/components/SmtpSettings.tsx index 3eb6ecf66..589619c47 100644 --- a/apps/web/src/components/SmtpSettings.tsx +++ b/apps/web/src/components/SmtpSettings.tsx @@ -21,7 +21,7 @@ export function SmtpSettings({smtpConfig}: SmtpSettingsProps) { - SMTP Server + SMTP server SMTP server is not configured on this instance @@ -44,7 +44,7 @@ export function SmtpSettings({smtpConfig}: SmtpSettingsProps) { - Development Mode + Development mode @@ -61,11 +61,10 @@ export function SmtpSettings({smtpConfig}: SmtpSettingsProps) { - SMTP Server Configuration + SMTP server configuration - Send emails through standard email clients using SMTP protocol. Works with Outlook, Thunderbird, and any - SMTP-compatible application. + Send through any SMTP client — Outlook, Thunderbird, or your own. @@ -73,7 +72,7 @@ export function SmtpSettings({smtpConfig}: SmtpSettingsProps) { {/* Server Details */}
- + {smtpConfig.domain} @@ -117,7 +116,7 @@ export function SmtpSettings({smtpConfig}: SmtpSettingsProps) { {/* Credentials */}
-

Authentication Credentials

+

Authentication credentials

diff --git a/apps/web/src/components/TeamSettings.tsx b/apps/web/src/components/TeamSettings.tsx index e84838a17..55b4fedec 100644 --- a/apps/web/src/components/TeamSettings.tsx +++ b/apps/web/src/components/TeamSettings.tsx @@ -5,7 +5,6 @@ import { Button, Card, CardContent, - CardDescription, CardHeader, CardTitle, Dialog, @@ -91,7 +90,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe try { await network.fetch('POST', `/projects/${projectId}/members`, values); - setSuccess('Member added successfully'); + setSuccess('Member added'); await mutate(); form.reset(); setShowAddDialog(false); @@ -102,7 +101,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe if (err instanceof Error) { setError(err.message); } else { - setError('Failed to add member'); + setError('Couldn’t add that member. Check the email address and try again.'); } } finally { setIsSubmitting(false); @@ -118,7 +117,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe try { await network.fetch('DELETE', `/projects/${projectId}/members/${memberToRemove.userId}`); - setSuccess('Member removed successfully'); + setSuccess('Member removed'); await mutate(); setMemberToRemove(null); @@ -128,7 +127,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe if (err instanceof Error) { setError(err.message); } else { - setError('Failed to remove member'); + setError('Couldn’t remove that member. Try again.'); } } finally { setIsSubmitting(false); @@ -145,7 +144,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe `/projects/${projectId}/members/${userId}`, {role: newRole}, ); - setSuccess('Member role updated successfully'); + setSuccess('Role updated'); await mutate(); // Clear success message after 3 seconds @@ -154,7 +153,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe if (err instanceof Error) { setError(err.message); } else { - setError('Failed to update member role'); + setError('Couldn’t change that role. Try again.'); } } }; @@ -203,8 +202,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
- Team Members - Manage who has access to this project + Team members
{canManageMembers && ( )}
@@ -314,7 +312,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe > - Add Team Member + Add team member Add a user to this project by their email address. They must have an existing account. @@ -329,7 +327,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe name="email" render={({field}) => ( - Email Address + Email address @@ -378,7 +376,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe Cancel @@ -390,10 +388,9 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe setMemberToRemove(null)}> - Remove Team Member + Remove {memberToRemove?.email}? - Are you sure you want to remove {memberToRemove?.email} from this project? They will - immediately lose access. + They lose access to this project immediately. Their Plunk account is not affected. @@ -401,7 +398,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe Cancel diff --git a/apps/web/src/components/TemplateSelectionDialog.tsx b/apps/web/src/components/TemplateSelectionDialog.tsx index bd597a171..d012566cd 100644 --- a/apps/web/src/components/TemplateSelectionDialog.tsx +++ b/apps/web/src/components/TemplateSelectionDialog.tsx @@ -128,7 +128,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: setSearchInput(e.target.value)} className="pl-10" @@ -164,7 +164,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: variant={typeFilter === 'ALL' ? 'default' : 'secondary'} size="sm" > - All Templates + All templates
diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx index 328fb8df4..e772b05c4 100644 --- a/apps/web/src/components/WorkflowBuilder.tsx +++ b/apps/web/src/components/WorkflowBuilder.tsx @@ -510,12 +510,12 @@ type PickerContext = {mode: 'append'; fromStepId: string; branch?: string} | {mo // Step type options for adding new steps const STEP_TYPE_OPTIONS = [ - {value: 'SEND_EMAIL', label: 'Send Email', icon: Mail, color: STEP_TYPE_COLORS.SEND_EMAIL}, + {value: 'SEND_EMAIL', label: 'Send email', icon: Mail, color: STEP_TYPE_COLORS.SEND_EMAIL}, {value: 'DELAY', label: 'Delay', icon: Clock, color: STEP_TYPE_COLORS.DELAY}, - {value: 'WAIT_FOR_EVENT', label: 'Wait for Event', icon: Clock, color: STEP_TYPE_COLORS.WAIT_FOR_EVENT}, + {value: 'WAIT_FOR_EVENT', label: 'Wait for event', icon: Clock, color: STEP_TYPE_COLORS.WAIT_FOR_EVENT}, {value: 'CONDITION', label: 'Condition', icon: GitBranch, color: STEP_TYPE_COLORS.CONDITION}, {value: 'WEBHOOK', label: 'Webhook', icon: Webhook, color: STEP_TYPE_COLORS.WEBHOOK}, - {value: 'UPDATE_CONTACT', label: 'Update Contact', icon: UserCog, color: STEP_TYPE_COLORS.UPDATE_CONTACT}, + {value: 'UPDATE_CONTACT', label: 'Update contact', icon: UserCog, color: STEP_TYPE_COLORS.UPDATE_CONTACT}, {value: 'EXIT', label: 'Exit', icon: LogOut, color: STEP_TYPE_COLORS.EXIT}, ]; @@ -808,7 +808,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr window.dispatchEvent(event); }, 100); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to insert step'); + toast.error(error instanceof Error ? error.message : 'Couldn’t insert the step. Try again.'); } return; } @@ -872,7 +872,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr }, ); - toast.success('Step added successfully'); + toast.success('Step added'); setPickerContext(null); onUpdate(); @@ -882,7 +882,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr window.dispatchEvent(event); }, 100); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to add step'); + toast.error(error instanceof Error ? error.message : 'Couldn’t add the step. Try again.'); } }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -941,7 +941,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr onUpdate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete step'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the step. Try again.'); } finally { setStepToDelete(null); } @@ -969,7 +969,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr toast.success('Steps disconnected'); onUpdate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to disconnect steps'); + toast.error(error instanceof Error ? error.message : 'Couldn’t disconnect those steps. Try again.'); } finally { setTransitionToDisconnect(null); } @@ -1048,7 +1048,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr toast.success('Steps connected'); onUpdate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to connect steps'); + toast.error(error instanceof Error ? error.message : 'Couldn’t connect those steps. Try again.'); } }, [isValidConnection, steps, workflowId, onUpdate], @@ -1078,7 +1078,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr setPickerContext(null); onUpdate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to connect steps'); + toast.error(error instanceof Error ? error.message : 'Couldn’t connect those steps. Try again.'); } }, [pickerContext, steps, workflowId, onUpdate], @@ -1376,9 +1376,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr

This action cannot be undone.

) : ( -

- Are you sure you want to delete this step? This action cannot be undone. -

+

This step is removed from the workflow. This can't be undone.

)} diff --git a/apps/web/src/components/WorkflowVisualizer.tsx b/apps/web/src/components/WorkflowVisualizer.tsx index d795a6e6e..ee61c5c8e 100644 --- a/apps/web/src/components/WorkflowVisualizer.tsx +++ b/apps/web/src/components/WorkflowVisualizer.tsx @@ -200,7 +200,7 @@ function CustomNode({
- If/Else Branch + If/else branch
)} @@ -278,7 +278,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) { type: 'custom', position: {x: 0, y: 0}, data: { - label: 'End Workflow', + label: 'End workflow', type: 'END', icon: LogOut, color: '#9ca3af', @@ -295,7 +295,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) { type: 'custom', position: {x: 0, y: 0}, data: { - label: 'End Workflow', + label: 'End workflow', type: 'END', icon: LogOut, color: '#9ca3af', diff --git a/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx b/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx index 902fc8ad4..e4cb95d63 100644 --- a/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx @@ -31,7 +31,7 @@ interface OperatorOption { const ALL_OPERATORS: OperatorOption[] = [ {value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']}, - {value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notEquals', label: 'Not equals', types: ['string', 'number', 'boolean', 'date']}, {value: 'contains', label: 'Contains', types: ['string']}, {value: 'notContains', label: 'Does not contain', types: ['string']}, {value: 'greaterThan', label: 'Greater than', types: ['number', 'date']}, @@ -303,7 +303,7 @@ function ConditionModeToggle({ }: ConditionModeToggleProps) { return (
- +
)} diff --git a/apps/web/src/components/workflow-steps/ExitStepDialog.tsx b/apps/web/src/components/workflow-steps/ExitStepDialog.tsx index c55d21010..9a3657d7b 100644 --- a/apps/web/src/components/workflow-steps/ExitStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/ExitStepDialog.tsx @@ -36,17 +36,17 @@ export function ExitStepDialog({step, workflowId, open, onOpenChange, onSuccess} isSubmitting={isSubmitting} >
- + setRecipientType(value as 'CONTACT' | 'CUSTOM')}> @@ -100,7 +100,7 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc /> @@ -109,7 +109,7 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc {recipientType === 'CUSTOM' && (
- +
- +
(
updateHeader(index, {key: e.target.value})} className="text-sm font-mono" /> updateHeader(index, {value: e.target.value})} className="text-sm font-mono" diff --git a/apps/web/src/components/workflow-steps/shared.tsx b/apps/web/src/components/workflow-steps/shared.tsx index 97aad05ea..28a3c0de8 100644 --- a/apps/web/src/components/workflow-steps/shared.tsx +++ b/apps/web/src/components/workflow-steps/shared.tsx @@ -57,10 +57,10 @@ export function useStepUpdate(workflowId: string, stepId: string) { `/workflows/${workflowId}/steps/${stepId}`, input as Parameters[2], ); - toast.success('Step updated successfully'); + toast.success('Step updated'); return true; } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to update step'); + toast.error(error instanceof Error ? error.message : 'Couldn’t save the step. Try again.'); return false; } finally { setIsSubmitting(false); @@ -108,7 +108,7 @@ export function StepDialogShell({
- + diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 135db14a1..eb6275e39 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -65,7 +65,7 @@ function AuthGuard({children}: {children: React.ReactNode}) { // Show loading state while checking authentication (only for protected routes) if (isLoading && !isPublicRoute) { - return ; + return ; } // Don't render protected content if redirecting @@ -90,7 +90,7 @@ function ProjectGuard({children}: {children: React.ReactNode}) { // Show loading state while checking projects (only for routes that need a project) if (isLoading && !isNoProjectRoute) { - return ; + return ; } // Don't render protected content if redirecting diff --git a/apps/web/src/pages/_document.tsx b/apps/web/src/pages/_document.tsx index 1fa287d81..a2f40380f 100644 --- a/apps/web/src/pages/_document.tsx +++ b/apps/web/src/pages/_document.tsx @@ -11,7 +11,7 @@ function Document({locale}: {locale: string}) { {/* Open Graph / Facebook */} @@ -19,7 +19,7 @@ function Document({locale}: {locale: string}) { @@ -28,7 +28,7 @@ function Document({locale}: {locale: string}) { diff --git a/apps/web/src/pages/activity/index.tsx b/apps/web/src/pages/activity/index.tsx index b928c39b5..ba7613a75 100644 --- a/apps/web/src/pages/activity/index.tsx +++ b/apps/web/src/pages/activity/index.tsx @@ -38,7 +38,7 @@ export default function ActivityPage() { const statsCards = [ { - name: 'Events Triggered', + name: 'Events triggered', value: stats?.totalEvents?.toLocaleString() || '0', icon: Zap, description: 'Last 30 days', @@ -46,7 +46,7 @@ export default function ActivityPage() { bgColor: 'bg-neutral-100', }, { - name: 'Emails Sent', + name: 'Emails sent', value: stats?.totalEmailsSent?.toLocaleString() || '0', icon: Send, description: 'Last 30 days', @@ -54,7 +54,7 @@ export default function ActivityPage() { bgColor: 'bg-neutral-100', }, { - name: 'Open Rate', + name: 'Open rate', value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%', icon: Eye, description: `${stats?.totalEmailsOpened?.toLocaleString() || '0'} opens`, @@ -62,7 +62,7 @@ export default function ActivityPage() { bgColor: 'bg-neutral-100', }, { - name: 'Click Rate', + name: 'Click rate', value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%', icon: MousePointerClick, description: `${stats?.totalEmailsClicked?.toLocaleString() || '0'} clicks`, @@ -80,7 +80,7 @@ export default function ActivityPage() {

Activity

- Real-time overview of events, emails, and workflow executions across your project. + Events, emails, and workflow runs as they happen.

@@ -117,18 +117,18 @@ export default function ActivityPage() { - All Activity Types + All activity types Events Emails - Emails Sent - Emails Delivered - Emails Received - Emails Opened - Emails Clicked - Emails Bounced - Email Complaints + Emails sent + Emails delivered + Emails received + Emails opened + Emails clicked + Emails bounced + Email complaints Workflows @@ -153,11 +153,7 @@ export default function ActivityPage() { {/* Activity Feed */} - Recent Activity - - Live feed of all activities happening across your project. Updates automatically as new activities - occur. - + Recent activity 0 ? 'positive' : 'neutral') : 'neutral', }, { - name: 'Open Rate', + name: 'Open rate', value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%', icon: Eye, description: `${stats?.totalEmailsOpened?.toLocaleString() || cumulativeTotals.opens.toLocaleString()} opens`, @@ -163,7 +162,7 @@ export default function AnalyticsPage() { trend: stats?.openRate && stats.openRate > 20 ? 'positive' : 'neutral', }, { - name: 'Click Rate', + name: 'Click rate', value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%', icon: MousePointerClick, description: `${stats?.totalEmailsClicked?.toLocaleString() || cumulativeTotals.clicks.toLocaleString()} clicks`, @@ -172,7 +171,7 @@ export default function AnalyticsPage() { trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral', }, { - name: 'Active Campaigns', + name: 'Active campaigns', value: campaignStats?.active?.toLocaleString() || '0', icon: Megaphone, description: `${campaignStats?.total || 0} total campaigns`, @@ -190,7 +189,7 @@ export default function AnalyticsPage() { trend: stats?.totalWorkflowsStarted && stats.totalWorkflowsStarted > 0 ? 'positive' : 'neutral', }, { - name: 'Total Events', + name: 'Total events', value: stats?.totalEvents?.toLocaleString() || '0', icon: Zap, description: 'Custom events tracked', @@ -210,7 +209,7 @@ export default function AnalyticsPage() {

Analytics

- Comprehensive insights into your email performance, engagement metrics, and delivery statistics. + Opens, clicks, and deliverability for the last {days} days.

@@ -233,7 +232,7 @@ export default function AnalyticsPage() {
- Failed to load analytics data. Please try again. + Couldn’t load analytics. Try again in a moment.
@@ -265,7 +264,7 @@ export default function AnalyticsPage() { {/* Email Volume Chart */} - Email Volume Trends + Email volume trends Daily email sends, opens, and clicks over the selected period @@ -375,7 +374,7 @@ export default function AnalyticsPage() { {/* Engagement Rate Chart */} - Engagement Rate Trends + Engagement rate trends Open rate percentage over time @@ -415,7 +414,7 @@ export default function AnalyticsPage() { value} - formatter={(value: any) => [`${value}%`, 'Open Rate']} + formatter={(value: any) => [`${value}%`, 'Open rate']} /> } cursor={{ @@ -448,51 +447,22 @@ export default function AnalyticsPage() { - {/* Key Insights */} + {/* Events. The former "Performance Insights" card was removed: it dispensed generic + advice ("above industry average", "add more compelling calls-to-action") that the + charts above already answer with real numbers. */}
- Performance Insights - Key metrics and recommendations + Events -
-
- -
-
-

Open Rate

-

- {stats?.openRate && stats.openRate > 20 - ? 'Your open rate is above industry average!' - : 'Consider improving subject lines to increase open rates.'} -

-
-
-
-
- -
+
-

Click Rate

-

- {stats?.clickRate && stats.clickRate > 3 - ? 'Great click-through performance!' - : 'Add more compelling calls-to-action to boost clicks.'} -

-
-
-
-
- +

{stats?.totalEvents?.toLocaleString() || '0'}

+

triggered in the last {days} days

-
-

Engagement

-

- {stats?.totalWorkflowsStarted - ? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started` - : 'Set up workflows to automate your email campaigns.'} -

+
+
@@ -500,25 +470,20 @@ export default function AnalyticsPage() { - Event Activity - Custom events and triggers + Workflow runs
-

{stats?.totalEvents?.toLocaleString() || '0'}

-

Total Events

+

+ {stats?.totalWorkflowsStarted?.toLocaleString() || '0'} +

+

started in the last {days} days

- +
-
-

- Events triggered by your contacts over the last {days} days. These can trigger workflows and - automations. -

-
@@ -527,7 +492,7 @@ export default function AnalyticsPage() { {topCampaigns && topCampaigns.length > 0 && ( - Campaign Performance + Campaign performance Top performing campaigns in the last {days} days @@ -539,8 +504,8 @@ export default function AnalyticsPage() {
- - + + @@ -594,7 +559,7 @@ export default function AnalyticsPage() { {topEvents && topEvents.length > 0 && ( - Top Events + Top events Most frequently triggered events in the last {days} days diff --git a/apps/web/src/pages/auth/reset-password.tsx b/apps/web/src/pages/auth/reset-password.tsx index 3f0e5156b..5cce8b453 100644 --- a/apps/web/src/pages/auth/reset-password.tsx +++ b/apps/web/src/pages/auth/reset-password.tsx @@ -76,7 +76,7 @@ export default function ResetPassword() { }, 2000); } else { setStatus('error'); - setErrorMessage('Failed to reset password. The link may be invalid or expired.'); + setErrorMessage('Couldn’t reset your password. The link may have expired — request a new one.'); } } catch (error) { setStatus('error'); @@ -87,7 +87,7 @@ export default function ResetPassword() { if (!token) { return ( <> - +
@@ -119,7 +119,7 @@ export default function ResetPassword() { return ( <> - +
diff --git a/apps/web/src/pages/auth/signup.tsx b/apps/web/src/pages/auth/signup.tsx index 323625bcb..a16678f8a 100644 --- a/apps/web/src/pages/auth/signup.tsx +++ b/apps/web/src/pages/auth/signup.tsx @@ -78,7 +78,7 @@ export default function Signup() { return ( <> - +
- +
@@ -178,7 +178,7 @@ export default function VerifyEmail() {
-

Verifying...

+

Verifying…

Please wait a moment.

@@ -259,7 +259,7 @@ export default function VerifyEmail() {

- Make changes to your campaign before sending + Not sent yet. Edit freely.

@@ -390,7 +390,7 @@ export default function CampaignDetailsPage() { className="flex-1 sm:flex-none" > - {isSubmitting ? 'Saving...' : 'Save'} + {isSubmitting ? 'Saving…' : 'Save'} Save @@ -406,7 +406,7 @@ export default function CampaignDetailsPage() {
- Send Test Email + Send test email Preview in your inbox before sending @@ -417,7 +417,7 @@ export default function CampaignDetailsPage() {
- Send Now + Send now Send immediately to all recipients @@ -428,7 +428,7 @@ export default function CampaignDetailsPage() {
- Schedule for Later + Schedule for later Choose a specific date and time
@@ -483,7 +483,7 @@ export default function CampaignDetailsPage() { /> @@ -558,7 +558,7 @@ export default function CampaignDetailsPage() { {/* Basic Information */} - Basic Information + Basic information Name and describe your campaign @@ -579,7 +579,7 @@ export default function CampaignDetailsPage() { setEditedCampaign({...editedCampaign, description: e.target.value})} /> @@ -590,7 +590,7 @@ export default function CampaignDetailsPage() { {/* Campaign Type */} - Campaign Type + Campaign type Choose how this campaign should be treated @@ -644,7 +644,7 @@ export default function CampaignDetailsPage() { {/* Email Settings */} - Email Settings + Email settings Configure sender information and subject @@ -676,7 +676,7 @@ export default function CampaignDetailsPage() { {/* Email Content */} - Email Content + Email content Design your email message @@ -759,7 +759,7 @@ export default function CampaignDetailsPage() { disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress} > - {dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send preview'} + {dialog.type === 'testEmail' && dialog.sending ? 'Sending…' : 'Send preview'} @@ -955,9 +955,9 @@ export default function CampaignDetailsPage() { open={dialog.type === 'delete'} onOpenChange={open => !open && setDialog({type: 'none'})} onConfirm={handleDelete} - title="Delete Campaign" - description="Are you sure you want to delete this draft campaign? This action cannot be undone." - confirmText="Delete Campaign" + title={`Delete ${c.name}?`} + description="This draft and its content are gone for good." + confirmText="Delete campaign" variant="destructive" /> @@ -989,7 +989,7 @@ export default function CampaignDetailsPage() {
@@ -1021,7 +1021,7 @@ export default function CampaignDetailsPage() { style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}} />
-

This page updates automatically every 5 seconds

+

Updating every 5 seconds

@@ -1032,7 +1032,7 @@ export default function CampaignDetailsPage() {
- Total Recipients + Total recipients @@ -1045,7 +1045,7 @@ export default function CampaignDetailsPage() { - Delivery Rate + Delivery rate @@ -1059,7 +1059,7 @@ export default function CampaignDetailsPage() { - Open Rate + Open rate @@ -1070,7 +1070,7 @@ export default function CampaignDetailsPage() { - Click Rate + Click rate @@ -1086,7 +1086,7 @@ export default function CampaignDetailsPage() { {/* Email Content - Takes 2 columns */} - Email Preview + Email preview How your email will appear to recipients @@ -1114,7 +1114,7 @@ export default function CampaignDetailsPage() { {/* Email Body Preview */}
-

Message Content

+

Message content

@@ -1127,7 +1127,7 @@ export default function CampaignDetailsPage() { {/* Campaign Details */} - Campaign Info + Campaign info Configuration and metadata @@ -1156,7 +1156,7 @@ export default function CampaignDetailsPage() { {/* Scheduling Info */} {c.scheduledFor && (
-

Scheduled For

+

Scheduled for

@@ -1179,7 +1179,7 @@ export default function CampaignDetailsPage() { {/* Sent At */} {c.sentAt && (
-

Sent On

+

Sent on

{formatFullDateTime(new Date(c.sentAt))}

@@ -1195,9 +1195,10 @@ export default function CampaignDetailsPage() { open={dialog.type === 'cancel'} onOpenChange={open => !open && setDialog({type: 'none'})} onConfirm={handleCancel} - title="Cancel Campaign" - description="Are you sure you want to cancel this campaign?" - confirmText="Cancel Campaign" + title="Cancel this campaign?" + description="Sending stops now. Contacts who already received it keep their copy." + cancelText="Keep sending" + confirmText="Cancel campaign" variant="destructive" /> diff --git a/apps/web/src/pages/campaigns/create.tsx b/apps/web/src/pages/campaigns/create.tsx index 3e7bfabf5..84580bb9c 100644 --- a/apps/web/src/pages/campaigns/create.tsx +++ b/apps/web/src/pages/campaigns/create.tsx @@ -74,9 +74,9 @@ export default function CreateCampaignPage() { if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName); if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo); setBody(template.body); - toast.success('Template loaded successfully'); + toast.success('Template loaded'); } catch { - toast.error('Failed to load template'); + toast.error('Couldn’t load that template. Try again.'); } finally { setLoadingTemplate(false); } @@ -94,9 +94,9 @@ export default function CreateCampaignPage() { } if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId); setBody(campaign.data.body); - toast.success('Campaign loaded successfully'); + toast.success('Campaign loaded'); } catch { - toast.error('Failed to load campaign'); + toast.error('Couldn’t load that campaign. Try again.'); } finally { setLoadingTemplate(false); } @@ -144,10 +144,10 @@ export default function CreateCampaignPage() { audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined, } as any); - toast.success('Campaign created successfully'); + toast.success('Campaign created'); void router.push(`/campaigns/${response.data.id}`); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to create campaign'); + toast.error(error instanceof Error ? error.message : 'Couldn’t create the campaign. Try again.'); setSaving(false); } }; @@ -164,7 +164,7 @@ export default function CreateCampaignPage() { return ( <> - + {loadingTemplate && (
@@ -181,9 +181,9 @@ export default function CreateCampaignPage() {
-

Create Campaign

+

Create campaign

- Create a new email campaign to send to your contacts + A one-off email sent to a list of contacts.

@@ -195,8 +195,7 @@ export default function CreateCampaignPage() { {/* Basic Information */} - Basic Information - Name and describe your campaign + Basic information
@@ -216,7 +215,7 @@ export default function CreateCampaignPage() { setDescription(e.target.value)} /> @@ -227,8 +226,8 @@ export default function CreateCampaignPage() { {/* Campaign Type */} - Campaign Type - Choose how this campaign should be treated + Campaign type + Transactional campaigns skip the subscription check and the footer.
@@ -280,8 +279,7 @@ export default function CreateCampaignPage() { {/* Email Settings */} - Email Settings - Configure sender information and subject + Email settings - Email Content - Design your email message + Email content Audience - Choose who will receive this campaign
@@ -351,7 +347,7 @@ export default function CreateCampaignPage() { /> @@ -402,7 +398,7 @@ export default function CreateCampaignPage() { Cancel
diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx index 91ec7cfd7..a2eec4c99 100644 --- a/apps/web/src/pages/campaigns/index.tsx +++ b/apps/web/src/pages/campaigns/index.tsx @@ -155,10 +155,10 @@ export default function CampaignsPage() { try { await network.fetch('POST', `/campaigns/${campaignToCancel}/cancel`); - toast.success('Campaign cancelled successfully'); + toast.success('Campaign canceled'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign'); + toast.error(error instanceof Error ? error.message : 'Couldn’t cancel the campaign. Try again.'); } finally { setCampaignToCancel(null); } @@ -167,10 +167,10 @@ export default function CampaignsPage() { const handleDuplicate = async (campaignId: string) => { try { await network.fetch('POST', `/campaigns/${campaignId}/duplicate`); - toast.success('Campaign duplicated successfully'); + toast.success('Campaign duplicated'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to duplicate campaign'); + toast.error(error instanceof Error ? error.message : 'Couldn’t duplicate the campaign. Try again.'); } }; @@ -179,10 +179,10 @@ export default function CampaignsPage() { try { await network.fetch('DELETE', `/campaigns/${campaignToDelete}`); - toast.success('Campaign deleted successfully'); + toast.success('Campaign deleted'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete campaign'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the campaign. Try again.'); } finally { setCampaignToDelete(null); } @@ -207,7 +207,7 @@ export default function CampaignsPage() { setRowSelection({}); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete campaigns'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete those campaigns. Try again.'); } finally { // ConfirmDialog closes itself after onConfirm resolves. setBulkDeleteStatus('idle'); @@ -557,14 +557,14 @@ export default function CampaignsPage() {

Campaigns

- Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''} + One-off emails sent to a list of contacts. {data?.total ? `${data.total} total` : ''}

@@ -574,7 +574,7 @@ export default function CampaignsPage() {
- Empty Campaign + Empty campaign Start from scratch with a blank canvas @@ -585,7 +585,7 @@ export default function CampaignsPage() {
- From Template + From template Use an existing template as a starting point @@ -596,7 +596,7 @@ export default function CampaignsPage() {
- From Previous Campaign + From previous campaign Copy content and settings from an existing campaign @@ -620,7 +620,7 @@ export default function CampaignsPage() { setSearchInput(e.target.value)} className="pl-10 pr-10 h-8 text-xs" @@ -716,7 +716,7 @@ export default function CampaignsPage() {
- Empty Campaign + Empty campaign Start from scratch with a blank canvas @@ -727,7 +727,7 @@ export default function CampaignsPage() {
- From Template + From template Use an existing template as a starting point @@ -738,7 +738,7 @@ export default function CampaignsPage() {
- From Previous Campaign + From previous campaign Copy content and settings from an existing campaign @@ -947,9 +947,10 @@ export default function CampaignsPage() { open={showCancelDialog} onOpenChange={setShowCancelDialog} onConfirm={handleCancel} - title="Cancel Campaign" - description="Are you sure you want to cancel this campaign?" - confirmText="Cancel Campaign" + title="Cancel this campaign?" + description="Sending stops now. Contacts who already received it keep their copy." + cancelText="Keep sending" + confirmText="Cancel campaign" variant="destructive" /> @@ -957,9 +958,9 @@ export default function CampaignsPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Campaign" - description="Are you sure you want to delete this draft campaign? This action cannot be undone." - confirmText="Delete Campaign" + title="Delete this draft?" + description="The draft and its content are gone for good." + confirmText="Delete campaign" variant="destructive" /> @@ -967,9 +968,9 @@ export default function CampaignsPage() { open={showBulkDeleteDialog} onOpenChange={setShowBulkDeleteDialog} onConfirm={handleBulkDelete} - title={`Delete ${selectedIds.length} campaign${selectedIds.length === 1 ? '' : 's'}`} - description="Are you sure you want to delete the selected campaigns? This action cannot be undone. Only draft campaigns can be deleted — selecting a non-draft campaign will block the operation." - confirmText="Delete" + title={`Delete ${selectedIds.length} campaign${selectedIds.length === 1 ? '' : 's'}?`} + description="Only drafts can be deleted. If your selection includes a sent or scheduled campaign, nothing will be deleted." + confirmText="Delete campaigns" variant="destructive" status={bulkDeleteStatus} /> diff --git a/apps/web/src/pages/contacts/[id].tsx b/apps/web/src/pages/contacts/[id].tsx index 799d5e8e8..42285250a 100644 --- a/apps/web/src/pages/contacts/[id].tsx +++ b/apps/web/src/pages/contacts/[id].tsx @@ -57,10 +57,10 @@ export default function ContactDetailPage() { }, typeof ContactSchemas.create >('PATCH', `/contacts/${id}`, {email, subscribed, data: customData}); - toast.success('Contact updated successfully'); + toast.success('Contact updated'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to update contact'); + toast.error(error instanceof Error ? error.message : 'Couldn’t save the contact. Try again.'); } finally { setIsSubmitting(false); } @@ -69,10 +69,10 @@ export default function ContactDetailPage() { const handleDelete = async () => { try { await network.fetch('DELETE', `/contacts/${id}`); - toast.success('Contact deleted successfully'); + toast.success('Contact deleted'); void router.push('/contacts'); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete contact'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the contact. Try again.'); } }; @@ -83,7 +83,7 @@ export default function ContactDetailPage() { setTimeout(() => setCopiedId(null), 2000); toast.success(`${label} copied to clipboard`); } catch { - toast.error('Failed to copy'); + toast.error('Couldn’t copy to your clipboard.'); } }; @@ -108,7 +108,7 @@ export default function ContactDetailPage() {
@@ -142,7 +142,7 @@ export default function ContactDetailPage() {
@@ -151,12 +151,12 @@ export default function ContactDetailPage() {
- Contact Information + Contact information
- + - {isSubmitting ? 'Saving...' : 'Save Changes'} + {isSubmitting ? 'Saving…' : 'Save Changes'} @@ -266,7 +266,7 @@ export default function ContactDetailPage() {
-

Last Updated

+

Last updated

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

@@ -280,11 +280,11 @@ export default function ContactDetailPage() { {/* Public Links Card */} - Public Links + Public links
-

Subscribe Page

+

Subscribe page

@@ -501,7 +501,7 @@ export default function ContactsPage() { setSearchInput(e.target.value)} className="pl-10 pr-10 h-8 text-xs" @@ -608,7 +608,7 @@ export default function ContactsPage() { action={ } /> @@ -794,9 +794,9 @@ export default function ContactsPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Contact" - description="Are you sure you want to delete this contact? This action cannot be undone." - confirmText="Delete" + title="Delete this contact?" + description="Their events and email history are deleted too. This can't be undone." + confirmText="Delete contact" variant="destructive" /> @@ -833,7 +833,7 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo if (response._meta?.isUpdate) { toast.success(`Contact ${response.email} already existed and was updated with new data`); } else { - toast.success('Contact created successfully'); + toast.success('Contact created'); } setEmail(''); @@ -842,7 +842,7 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo onOpenChange(false); onSuccess(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to save contact'); + toast.error(error instanceof Error ? error.message : 'Couldn’t save the contact. Try again.'); } finally { setIsSubmitting(false); } @@ -852,11 +852,13 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo - Create New Contact + Create new contact
- + @@ -1026,7 +1028,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia pollIntervalRef.current = null; } setStatus('failed'); - toast.error('Failed to check import status'); + toast.error('Lost track of the import. Reopen this dialog to check on it.'); } }; @@ -1053,7 +1055,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia void pollJobStatus(data.jobId); }, 1000); // Poll every second } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Failed to upload file'; + const errorMsg = error instanceof Error ? error.message : 'Couldn’t upload the file. Try again.'; setErrorMessage(errorMsg); toast.error(errorMsg); setStatus('failed'); @@ -1079,7 +1081,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia - Import Contacts from CSV + Import contacts from CSV
@@ -1091,7 +1093,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia {/* File Upload */} {status === 'idle' || status === 'failed' ? (
- +
- {status === 'uploading' ? 'Uploading file...' : 'Processing contacts...'} + {status === 'uploading' ? 'Uploading file…' : 'Processing contacts…'} {progress}%
@@ -1183,7 +1185,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia Cancel ) : status === 'completed' ? ( @@ -1203,9 +1205,10 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog} onConfirm={confirmClose} - title="Close Import" - description="Import is still in progress. Are you sure you want to close?" - confirmText="Close Anyway" + title="Close while the import is running?" + description="The import keeps running in the background. You just won't see the result here." + cancelText="Keep watching" + confirmText="Close anyway" variant="destructive" /> @@ -1308,7 +1311,7 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount pollIntervalRef.current = null; } setStatus('failed'); - toast.error('Failed to check operation status'); + toast.error('Lost track of the job. It may still be running.'); } }; @@ -1333,7 +1336,7 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount void pollJobStatus(data.jobId); }, 1000); } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Failed to start operation'; + const errorMsg = error instanceof Error ? error.message : 'Couldn’t start the job. Try again.'; setErrorMessage(errorMsg); toast.error(errorMsg); setStatus('failed'); @@ -1491,7 +1494,8 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount onOpenChange={setShowCloseConfirmDialog} onConfirm={confirmClose} title="Hide this dialog?" - description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated." + description="The job keeps running and your contacts are still updated. You just won't see the result here." + cancelText="Keep watching" confirmText="Hide" variant="default" /> diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index be01ebec2..9772eda93 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -244,7 +244,7 @@ function CompactActivityRow({activity}: {activity: Activity}) { const DISABLED_COPY: Record = { CARD_VERIFICATION_FAILED: { detail: - 'Your card completed the initial payment but declined the follow-up charge we use to confirm it can be billed automatically each month. This is common with prepaid, virtual, and single-use cards. Add a card that supports recurring payments to continue.', + 'Add a card that supports recurring payments to continue. Yours took the first payment but declined the follow-up charge that confirms monthly billing works, which is common with prepaid, virtual, and single-use cards.', href: '/settings?tab=billing', }, PAYMENT_FAILED: { @@ -350,19 +350,19 @@ export default function Index() { const stats = [ { - name: 'Total Contacts', + name: 'Total contacts', value: totalContacts, icon: Users, format: (n: number) => n.toLocaleString(), }, { - name: 'Emails Sent', + name: 'Emails sent', value: totalEmailsSent, icon: Mail, format: (n: number) => n.toLocaleString(), }, { - name: 'Open Rate', + name: 'Open rate', value: openRate, icon: TrendingUp, format: (n: number) => `${n.toFixed(1)}%`, @@ -403,10 +403,10 @@ export default function Index() { if (response.success) { setResendMessage('Verification email sent! Please check your inbox.'); } else { - setResendMessage('Failed to send verification email. Please try again.'); + setResendMessage('Couldn’t send the verification email. Try again in a moment.'); } } catch { - setResendMessage('Failed to send verification email. Please try again.'); + setResendMessage('Couldn’t send the verification email. Try again in a moment.'); } finally { setIsResending(false); } @@ -443,7 +443,7 @@ export default function Index() { DISABLED_COPY_FALLBACK.href } > - View Details + View details @@ -466,7 +466,7 @@ export default function Index() { onClick={handleResendVerification} disabled={isResending} > - {isResending ? 'Sending...' : 'Resend verification email'} + {isResending ? 'Sending…' : 'Resend verification email'} {resendMessage && (

@@ -496,7 +496,7 @@ export default function Index() { Your emails currently include Plunk branding. Upgrade to a subscription to remove it. @@ -520,8 +520,8 @@ export default function Index() {

{stats.map((stat, index) => { const Icon = stat.icon; - const isEmails = stat.name === 'Emails Sent'; - const isOpenRate = stat.name === 'Open Rate'; + const isEmails = stat.name === 'Emails sent'; + const isOpenRate = stat.name === 'Open rate'; return ( - API Keys + API keys Use these keys to integrate with Plunk {activeProject ? (
- +
@@ -102,9 +102,9 @@ export default function CreateProject() { name="name" render={({field}) => ( - Project Name + Project name - + You can change this later @@ -136,7 +136,7 @@ export default function CreateProject() { ) : ( <> - Create Project + Create project )} @@ -163,8 +163,7 @@ export default function CreateProject() {

- Contact your project administrator and ask them to add you as a member. They can invite you from - the project settings page. + Ask a project admin to invite you from Settings → Team.

diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx index c315447bf..42d37def6 100644 --- a/apps/web/src/pages/segments/[id].tsx +++ b/apps/web/src/pages/segments/[id].tsx @@ -97,10 +97,10 @@ export default function SegmentDetailPage() { ...(segment?.type !== 'STATIC' && {condition}), trackMembership, }); - toast.success('Segment updated successfully'); + toast.success('Segment updated'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to update segment'); + toast.error(error instanceof Error ? error.message : 'Couldn’t save the segment. Try again.'); } finally { setIsSubmitting(false); } @@ -121,7 +121,7 @@ export default function SegmentDetailPage() { toast.success(`Membership updated: ${result.added} added, ${result.removed} removed, ${result.total} total`); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to compute membership'); + toast.error(error instanceof Error ? error.message : 'Couldn’t recompute membership. Try again.'); } finally { setIsComputing(false); } @@ -144,7 +144,7 @@ export default function SegmentDetailPage() { void mutate(); void mutateContacts(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to add contacts'); + toast.error(error instanceof Error ? error.message : 'Couldn’t add those contacts. Try again.'); } finally { setIsAddingMembers(false); } @@ -160,7 +160,7 @@ export default function SegmentDetailPage() { void mutate(); void mutateContacts(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to remove contact'); + toast.error(error instanceof Error ? error.message : 'Couldn’t remove the contact. Try again.'); } finally { setRemovingEmail(null); } @@ -169,10 +169,10 @@ export default function SegmentDetailPage() { const handleDelete = async () => { try { await network.fetch('DELETE', `/segments/${id}`); - toast.success('Segment deleted successfully'); + toast.success('Segment deleted'); void router.push('/segments'); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete segment'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the segment. Try again.'); } }; @@ -197,7 +197,7 @@ export default function SegmentDetailPage() {
@@ -229,7 +229,7 @@ export default function SegmentDetailPage() {
@@ -240,12 +240,14 @@ export default function SegmentDetailPage() { {/* Basic Info */} - Segment Details + Segment details Update segment name and description
- + - Filter Conditions + Filter conditions Build complex audience filters with AND/OR logic @@ -306,7 +308,7 @@ export default function SegmentDetailPage() {
@@ -315,7 +317,7 @@ export default function SegmentDetailPage() { {isStatic && ( - Add Members + Add members Search and select contacts, or paste a list of emails @@ -324,7 +326,7 @@ export default function SegmentDetailPage() { onChange={setPickedEmails} onAdd={handleAddMembers} existing={contactsData?.data.map(c => c.email) ?? []} - placeholder="Search contacts to add..." + placeholder="Search contacts to add…" /> {pickedEmails.length > 0 && ( )} @@ -353,7 +355,7 @@ export default function SegmentDetailPage() { {!isStatic && trackMembership && ( )}
@@ -497,7 +499,7 @@ export default function SegmentDetailPage() {
-

Last Updated

+

Last updated

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

@@ -515,9 +517,9 @@ export default function SegmentDetailPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Segment" - description="Are you sure you want to delete this segment? This action cannot be undone." - confirmText="Delete" + title={`Delete ${segment.name}?`} + description="The contacts in it are kept. Only the segment is deleted." + confirmText="Delete segment" variant="destructive" /> diff --git a/apps/web/src/pages/segments/index.tsx b/apps/web/src/pages/segments/index.tsx index b182dd849..e739dd643 100644 --- a/apps/web/src/pages/segments/index.tsx +++ b/apps/web/src/pages/segments/index.tsx @@ -68,10 +68,10 @@ export default function SegmentsPage() { try { await network.fetch('DELETE', `/segments/${segmentToDelete}`); - toast.success('Segment deleted successfully'); + toast.success('Segment deleted'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete segment'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the segment. Try again.'); } finally { setSegmentToDelete(null); } @@ -87,13 +87,13 @@ export default function SegmentsPage() {

Segments

- Create dynamic audience groups based on contact attributes and behaviors + Groups of contacts, defined by filters or picked by hand.

@@ -104,7 +104,7 @@ export default function SegmentsPage() { setSearchInput(e.target.value)} className="pl-10 pr-10" @@ -148,7 +148,7 @@ export default function SegmentsPage() { ) : undefined @@ -229,9 +229,9 @@ export default function SegmentsPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Segment" - description="Are you sure you want to delete this segment? This action cannot be undone." - confirmText="Delete" + title="Delete this segment?" + description="The contacts in it are kept. Only the segment is deleted." + confirmText="Delete segment" variant="destructive" /> diff --git a/apps/web/src/pages/segments/new.tsx b/apps/web/src/pages/segments/new.tsx index 6404e3ed8..c1cc81d7e 100644 --- a/apps/web/src/pages/segments/new.tsx +++ b/apps/web/src/pages/segments/new.tsx @@ -62,12 +62,12 @@ export default function NewSegmentPage() { toast.warning('Segment created, but some contacts could not be added'); } } else { - toast.success('Segment created successfully'); + toast.success('Segment created'); } void router.push('/segments'); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to create segment'); + toast.error(error instanceof Error ? error.message : 'Couldn’t create the segment. Try again.'); } finally { setIsSubmitting(false); } @@ -75,7 +75,7 @@ export default function NewSegmentPage() { return ( <> - +
{/* Header */} @@ -84,7 +84,7 @@ export default function NewSegmentPage() {
-

Create Segment

+

Create segment

{segmentType === 'DYNAMIC' ? 'Build complex audience filters with AND/OR logic' @@ -123,12 +123,14 @@ export default function NewSegmentPage() { {/* Basic Info */} - Segment Details + Segment details Give your segment a name and description

- + - Filter Conditions + Filter conditions Build complex audience filters with AND/OR logic @@ -186,7 +188,7 @@ export default function NewSegmentPage() { ) : ( - Initial Members + Initial members Optionally add contacts now — you can always add or remove members later @@ -196,7 +198,7 @@ export default function NewSegmentPage() { selected={selectedContacts} onChange={setSelectedContacts} onAdd={async (emails, _subscribed) => setSelectedContacts(prev => [...new Set([...prev, ...emails])])} - placeholder="Search and select contacts..." + placeholder="Search and select contacts…" /> @@ -209,7 +211,7 @@ export default function NewSegmentPage() {
diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index aec542951..da94b7f9c 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -146,7 +146,7 @@ export default function Settings() { if (router.query.success === 'true') { // Use setTimeout to defer state update, avoiding synchronous setState in effect const timer = setTimeout(() => { - setSuccessMessage('Subscription activated successfully! It may take a moment to update.'); + setSuccessMessage('Subscription active. Billing details may take a minute to appear.'); // Clear message and URL after 5 seconds setTimeout(() => { setSuccessMessage(null); @@ -207,12 +207,12 @@ export default function Settings() { // Refresh projects list await projectsMutate(); - setSuccessMessage('Project settings updated successfully'); + setSuccessMessage('Project settings updated'); // Clear success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to update project settings'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t save your project settings. Try again.'); } }; @@ -234,13 +234,13 @@ export default function Settings() { // Refresh projects list await projectsMutate(); - setSuccessMessage('API keys regenerated successfully'); + setSuccessMessage('API keys regenerated'); setDialog({type: 'none'}); // Clear success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate API keys'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t regenerate your API keys. Try again.'); setDialog({type: 'none'}); } }; @@ -273,7 +273,7 @@ export default function Settings() { window.location.href = response.url; } } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to start checkout'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t open checkout. Try again in a moment.'); setIsLoadingBilling(false); } }; @@ -299,7 +299,7 @@ export default function Settings() { window.location.href = response.url; } } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to open billing portal'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t open the billing portal. Try again in a moment.'); setIsLoadingBilling(false); } }; @@ -313,7 +313,7 @@ export default function Settings() { await network.fetch('POST', `/users/@me/projects/${activeProject.id}/reset`); - setSuccessMessage('Project reset successfully. All data has been cleared.'); + setSuccessMessage('Project reset. All campaigns, contacts, workflows, templates, and events are gone.'); setDialog({type: 'none'}); setResetConfirmText(''); @@ -322,7 +322,7 @@ export default function Settings() { window.location.reload(); }, 1500); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to reset project'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t reset the project. Try again.'); setDialog({type: 'none'}); setResetConfirmText(''); } @@ -337,7 +337,7 @@ export default function Settings() { await network.fetch('DELETE', `/users/@me/projects/${activeProject.id}`); - setSuccessMessage('Project deleted successfully. Redirecting...'); + setSuccessMessage('Project deleted. Redirecting…'); setDialog({type: 'none'}); setDeleteConfirmText(''); @@ -349,7 +349,7 @@ export default function Settings() { window.location.href = '/'; }, 1500); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Failed to delete project'); + setErrorMessage(error instanceof Error ? error.message : 'Couldn’t delete the project. Try again.'); setDialog({type: 'none'}); setDeleteConfirmText(''); } @@ -376,7 +376,7 @@ export default function Settings() { {/* Header */}

Settings

-

Manage your project settings and preferences

+

Settings for this project.

{/* Tabs */} @@ -398,8 +398,7 @@ export default function Settings() {
- Project Settings - Update your project name and basic information + Project settings
@@ -409,9 +408,9 @@ export default function Settings() { name="name" render={({field}) => ( - Project Name + Project name - + @@ -425,7 +424,7 @@ export default function Settings() { name="tracking" render={({field}) => ( - Email Tracking + Email tracking @@ -518,7 +517,7 @@ export default function Settings() {
@@ -531,22 +530,22 @@ export default function Settings() {
- API Credentials + API keys Use these keys to integrate with the Plunk API
- Danger Zone + Danger zone Irreversible actions that affect your project data @@ -576,11 +575,10 @@ export default function Settings() {
-

Reset Project Data

+

Reset project data

- Clear all campaigns, contacts, workflows, templates, and events. This gives you a blank - project to start fresh. + Clear all campaigns, contacts, workflows, templates, and events.

Preserved: @@ -588,7 +586,7 @@ export default function Settings() {
@@ -597,7 +595,7 @@ export default function Settings() {
-

Delete Project Permanently

+

Delete project

Permanently delete this project and all associated data. This action{' '} @@ -606,7 +604,7 @@ export default function Settings() {

- Permanent Deletion + Permanent deletion All data will be lost
@@ -617,7 +615,7 @@ export default function Settings() { onClick={() => setDialog({type: 'delete'})} className="shrink-0" > - Delete Project + Delete project
@@ -634,8 +632,7 @@ export default function Settings() { - Billing & Subscription - Manage your subscription and billing information + Billing
@@ -669,7 +666,7 @@ export default function Settings() {
- Active Subscription + Active subscription

Your subscription is active. Manage your billing details, update payment methods, or @@ -679,7 +676,7 @@ export default function Settings() {

@@ -689,19 +686,23 @@ export default function Settings() {
- No Active Subscription + No active subscription

- Start a subscription and support the development of Plunk. Activation places two charges - of 1.00 on your card, in your billing currency: a one-time onboarding fee, and a second - charge made straight afterwards to confirm your card accepts automatic monthly billing. - Both are credited back to your account in full, so activation costs you nothing. -

-

- Some cards — most often prepaid, virtual, and single-use cards — accept a payment you - approve yourself but refuse recurring charges later. Checking upfront means you find out - now rather than a month into using Plunk. + Activation places two charges of 1.00 on your card, both credited back in full. It costs + you nothing.

+
+ + Why two charges? + +

+ The first is a one-time onboarding fee. The second runs straight afterwards to confirm + your card accepts automatic monthly billing. Prepaid, virtual, and single-use cards + often accept a payment you approve yourself but refuse recurring charges later, so we + check now rather than a month in. +

+
@@ -710,7 +711,7 @@ export default function Settings() { onClick={() => handleStartSubscription(selectedCurrency)} disabled={isLoadingBilling} > - {isLoadingBilling ? 'Loading...' : 'Start Subscription'} + {isLoadingBilling ? 'Loading…' : 'Start Subscription'}
@@ -792,11 +793,11 @@ export default function Settings() { ) : ( - Security Overview + Security overview

- {isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'} + {isLoadingSecurityMetrics ? 'Loading…' : 'Unable to load security metrics'}

@@ -826,15 +827,14 @@ export default function Settings() { - Regenerate API Keys + Regenerate API keys? -

Are you sure you want to regenerate your API keys?

- Current keys will be immediately invalidated. Any integrations using the old keys - will stop working until updated. + Your current keys stop working immediately. Every integration using them fails + until you swap in the new keys.
@@ -844,7 +844,7 @@ export default function Settings() { Cancel @@ -896,7 +896,7 @@ export default function Settings() { disabled={resetConfirmText !== 'RESET'} className="w-full" > - Reset Data + Reset data @@ -954,7 +954,7 @@ export default function Settings() { disabled={deleteConfirmText !== 'DELETE'} className="w-full" > - Delete Forever + Delete forever diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index 25b59a030..27fa66102 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -92,7 +92,7 @@ export default function TemplateEditorPage() { // Silent save - no toast notification void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to save template'); + toast.error(error instanceof Error ? error.message : 'Couldn’t save the template. Try again.'); } finally { setIsSubmitting(false); } @@ -101,10 +101,10 @@ export default function TemplateEditorPage() { const handleDelete = async () => { try { await network.fetch('DELETE', `/templates/${id}`); - toast.success('Template deleted successfully'); + toast.success('Template deleted'); void router.push('/templates'); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete template'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the template. Try again.'); } }; @@ -128,10 +128,10 @@ export default function TemplateEditorPage() {
-

Edit Template

+

Edit template

{isSubmitting - ? 'Saving...' + ? 'Saving…' : hasChanges ? Unsaved changes : 'All changes saved'} @@ -144,19 +144,18 @@ export default function TemplateEditorPage() {

- Basic Information - Name and describe your template + Basic information
- + setEditedTemplate({...editedTemplate, name: e.target.value})} required - placeholder="Welcome Email" + placeholder="e.g., Welcome email" />
@@ -167,7 +166,7 @@ export default function TemplateEditorPage() { type="text" value={editedTemplate.description || ''} onChange={e => setEditedTemplate({...editedTemplate, description: e.target.value})} - placeholder="Sent to new subscribers" + placeholder="e.g., Sent to new subscribers" />
@@ -175,8 +174,8 @@ export default function TemplateEditorPage() { - Template Type - Choose how this template should be treated + Template type + Transactional templates skip the subscription check and the footer.
@@ -228,19 +227,18 @@ export default function TemplateEditorPage() { {/* Email Settings */} - Email Settings - Configure sender information and subject + Email settings
- + setEditedTemplate({...editedTemplate, subject: e.target.value})} required - placeholder="Welcome to our platform!" + placeholder="e.g., Welcome to Acme" />

Use {'{{variableName}}'} for dynamic content

@@ -260,8 +258,7 @@ export default function TemplateEditorPage() { {/* Email Body */} - Email Body - Create your email using the visual editor or paste custom HTML + Email body setShowDeleteDialog(true)} > - Delete Template + Delete template
@@ -297,9 +294,9 @@ export default function TemplateEditorPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Template" - description="Are you sure you want to delete this template? This action cannot be undone." - confirmText="Delete Template" + title={`Delete ${template.name}?`} + description="Campaigns already sent with it are unaffected. This can't be undone." + confirmText="Delete template" variant="destructive" /> diff --git a/apps/web/src/pages/templates/create.tsx b/apps/web/src/pages/templates/create.tsx index 8e3ee64d5..78f10b885 100644 --- a/apps/web/src/pages/templates/create.tsx +++ b/apps/web/src/pages/templates/create.tsx @@ -58,17 +58,17 @@ export default function CreateTemplatePage() { type, }); - toast.success('Template created successfully'); + toast.success('Template created'); void router.push(`/templates/${template.id}`); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to create template'); + toast.error(error instanceof Error ? error.message : 'Couldn’t create the template. Try again.'); setSaving(false); } }; return ( <> - +
{/* Header */} @@ -77,9 +77,9 @@ export default function CreateTemplatePage() {
-

Create Template

+

Create template

- Create a reusable email template for campaigns and workflows + A reusable email for campaigns and workflows.

@@ -89,19 +89,18 @@ export default function CreateTemplatePage() {
- Basic Information - Name and describe your template + Basic information
- + setName(e.target.value)} required - placeholder="Welcome Email" + placeholder="e.g., Welcome email" />
@@ -112,7 +111,7 @@ export default function CreateTemplatePage() { type="text" value={description} onChange={e => setDescription(e.target.value)} - placeholder="Sent to new subscribers" + placeholder="e.g., Sent to new subscribers" />
@@ -120,8 +119,8 @@ export default function CreateTemplatePage() { - Template Type - Choose how this template should be treated + Template type + Transactional templates skip the subscription check and the footer.
@@ -173,19 +172,18 @@ export default function CreateTemplatePage() { {/* Email Settings */} - Email Settings - Configure sender information and subject + Email settings
- + setSubject(e.target.value)} required - placeholder="Welcome to our platform!" + placeholder="e.g., Welcome to Acme" />

Use {'{{variableName}}'} for dynamic content

@@ -205,8 +203,7 @@ export default function CreateTemplatePage() { {/* Email Body */} - Email Body - Create your email using the visual editor or paste custom HTML + Email body @@ -219,7 +216,7 @@ export default function CreateTemplatePage() { Cancel
diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx index a59db796f..82e704c89 100644 --- a/apps/web/src/pages/templates/index.tsx +++ b/apps/web/src/pages/templates/index.tsx @@ -118,10 +118,10 @@ export default function TemplatesPage() { try { await network.fetch('DELETE', `/templates/${templateToDelete}`); - toast.success('Template deleted successfully'); + toast.success('Template deleted'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete template'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the template. Try again.'); } finally { setTemplateToDelete(null); } @@ -130,10 +130,10 @@ export default function TemplatesPage() { const handleDuplicate = async (templateId: string) => { try { await network.fetch('POST', `/templates/${templateId}/duplicate`); - toast.success('Template duplicated successfully'); + toast.success('Template duplicated'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to duplicate template'); + toast.error(error instanceof Error ? error.message : 'Couldn’t duplicate the template. Try again.'); } }; @@ -156,7 +156,7 @@ export default function TemplatesPage() { setRowSelection({}); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete templates'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete those templates. Try again.'); } finally { // ConfirmDialog closes itself after onConfirm resolves. setBulkDeleteStatus('idle'); @@ -355,16 +355,16 @@ export default function TemplatesPage() { {/* Header */}
-

Email Templates

+

Templates

- Create and manage reusable email templates for your campaigns and workflows.{' '} - {data?.total ? `${data.total} total templates` : ''} + Reusable emails for campaigns and workflows.{' '} + {data?.total ? `${data.total} total` : ''}

@@ -383,7 +383,7 @@ export default function TemplatesPage() { setSearchInput(e.target.value)} className="pl-10 pr-10 h-8 text-xs" @@ -469,7 +469,7 @@ export default function TemplatesPage() { } @@ -610,9 +610,9 @@ export default function TemplatesPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Template" - description="Are you sure you want to delete this template? This action cannot be undone." - confirmText="Delete" + title="Delete this template?" + description="Campaigns already sent with it are unaffected. This can't be undone." + confirmText="Delete template" variant="destructive" /> @@ -620,9 +620,9 @@ export default function TemplatesPage() { open={showBulkDeleteDialog} onOpenChange={setShowBulkDeleteDialog} onConfirm={handleBulkDelete} - title={`Delete ${selectedIds.length} template${selectedIds.length === 1 ? '' : 's'}`} - description="Are you sure you want to delete the selected templates? This action cannot be undone. Templates referenced by a workflow step will block the operation." - confirmText="Delete" + title={`Delete ${selectedIds.length} template${selectedIds.length === 1 ? '' : 's'}?`} + description="Campaigns already sent with them are unaffected, and this can't be undone. A template used by a workflow step can't be deleted — if your selection includes one, nothing will be deleted." + confirmText="Delete templates" variant="destructive" status={bulkDeleteStatus} /> diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index fd970f27d..2d4a2735e 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -113,11 +113,11 @@ export default function WorkflowEditorPage() { setDialog({type: 'cancelOne', executionId, cancelling: true}); try { await network.fetch('DELETE', `/workflows/${id}/executions/${executionId}`); - toast.success('Execution cancelled successfully'); + toast.success('Execution canceled'); setDialog({type: 'none'}); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to cancel execution'); + toast.error(error instanceof Error ? error.message : 'Couldn’t cancel the execution. Try again.'); setDialog({type: 'cancelOne', executionId, cancelling: false}); } }; @@ -127,11 +127,11 @@ export default function WorkflowEditorPage() { setDialog(d => (d.type === 'cancelAll' ? {...d, cancelling: true} : d)); try { const result = await network.fetch<{cancelled: number}>('POST', `/workflows/${id}/executions/cancel-all`); - toast.success(`Successfully cancelled ${result.cancelled} execution(s)`); + toast.success(`Canceled ${result.cancelled} execution${result.cancelled === 1 ? '' : 's'}`); setDialog({type: 'none'}); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to cancel executions'); + toast.error(error instanceof Error ? error.message : 'Couldn’t cancel those executions. Try again.'); setDialog(d => (d.type === 'cancelAll' ? {...d, cancelling: false} : d)); } }; @@ -304,10 +304,10 @@ export default function WorkflowEditorPage() { await network.fetch('PATCH', `/workflows/${id}`, { enabled: !workflow.enabled, }); - toast.success(`Workflow ${!workflow.enabled ? 'enabled' : 'disabled'} successfully`); + toast.success(`Workflow ${!workflow.enabled ? 'enabled' : 'disabled'}`); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to toggle workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t change the workflow. Try again.'); } }; @@ -319,21 +319,21 @@ export default function WorkflowEditorPage() { }) => { try { await network.fetch('PATCH', `/workflows/${id}`, data); - toast.success('Workflow updated successfully'); + toast.success('Workflow updated'); void mutate(); setDialog({type: 'none'}); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to update workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t save the workflow. Try again.'); } }; const handleDelete = async () => { try { await network.fetch('DELETE', `/workflows/${id}`); - toast.success('Workflow deleted successfully'); + toast.success('Workflow deleted'); void router.push('/workflows'); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the workflow. Try again.'); } }; @@ -445,19 +445,18 @@ export default function WorkflowEditorPage() {

- This workflow has {activeExecutionsCount} active execution - {activeExecutionsCount !== 1 ? 's' : ''}.{' '} - {!workflow.enabled && 'Even though the workflow is disabled, existing executions will continue. '} - To protect running workflows, you cannot: + {activeExecutionsCount} contact{activeExecutionsCount !== 1 ? 's are' : ' is'} part way + through this workflow + {!workflow.enabled && ', and disabling it does not stop them'}. Until they finish, you can't:

  • Delete steps or transitions
  • -
  • Modify step configurations (email templates, conditions, etc.)
  • -
  • Change the workflow trigger
  • +
  • Change step settings, such as templates or conditions
  • +
  • Change the trigger

- You can still rename steps and adjust their position. To make configuration changes, wait for executions - to complete or cancel them from the Executions tab. + Renaming and repositioning steps still works. To change anything else, wait them out or cancel them from + the Executions tab.

@@ -512,7 +511,7 @@ export default function WorkflowEditorPage() { : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300' }`} > - Workflow Builder + Workflow builder
Sent Opened ClickedOpen RateClick RateOpen rateClick rate
- Current Step + Current step Started @@ -671,29 +670,28 @@ export default function WorkflowEditorPage() { return handleCancelExecution(dialog.executionId); } }} - title="Cancel Execution" + title="Cancel this execution?" description={ dialog.type === 'cancelOne' && executionsData?.executions ? (

- Are you sure you want to cancel the workflow execution for{' '} + Stops this workflow for{' '} {executionsData.executions.find(e => e.id === dialog.executionId)?.contact.email || 'this contact'} - ? + .

- The contact will not receive any remaining emails or actions from this workflow. This action cannot - be undone. + They won't receive any remaining emails or actions from it. This can't be undone.

) : ( - 'Are you sure you want to cancel this execution?' + "The contact won't receive any remaining emails or actions from this workflow. This can't be undone." ) } - confirmText="Cancel Execution" - cancelText="Keep Running" + confirmText="Cancel execution" + cancelText="Keep running" variant="destructive" status={dialog.type === 'cancelOne' && dialog.cancelling ? 'loading' : 'idle'} /> @@ -703,21 +701,20 @@ export default function WorkflowEditorPage() { open={dialog.type === 'cancelAll'} onOpenChange={open => !open && setDialog({type: 'none'})} onConfirm={handleCancelAllExecutions} - title="Cancel All Active Executions" + title="Cancel every active execution?" description={

- Are you sure you want to cancel all {activeExecutionsCount} active execution - {activeExecutionsCount !== 1 ? 's' : ''}? + Stops this workflow for all {activeExecutionsCount} contact + {activeExecutionsCount !== 1 ? 's' : ''} currently in it.

- All contacts currently in this workflow will be stopped and won't receive any remaining emails or - actions. This action cannot be undone. + They won't receive any remaining emails or actions. This can't be undone.

} - confirmText={`Cancel ${activeExecutionsCount} Execution${activeExecutionsCount !== 1 ? 's' : ''}`} - cancelText="Keep Running" + confirmText={`Cancel ${activeExecutionsCount} execution${activeExecutionsCount !== 1 ? 's' : ''}`} + cancelText="Keep running" variant="destructive" status={dialog.type === 'cancelAll' && dialog.cancelling ? 'loading' : 'idle'} /> @@ -727,9 +724,9 @@ export default function WorkflowEditorPage() { open={dialog.type === 'delete'} onOpenChange={open => !open && setDialog({type: 'none'})} onConfirm={handleDelete} - title="Delete Workflow" - description="Are you sure you want to delete this workflow? This action cannot be undone." - confirmText="Delete Workflow" + title={`Delete ${workflow.name}?`} + description="Its steps and execution history are deleted too. This can't be undone." + confirmText="Delete workflow" variant="destructive" /> @@ -796,11 +793,13 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr - Workflow Settings + Workflow settings
- + setName(e.target.value)} required />
@@ -816,7 +815,9 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
- +

When enabled, contacts can enter this workflow multiple times. When disabled, contacts can only enter @@ -884,7 +885,7 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr Cancel

diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 0c6bc00ba..8a994b681 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -137,10 +137,10 @@ export default function WorkflowsPage() { try { await network.fetch('DELETE', `/workflows/${workflowToDelete}`); - toast.success('Workflow deleted successfully'); + toast.success('Workflow deleted'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete the workflow. Try again.'); } finally { setWorkflowToDelete(null); } @@ -149,10 +149,10 @@ export default function WorkflowsPage() { const handleDuplicate = async (workflowId: string) => { try { await network.fetch('POST', `/workflows/${workflowId}/duplicate`); - toast.success('Workflow duplicated successfully'); + toast.success('Workflow duplicated'); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to duplicate workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t duplicate the workflow. Try again.'); } }; @@ -161,10 +161,10 @@ export default function WorkflowsPage() { await network.fetch('PATCH', `/workflows/${workflowId}`, { enabled: !currentlyEnabled, }); - toast.success(`Workflow ${!currentlyEnabled ? 'enabled' : 'disabled'} successfully`); + toast.success(`Workflow ${!currentlyEnabled ? 'enabled' : 'disabled'}`); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to toggle workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t change the workflow. Try again.'); } }; @@ -187,7 +187,7 @@ export default function WorkflowsPage() { setRowSelection({}); void mutate(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete workflows'); + toast.error(error instanceof Error ? error.message : 'Couldn’t delete those workflows. Try again.'); } finally { // ConfirmDialog closes itself after onConfirm resolves. setBulkDeleteStatus('idle'); @@ -434,13 +434,13 @@ export default function WorkflowsPage() {

Workflows

- Automate your email campaigns with powerful workflows.{' '} - {data?.total ? `${data.total} total workflows` : ''} + Automated emails triggered by contact events.{' '} + {data?.total ? `${data.total} total` : ''}

@@ -458,7 +458,7 @@ export default function WorkflowsPage() { setSearchInput(e.target.value)} className="pl-10 pr-10 h-8 text-xs" @@ -543,7 +543,7 @@ export default function WorkflowsPage() { action={ } /> @@ -714,9 +714,9 @@ export default function WorkflowsPage() { open={showDeleteDialog} onOpenChange={setShowDeleteDialog} onConfirm={handleDelete} - title="Delete Workflow" - description="Are you sure you want to delete this workflow? This action cannot be undone." - confirmText="Delete" + title="Delete this workflow?" + description="Its steps and execution history are deleted too. This can't be undone." + confirmText="Delete workflow" variant="destructive" /> @@ -724,9 +724,9 @@ export default function WorkflowsPage() { open={showBulkDeleteDialog} onOpenChange={setShowBulkDeleteDialog} onConfirm={handleBulkDelete} - title={`Delete ${selectedIds.length} workflow${selectedIds.length === 1 ? '' : 's'}`} - description="Are you sure you want to delete the selected workflows? This action cannot be undone. Workflows with active executions will block the operation." - confirmText="Delete" + title={`Delete ${selectedIds.length} workflow${selectedIds.length === 1 ? '' : 's'}?`} + description="Their steps and execution history go too, and this can't be undone. A workflow with active executions can't be deleted — if your selection includes one, nothing will be deleted." + confirmText="Delete workflows" variant="destructive" status={bulkDeleteStatus} /> @@ -767,7 +767,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia enabled: false, }); - toast.success('Workflow created successfully'); + toast.success('Workflow created'); setName(''); setDescription(''); setEventName(''); @@ -778,7 +778,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia // Redirect to the workflow editor window.location.href = `/workflows/${workflow.id}`; } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to create workflow'); + toast.error(error instanceof Error ? error.message : 'Couldn’t create the workflow. Try again.'); } finally { setIsSubmitting(false); } @@ -788,18 +788,20 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia - Create New Workflow + Create new workflow
- + setName(e.target.value)} required - placeholder="Welcome Email Sequence" + placeholder="e.g., Welcome sequence" />
@@ -809,14 +811,16 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia id="description" value={description} onChange={e => setDescription(e.target.value)} - placeholder="Send a series of welcome emails to new subscribers" + placeholder="e.g., Three emails over the first week" className="w-full px-3 py-2 border border-neutral-200 rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" rows={3} />
- + {/* Combobox: 可自由輸入 event name,同時提供已追蹤 event 的下拉建議 */}

When enabled, contacts can enter this workflow multiple times. @@ -907,7 +911,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia Cancel