Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions .impeccable.md

This file was deleted.

74 changes: 74 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 3 additions & 3 deletions apps/web/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -179,7 +179,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
<div className="text-center py-12">
<p className="text-red-600 text-sm">{error}</p>
<Button onClick={() => fetchActivities()} variant="outline" className="mt-4">
Try Again
Try again
</Button>
</div>
);
Expand Down Expand Up @@ -232,7 +232,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
<div className="w-full border-t border-neutral-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-4 text-sm font-medium text-neutral-500">Upcoming Scheduled</span>
<span className="bg-white px-4 text-sm font-medium text-neutral-500">Upcoming scheduled</span>
</div>
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ActivityItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ export const ActivityItem = memo(function ActivityItem({activity, status = 'comp
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-neutral-600 hover:text-neutral-900 transition-colors group">
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium">Event Data</span>
<span className="font-medium">Event data</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 p-3 bg-neutral-50 rounded-md border border-neutral-200 text-xs overflow-x-auto">
Expand Down
102 changes: 44 additions & 58 deletions apps/web/src/components/BillingConsumption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Card>
<CardHeader>
<CardTitle>Usage this month</CardTitle>
{period && <CardDescription>Billing period: {period}</CardDescription>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}

export function BillingConsumption({projectId, hasSubscription}: BillingConsumptionProps) {
const {data: config} = useConfig();
const billingEnabled = config?.features.billing.enabled ?? false;
Expand Down Expand Up @@ -39,58 +52,37 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt

if (!hasSubscription) {
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
Usage tracking is only available with an active subscription. Start a subscription to track your email
consumption.
</p>
</div>
</Alert>
</CardContent>
</Card>
<UsageCard>
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">Usage tracking starts when you start a subscription.</p>
</div>
</Alert>
</UsageCard>
);
}

if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-4">
<IconSpinner size="sm" />
</div>
</CardContent>
</Card>
<UsageCard>
<div className="flex items-center justify-center py-4">
<IconSpinner size="sm" />
</div>
</UsageCard>
);
}

if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">Failed to load consumption data. Please try again later.</p>
</div>
</Alert>
</CardContent>
</Card>
<UsageCard>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">Couldn’t load usage data. Try again in a moment.</p>
</div>
</Alert>
</UsageCard>
);
}

Expand All @@ -99,14 +91,9 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
}

return (
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>
Billing period: {formatDate(consumptionData.period.start)} - {formatDate(consumptionData.period.end)}
</CardDescription>
</CardHeader>
<CardContent>
<UsageCard
period={`${formatDate(consumptionData.period.start)} – ${formatDate(consumptionData.period.end)}`}
>
<div className="space-y-6">
{/* Total Usage */}
<div className="border border-neutral-200 rounded-lg p-6">
Expand All @@ -115,7 +102,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
<TrendingUp className="h-5 w-5 text-neutral-600" />
</div>
<div>
<p className="text-sm text-neutral-500">Total Emails Sent</p>
<p className="text-sm text-neutral-500">Total emails sent</p>
<p className="text-3xl font-bold text-neutral-900">{consumptionData.usage.total.toLocaleString()}</p>
</div>
</div>
Expand All @@ -129,7 +116,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
<Coins className="h-5 w-5 text-neutral-600" />
</div>
<div>
<p className="text-sm text-neutral-500">Account Credits</p>
<p className="text-sm text-neutral-500">Account credits</p>
<p className="text-2xl font-bold text-neutral-900">
{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}
</p>
Expand All @@ -144,10 +131,10 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
{/* Upcoming Invoice */}
{consumptionData.upcomingInvoice && (
<div className="border border-neutral-200 rounded-lg p-6">
<h3 className="font-semibold text-neutral-900 mb-4">Upcoming Invoice</h3>
<h3 className="font-semibold text-neutral-900 mb-4">Upcoming invoice</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-neutral-600">Billing Period</span>
<span className="text-sm text-neutral-600">Billing period</span>
<span className="text-sm font-medium text-neutral-900">
{formatDate(consumptionData.upcomingInvoice.periodStart)} -{' '}
{formatDate(consumptionData.upcomingInvoice.periodEnd)}
Expand All @@ -161,15 +148,15 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
</div>
{consumptionData.credits && consumptionData.credits.hasCredits && (
<div className="flex justify-between items-center">
<span className="text-sm text-green-600">Credits Applied</span>
<span className="text-sm text-green-600">Credits applied</span>
<span className="text-sm font-medium text-green-600">
-{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}
</span>
</div>
)}
<div className="border-t border-neutral-200 pt-3 mt-3">
<div className="flex justify-between items-center">
<span className="text-base font-semibold text-neutral-900">Amount Due</span>
<span className="text-base font-semibold text-neutral-900">Amount due</span>
<span className="text-base font-bold text-neutral-900">
{formatCurrency(
consumptionData.upcomingInvoice.amountDue,
Expand All @@ -192,7 +179,6 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
</Alert>
)}
</div>
</CardContent>
</Card>
</UsageCard>
);
}
Loading
Loading