diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..e1ee9ed --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,20 @@ +language: en-US +early_access: false +reviews: + profile: chill + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + path_filters: + - "!**/pnpm-lock.yaml" + - "!apps/docs/images/**" + - "!**/*.png" + - "!**/*.jpg" + - "!**/*.jpeg" + - "!**/*.webp" + auto_review: + enabled: true + drafts: false +chat: + auto_reply: true diff --git a/.github/workflows/coderabbit.yml b/.github/workflows/coderabbit.yml new file mode 100644 index 0000000..cc70faa --- /dev/null +++ b/.github/workflows/coderabbit.yml @@ -0,0 +1,19 @@ +name: CodeRabbit Review + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Run CodeRabbit review + uses: coderabbitai/coderabbit-action@v2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec..be730cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,4 @@ { - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ] + "editor.formatOnSave": true, + "editor.formatOnPaste": true, } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7298a90..1e307b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.2.6] - 2026-05-10 + +### Added + +#### Marketing Components +- **Modular homepage architecture** — split the landing page into reusable section components (`Hero`, `TrustStrip`, `Features`, `Comparison`, `PricingSection`, `CallToAction`, `DevSection`) for clearer ownership and easier iteration +- **Single pricing calculator flow** — promoted the calculator-led pricing experience and removed older card-based pricing composition in favor of a unified pricing section + +#### Dashboard — Broadcasts +- **Broadcasts section** — introduced a dedicated `/broadcasts` route and page hierarchy (`/broadcasts`, `/broadcasts/[broadcastId]`, `/broadcasts/[broadcastId]/edit`) for one-off email blasts, separating them from the Campaigns flow which is now focused on recurring/automated sequences + +#### Dashboard — Logs +- **Unified logs dashboard** — added a first-class `/logs` dashboard page that merges email events, webhook calls, and notification delivery logs into one searchable audit trail with source and status filtering + +#### Dashboard Settings +- **Settings API Keys page** — added first-class `/settings/api-keys` route +- **Settings SMTP page** — added first-class `/settings/smtp` route + +#### Billing — Custom Plan Contracts +- **Slider-based plan selector** — `BillingPlanSelector` component replaces static plan cards at `/settings/billing` with interactive marketing and transactional email limit sliders (1,000 – 3,000,000 each); calculated contract total updates in real time as limits are adjusted +- **Custom Stripe checkout sessions** — `createCustomCheckoutSession` tRPC mutation creates a Stripe session with `price_data` (unit_amount = exact contract price in CAD) rather than a fixed price ID, locking teams into the exact limit/price combination they selected +- **Custom plan query** — `getCustomPlanContract` tRPC query lets the billing page and `PlanDetails` component surface the active custom contract (limits + monthly price) for teams already on a custom plan +- **Custom plan DB fields** — added four optional fields to the `Team` model (`customPlanEnabled`, `customMarketingEmailLimit`, `customTransactionalEmailLimit`, `customMonthlyPriceCents`); populated at checkout completion via the Stripe webhook + +#### Campaigns +- **Campaign intent field** — campaigns now store an `intent` column (migration `20260510120000_add_campaign_intent`) to distinguish campaign purpose at creation time; `create-campaign` dialog captures intent upfront + +#### SDKs +- **Go SDK initial package** — introduced `packages/go-sdk` with typed client surfaces for emails, contacts, contact books, campaigns, domains, and analytics + +#### Documentation +- **SMTP auth API reference page** — added `apps/docs/api-reference/smtp/auth.mdx` +- **Self-hosting Docker doc relocation** — promoted Docker setup docs to `apps/docs/self-hosting/docker.mdx` and aligned navigation + +#### CI / Review Automation +- **CodeRabbit support** — added `.github/workflows/coderabbit.yml` and root `.coderabbit.yaml` to enable automated PR review summaries and inline feedback on pull requests + +### Changed + +#### Marketing Site +- **Homepage composition refresh** — replaced older section files (`FeatureCard*`, `PricingTiers`, `CodeExample`) with the new component set and updated page assembly +- **Icon system modernization** — replaced hand-authored inline SVG usage in key marketing and app screens with icon components for consistency and maintainability +- **Developer section improvements** — expanded dev-focused section behavior and language-toggle handling in `CodeLangToggle` and `DevSection` + +#### Settings UX & Navigation +- **Developer settings consolidation** — aligned developer tooling pages with the canonical Settings area while preserving compatibility flows from legacy dev-settings routes + +#### Plans, Billing & Limits +- **Pricing/plan constant updates** — refreshed shared Stripe plan/product definitions and app-side plan/payment constants to keep UI, checkout, and limits in sync +- **Billing page rebuilt around custom plan selector** — `/settings/billing` now surfaces the slider-based `BillingPlanSelector` as the primary upgrade path; `PlanDetails` shows active custom contract details (limits and monthly price) when a custom plan is active +- **Custom plan limit enforcement** — `LimitService` now reads `customMarketingEmailLimit` and `customTransactionalEmailLimit` from the `Team` record and enforces per-type monthly caps for custom plan teams, bypassing the standard plan-tier daily limits; the daily usage job skips metered overage billing for custom plan teams to avoid double-charging +- **Admin plan assignment clears custom contract** — `adminAssignPlan` with `method: "complimentary"` now explicitly clears all custom plan fields (`customPlanEnabled`, all limit columns) so stale slider contracts do not persist after a manual admin override +- **Free-plan marketing access switched to limit-based enforcement** — Contacts, Broadcasts, and Campaigns are no longer hard-locked in the dashboard for Free teams; access is now controlled by enforceable plan limits (including campaign count and monthly sending caps) +- **Free plan marketing baseline updated** — FREE plan now includes limited marketing capability (`marketingEmailsIncluded: true`) with a capped campaign allowance (`campaignsLimit: 3`) instead of full marketing exclusion +- **Stripe seed updates** — adjusted `stripe-seed.ts` for current product/price setup behavior + +#### Analytics +- **Analytics detail expansion** — dashboard analytics now includes additional breakdown cards (delivery rate, bounce rate, complaint rate, and total volume) for clearer at-a-glance performance tracking +- **Paid-only advanced analytics insights** — added a paid-tier insights section for open rate, click rate, click-to-open rate, and average daily volume with an upgrade CTA for free teams + +#### Usage Breakdown +- **Full usage limit breakdown** — the `/settings/usage` page now shows a complete limit audit for all tracked resources, including monthly and daily email usage, marketing vs transactional email usage, domains, contact books, contacts, campaigns, team members, and webhooks, so teams can see both current usage and allowed limits in one place + +#### Documentation & API Spec +- **OpenAPI refresh** — regenerated and updated API reference spec and intro content +- **Docs navigation/content refresh** — updated docs navigation and onboarding pages (including Go and self-hosting paths) to match current product structure + +### Fixed + +#### Dashboard & Routing +- **Settings route reliability** — removed dead-end developer settings destinations by wiring pages into canonical Settings routes and maintaining redirect compatibility + +#### Billing & Limits +- **Admin bypass email normalization** — `LimitService.isAdminOrFounderTeam` now normalises the environment admin email with `trim()` + `toLowerCase()` and uses a case-insensitive Prisma query, preventing mismatches caused by casing differences in env config +- **Custom plan Stripe webhook sync** — `syncStripeData` now reads `customPlanEnabled`, `customMarketingEmailLimit`, `customTransactionalEmailLimit`, and `customMonthlyPriceCents` from subscription metadata and persists them back to the `Team` record so contract limits survive server restarts and are always in sync with Stripe + +#### Visual Consistency +- **Cross-page icon sizing/alignment** — normalized icon rendering across footer, auth, error, not-found, and dashboard surfaces after component migration +- **UpgradeModal scroll** — fixed scroll behavior in the upgrade and notification modals so long plan/feature lists are fully accessible without clipping + +--- + ## [0.2.5] - 2026-05-09 ### Added @@ -515,7 +597,8 @@ Initial public beta release of ByteSend — an all-in-one email infrastructure p --- -[Unreleased]: https://github.com/nodebyte/bytesend/compare/v0.2.5...HEAD +[Unreleased]: https://github.com/nodebyte/bytesend/compare/v0.2.6...HEAD +[0.2.6]: https://github.com/nodebyte/bytesend/compare/v0.2.5...v0.2.6 [0.2.5]: https://github.com/nodebyte/bytesend/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/nodebyte/bytesend/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/nodebyte/bytesend/compare/v0.2.2...v0.2.3 diff --git a/apps/docs/api-reference/introduction.mdx b/apps/docs/api-reference/introduction.mdx index 8df9825..cbc4a1b 100644 --- a/apps/docs/api-reference/introduction.mdx +++ b/apps/docs/api-reference/introduction.mdx @@ -21,4 +21,15 @@ Authentication to ByteSend's API is performed via the Authorization header with Authorization: Bearer bs_12345 ``` -You can create a new token/API key under your ByteSend [Developer Settings](https://bytesend.cloud/dev-settings/api-keys). +You can create a new token/API key under your ByteSend [Settings > API Keys](https://bytesend.cloud/settings/api-keys). + +### SMTP Authentication + +The SMTP relay server uses a different authentication method. Instead of Bearer tokens, SMTP clients authenticate using: + +- **Username**: The team's custom SMTP username (or the default `bytesend`) +- **Password**: Your API key + +The SMTP relay calls the [SMTP Auth endpoint](/api-reference/smtp/auth) to validate these credentials. This endpoint is **public** and does not require Bearer token authentication. + +For more details, see the [SMTP relay documentation](/self-hosting/smtp-server). diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 5cd0e54..f1ec8da 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -2679,6 +2679,63 @@ } } } + }, + "/v1/smtp/auth": { + "post": { + "description": "Authenticate SMTP credentials (used by SMTP relay server)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "SMTP username (typically the custom team username or default 'bytesend')" + }, + "password": { + "type": "string", + "description": "API key used as password for SMTP authentication" + } + }, + "required": ["username", "password"] + } + } + } + }, + "responses": { + "200": { + "description": "Credentials are valid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { "type": "boolean", "enum": [true] } + }, + "required": ["valid"] + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { "type": "boolean", "enum": [false] }, + "message": { "type": "string", "description": "Error message explaining why authentication failed" } + }, + "required": ["valid", "message"] + } + } + } + } + } + } } } } diff --git a/apps/docs/api-reference/smtp/auth.mdx b/apps/docs/api-reference/smtp/auth.mdx new file mode 100644 index 0000000..a6a0ef2 --- /dev/null +++ b/apps/docs/api-reference/smtp/auth.mdx @@ -0,0 +1,88 @@ +--- +openapi: post /v1/smtp/auth +--- + +Authenticate SMTP credentials. This endpoint is called by the SMTP relay server to validate client credentials during authentication. + +## Usage + +The SMTP relay server calls this endpoint during the `AUTH` phase to verify that the provided username and API key are valid. The username can be a custom team-specific SMTP username or the global default (`bytesend`). + +## Authentication + +This endpoint **does not require** Bearer token authentication. Instead, it accepts the credentials directly in the request body. + +## Request Body + +- `username` (string, required): The SMTP username. Can be: + - A custom team SMTP username (configured in team settings) + - The global default: `bytesend` + - Or any username if using an API key for a team with remote auth enabled + +- `password` (string, required): The API key to verify. This is used as the password in SMTP `AUTH PLAIN` or `AUTH LOGIN` flows. + +## Response + +**200 Success:** +```json +{ + "valid": true +} +``` + +**401 Unauthorized:** +```json +{ + "valid": false, + "message": "Invalid API key or username" +} +``` + +## Examples + + + +```bash cURL +curl -X POST https://bytesend.cloud/api/v1/smtp/auth \ + -H "Content-Type: application/json" \ + -d '{ + "username": "bytesend", + "password": "YOUR_API_KEY" + }' +``` + +```javascript JavaScript +const response = await fetch('https://bytesend.cloud/api/v1/smtp/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'bytesend', + password: 'YOUR_API_KEY' + }) +}); + +const data = await response.json(); +console.log(data); // { valid: true } or { valid: false, message: "..." } +``` + +```python Python +import requests + +response = requests.post( + 'https://bytesend.cloud/api/v1/smtp/auth', + json={ + 'username': 'bytesend', + 'password': 'YOUR_API_KEY' + } +) + +print(response.json()) # { 'valid': True } or { 'valid': False, 'message': '...' } +``` + + + +## Notes + +- This endpoint is **fire-and-forget** from the perspective of the SMTP relay. Failures are logged but do not block the SMTP handshake. +- The endpoint does not perform rate limiting separate from the standard API rate limits. +- For custom team SMTP usernames, ensure the API key belongs to that team. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 9345988..9f29556 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -1,6 +1,6 @@ { "$schema": "https://mintlify.com/docs.json", - "theme": "mint", + "theme": "maple", "name": "ByteSend", "colors": { "primary": "#0066CC", @@ -31,11 +31,11 @@ "group": "Getting Started", "pages": [ "introduction", - "get-started/smtp", + "get-started/go", "get-started/nodejs", "get-started/python", "get-started/local", - "get-started/set-up-docker" + "get-started/smtp" ] } ] @@ -47,6 +47,7 @@ "group": "Basic Setup", "pages": [ "self-hosting/overview", + "self-hosting/docker", "self-hosting/smtp-server", "self-hosting/certificates", "self-hosting/firewall", @@ -162,6 +163,12 @@ "api-reference/analytics/email-time-series", "api-reference/analytics/reputation-metrics" ] + }, + { + "group": "SMTP", + "pages": [ + "api-reference/smtp/auth" + ] } ] } @@ -175,7 +182,7 @@ "icon": "github" }, { - "anchor": "Community", + "anchor": "Discord", "href": "https://discord.gg/xqkqzVRC4S", "icon": "discord" }, @@ -183,11 +190,6 @@ "anchor": "Twitter", "href": "https://twitter.com/TryByteSend", "icon": "twitter" - }, - { - "anchor": "Dashboard", - "href": "https://bytesend.cloud", - "type": "button" } ] } @@ -216,7 +218,7 @@ ], "primary": { "type": "button", - "label": "Dashboard", + "label": "Website", "href": "https://bytesend.cloud" } }, diff --git a/apps/docs/get-started/go.mdx b/apps/docs/get-started/go.mdx index f7c927e..a6fbe42 100644 --- a/apps/docs/get-started/go.mdx +++ b/apps/docs/get-started/go.mdx @@ -1,51 +1,97 @@ --- title: Go -description: "The ByteSend Go package lets you interact with the ByteSend API to send emails, manage contacts, and work with domains. This guide covers basic setup and usage." +description: "The ByteSend Go SDK lets you interact with the ByteSend API to send emails, manage contacts, domains, and campaigns. This guide covers installation, configuration, and common usage patterns." icon: golang --- ## Prerequisites -- [ByteSend API key](https://bytesend.cloud/dev-settings/api-keys) +- Go 1.21+ +- [ByteSend API key](https://bytesend.cloud/settings/api-keys) - [Verified domain](https://bytesend.cloud/domains) ## Installation Install the ByteSend Go SDK: + ```bash -go get github.com/NodeByteHosting/bytesend-go +go get github.com/nodebyteltd/bytesend-go ``` -## Initialize +## Quick Start -Create a new client using your API key. ```go package main import ( "context" + "fmt" "log" - bytesend "github.com/NodeByteHosting/bytesend-go" + bytesend "github.com/nodebyteltd/bytesend-go" ) func main() { - client, err := bytesend.NewClient("bs_12345") + client, err := bytesend.NewClient("bs_your_api_key") + if err != nil { + log.Fatal(err) + } + + resp, err := client.Emails.Create(context.Background(), bytesend.SendEmailPayload{ + From: "hello@company.com", + To: []string{"user@example.com"}, + Subject: "Welcome!", + HTML: "

Hello!

", + }) if err != nil { log.Fatal(err) } + + fmt.Println("Email sent:", resp.EmailID) } ``` -API keys can also be supplied via the `BYTESEND_API_KEY` environment variable. +## Configuration + +### API Key -### Self-Hosted Setup +The API key can be passed directly or read from the `BYTESEND_API_KEY` environment variable: -If you are running a self-hosted version of ByteSend, provide the base URL using `WithBaseURL`. ```go +// Explicit API key +client, err := bytesend.NewClient("bs_your_api_key") +if err != nil { + log.Fatal(err) +} + +// From environment variable +client, err := bytesend.NewClient("") +if err != nil { + log.Fatal(err) +} +``` + +### Client Options + +Customize the client with options for base URL and HTTP client: + +```go +import ( + "net/http" + "time" + + bytesend "github.com/nodebyteltd/bytesend-go" +) + client, err := bytesend.NewClient( - "bs_12345", - bytesend.WithBaseURL("https://bytesend.cloud"), + "bs_your_api_key", + // Override the base URL (useful for self-hosting or proxying) + bytesend.WithBaseURL("https://your-instance.com/api/v1"), + + // Supply your own HTTP client (custom timeouts, transport, etc.) + bytesend.WithHTTPClient(&http.Client{ + Timeout: 60 * time.Second, + }), ) if err != nil { log.Fatal(err) @@ -53,95 +99,331 @@ if err != nil { ``` -The base URL should be the root domain only. Do not include `/api/v1`, as the SDK manages API paths internally. +The default base URL is `https://bytesend.cloud/api/v1`. For self-hosted deployments, include `/api/v1` in the base URL. The default HTTP client uses a 30-second timeout. All requests include the `User-Agent: bytesend-go` header. -### Options +## Error Handling -- `WithBaseURL(url string)`: Override the base API URL (e.g. for self-hosting) -- `WithHTTPClient(*http.Client)`: Provide a custom HTTP client +All service methods return a single `error`. API errors are returned as `*ErrorResponse`. Use `errors.As` to distinguish API errors from network or encoding errors: - -The default HTTP client uses a 30s timeout. Requests include a `User-Agent: bytesend-go` header. - +```go +import "errors" + +resp, err := client.Emails.Create(ctx, payload) +if err != nil { + var apiErr *bytesend.ErrorResponse + if errors.As(err, &apiErr) { + // API error (validation, auth, rate limit, etc.) + fmt.Printf("API error %s: %s\n", apiErr.Code, apiErr.Message) + } else { + // Network, timeout, or encoding error + log.Fatal(err) + } +} +``` ## Sending Emails + +### Send a basic email + ```go -resp, errResp, err := client.Emails.Send( - context.Background(), - bytesend.SendEmailPayload{ - To: []string{"hello@acme.com"}, - From: "hello@company.com", - Subject: "ByteSend email", - HTML: "

ByteSend is the best way to send emails

", - Text: "ByteSend is the best way to send emails", - Headers: map[string]string{ - "X-Campaign": "welcome", - }, +resp, err := client.Emails.Create(context.Background(), bytesend.SendEmailPayload{ + From: "hello@company.com", + To: []string{"user@example.com"}, + Subject: "Welcome!", + HTML: "

Hello!

", + Text: "Hello!", +}) +if err != nil { + log.Fatal(err) +} +fmt.Println("Email ID:", resp.EmailID) +``` + +### Send with an idempotency key + +Idempotency keys prevent duplicate sends on retries. The same key + same payload returns the original `emailId`. Keys expire after 24 hours. + +```go +resp, err := client.Emails.Create(ctx, payload, "order-12345-key") +if err != nil { + log.Fatal(err) +} +``` + +### Send with a template + +```go +resp, err := client.Emails.Create(ctx, bytesend.SendEmailPayload{ + From: "hello@company.com", + To: []string{"user@example.com"}, + TemplateID: "tmpl_abc123", + Variables: map[string]string{"name": "Jane", "plan": "Pro"}, +}) +``` + +### Send with attachments + +```go +import "os" + +content, err := os.ReadFile("invoice.pdf") +if err != nil { + log.Fatal(err) +} + +resp, err := client.Emails.Create(ctx, bytesend.SendEmailPayload{ + From: "hello@company.com", + To: []string{"user@example.com"}, + Subject: "Your invoice", + HTML: "

Find your invoice attached.

", + Attachments: []bytesend.Attachment{ + {Filename: "invoice.pdf", Content: content}, }, -) +}) +``` +### Batch send (up to 100 emails) + +```go +resp, err := client.Emails.Batch(ctx, []bytesend.SendEmailPayload{ + {From: "hello@company.com", To: []string{"a@example.com"}, Subject: "Hi A", HTML: "

Hi A

"}, + {From: "hello@company.com", To: []string{"b@example.com"}, Subject: "Hi B", HTML: "

Hi B

"}, +}) +// resp.Data contains individual email IDs +``` + +### List emails + +```go +resp, err := client.Emails.List(ctx, bytesend.ListEmailsParams{ + Page: "1", + Limit: "50", + DomainID: "123", +}) if err != nil { log.Fatal(err) } +fmt.Printf("Found %d emails\n", resp.Count) +``` + +### Get a single email with delivery events -if errResp != nil { - log.Fatalf("API error: %s", errResp.Message) +```go +email, err := client.Emails.Get(ctx, "em_abc123") +if err != nil { + log.Fatal(err) } +fmt.Printf("Status: %v\n", email.EmailEvents) +``` + +### Reschedule a scheduled email -log.Printf("Email queued with ID: %s", resp.EmailID) +```go +resp, err := client.Emails.Update(ctx, "em_abc123", bytesend.UpdateEmailPayload{ + ScheduledAt: "2026-06-01T09:00:00Z", +}) ``` - -Custom headers are forwarded as-is. ByteSend only manages the `X-ByteSend-Email-ID` and `References` headers. - +### Cancel a scheduled email -## Managing Contacts +```go +resp, err := client.Emails.Cancel(ctx, "em_abc123") +``` -### Get Contact Book ID +## Managing Contacts -Retrieve the contact book ID from the ByteSend dashboard. +### Create a contact -### Create Contacts ```go -contact, apiErr, err := client.Contacts.Create( - context.Background(), - "contactBook_123", +subscribed := true +resp, err := client.Contacts.Create( + ctx, + "cb_contactbook_id", bytesend.CreateContactPayload{ - Email: "hey@bobdole.com", - FirstName: "Bob", - LastName: "Dole", + Email: "user@example.com", + FirstName: "Jane", + LastName: "Doe", + Subscribed: &subscribed, + Properties: map[string]string{"plan": "pro"}, }, ) - if err != nil { - log.Fatal(err) // transport error + log.Fatal(err) } +fmt.Println("Contact ID:", resp.ContactID) +``` -if apiErr != nil { - log.Fatalf("API error: %s", apiErr.Message) +### List contacts + +```go +resp, err := client.Contacts.List(ctx, "cb_contactbook_id", bytesend.ListContactsParams{ + Page: "1", + Limit: "100", + // Emails: "a@example.com,b@example.com" — filter by email addresses + // IDs: "ct_1,ct_2" — filter by contact IDs +}) +if err != nil { + log.Fatal(err) } +``` + +### Get a contact -log.Printf("Contact ID: %s", contact.ContactID) +```go +contact, err := client.Contacts.Get(ctx, "cb_contactbook_id", "ct_contactid") +if err != nil { + log.Fatal(err) +} +fmt.Printf("%s %s <%s>\n", contact.FirstName, contact.LastName, contact.Email) ``` -### Update Contacts +### Update a contact + ```go -contact, apiErr, err := client.Contacts.Update( - context.Background(), - "contactBook_123", - "contact_456", +resp, err := client.Contacts.Update( + ctx, + "cb_contactbook_id", + "ct_contactid", bytesend.UpdateContactPayload{ - FirstName: "Bob", - LastName: "Dold", + FirstName: "Janet", + Properties: map[string]string{"plan": "enterprise"}, }, ) +``` + +### Bulk create contacts + +```go +payloads := []bytesend.CreateContactPayload{ + {Email: "alice@example.com", FirstName: "Alice"}, + {Email: "bob@example.com", FirstName: "Bob"}, +} + +resp, err := client.Contacts.BulkCreate(ctx, "cb_contactbook_id", payloads) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Created %d contacts\n", resp.Count) +``` + +## Managing Domains + +### List domains + +```go +resp, err := client.Domains.List(ctx) +if err != nil { + log.Fatal(err) +} +for _, domain := range resp { + fmt.Printf("%s — Status: %s\n", domain.Name, domain.Status) +} +``` + +### Create a domain + +```go +resp, err := client.Domains.Create(ctx, bytesend.CreateDomainPayload{ + Name: "email.example.com", + Region: "us-east-1", +}) +``` + +### Verify a domain + +After adding DNS records, verify the domain: + +```go +resp, err := client.Domains.Verify(ctx, 123) +if err != nil { + log.Fatal(err) +} +fmt.Println(resp.Message) +``` + +## Campaigns (Marketing) + +### Create a campaign +```go +resp, err := client.Campaigns.Create(ctx, bytesend.CreateCampaignPayload{ + Name: "Q2 Newsletter", + From: "hello@company.com", + Subject: "Q2 Update", + ContactBookID: "cb_book123", + HTML: "

Q2 Update

Here's what's new...

", + SendNow: true, +}) if err != nil { log.Fatal(err) } +``` + +### List campaigns -if apiErr != nil { - log.Fatalf("API error: %s", apiErr.Message) +```go +resp, err := client.Campaigns.List(ctx, bytesend.ListCampaignsParams{ + Page: "1", + Status: "sent", +}) +if err != nil { + log.Fatal(err) } +fmt.Printf("Found %d campaigns\n", len(resp.Campaigns)) +``` + +### Schedule a campaign + +```go +resp, err := client.Campaigns.Schedule(ctx, "campaign_id", bytesend.ScheduleCampaignPayload{ + ScheduledAt: "2026-06-01T09:00:00Z", + BatchSize: 1000, +}) ``` + +### Pause or resume a campaign + +```go +resp, err := client.Campaigns.Pause(ctx, "campaign_id") +resp, err = client.Campaigns.Resume(ctx, "campaign_id") +``` + +## Analytics + +### Email time series + +Retrieve delivery metrics for a date range: + +```go +resp, err := client.Analytics.EmailTimeSeries(ctx, bytesend.EmailTimeSeriesParams{ + Days: "7", // or "30" + DomainID: "optional_domain_id", +}) +if err != nil { + log.Fatal(err) +} + +for _, entry := range resp.Result { + fmt.Printf("%s: sent=%d, delivered=%d, opened=%d\n", + entry.Date, entry.Sent, entry.Delivered, entry.Opened) +} +``` + +### Reputation metrics + +```go +resp, err := client.Analytics.ReputationMetrics(ctx, bytesend.ReputationMetricsParams{ + DomainID: "optional_domain_id", +}) +if err != nil { + log.Fatal(err) +} + +fmt.Printf("Bounce rate: %.2f%%\n", resp.BounceRate*100) +fmt.Printf("Complaint rate: %.2f%%\n", resp.ComplaintRate*100) +``` + +## More Information + +For complete SDK documentation and examples, see the [GitHub repository](https://github.com/nodebyteltd/bytesend-go). diff --git a/apps/docs/get-started/nodejs.mdx b/apps/docs/get-started/nodejs.mdx index 1ec484d..2f42e45 100644 --- a/apps/docs/get-started/nodejs.mdx +++ b/apps/docs/get-started/nodejs.mdx @@ -6,7 +6,7 @@ icon: node-js ## Prerequisites -- [ByteSend API key](https://bytesend.cloud/dev-settings/api-keys) +- [ByteSend API key](https://bytesend.cloud/settings/api-keys) - [Verified domain](https://bytesend.cloud/domains) ## Using SDK @@ -33,7 +33,7 @@ icon: node-js - Get the API key from the [ByteSend dashboard](https://bytesend.cloud/dev-settings/api-keys) and initialize the SDK + Get the API key from the [ByteSend dashboard](https://bytesend.cloud/settings/api-keys) and initialize the SDK ```javascript import { ByteSend } from "bytesend-js"; diff --git a/apps/docs/get-started/smtp.mdx b/apps/docs/get-started/smtp.mdx index 257db1b..42860a5 100644 --- a/apps/docs/get-started/smtp.mdx +++ b/apps/docs/get-started/smtp.mdx @@ -8,7 +8,7 @@ icon: envelope You will need an API key and a verified domain to get the most out of this guide: -- [API Key](https://bytesend.cloud/dev-settings/api-keys) +- [API Key](https://bytesend.cloud/settings/api-keys) - [Verified Domain](https://bytesend.cloud/domains) ## SMTP credentials diff --git a/apps/docs/guides/admin-operations.mdx b/apps/docs/guides/admin-operations.mdx index d4a80d8..e2fb359 100644 --- a/apps/docs/guides/admin-operations.mdx +++ b/apps/docs/guides/admin-operations.mdx @@ -320,6 +320,6 @@ For privacy inquiries: ## Next Steps - [Manage your team members](/guides/plan-management) -- [Configure notification alerts](/settings/notifications) -- [Explore your usage](/settings/usage) +- [Configure notification alerts](https://bytesend.cloud/settings/notifications) +- [Explore your usage](https://bytesend.cloud/settings/usage) - [Contact support](https://discord.gg/xqkqzVRC4S) diff --git a/apps/docs/guides/discord-notifications.mdx b/apps/docs/guides/discord-notifications.mdx index 360b605..c7161f9 100644 --- a/apps/docs/guides/discord-notifications.mdx +++ b/apps/docs/guides/discord-notifications.mdx @@ -279,5 +279,5 @@ Discord webhooks don't expire, but: - [Return to notification providers](/guides/notification-providers) - [Set up Slack notifications](/guides/slack-notifications) -- [Manage all notifications settings](/settings/notifications) +- [Manage all notifications settings](https://bytesend.cloud/settings/notifications) - [Join the community Discord](https://discord.gg/xqkqzVRC4S) diff --git a/apps/docs/guides/notification-providers.mdx b/apps/docs/guides/notification-providers.mdx index 1136d08..584b179 100644 --- a/apps/docs/guides/notification-providers.mdx +++ b/apps/docs/guides/notification-providers.mdx @@ -244,4 +244,4 @@ Before relying on a provider: - [Set up Discord notifications](/guides/discord-notifications) - [Set up Slack notifications](/guides/slack-notifications) - [Manage your team members](/guides/plan-management) -- [Return to settings](/settings/notifications) +- [Return to settings](https://bytesend.cloud/settings/notifications) diff --git a/apps/docs/guides/plan-management.mdx b/apps/docs/guides/plan-management.mdx index 4b5699b..0d8e973 100644 --- a/apps/docs/guides/plan-management.mdx +++ b/apps/docs/guides/plan-management.mdx @@ -223,7 +223,7 @@ If using GitHub OAuth: ## Next Steps -- [View your usage](/settings/usage) -- [Set up notifications](/settings/notifications) +- [View your usage](https://bytesend.cloud/settings/usage) +- [Set up notifications](https://bytesend.cloud/settings/notifications) - [Upgrade your plan](/guides/plans-and-pricing) - [Configure notification providers](/guides/notification-providers) diff --git a/apps/docs/guides/slack-notifications.mdx b/apps/docs/guides/slack-notifications.mdx index 784b0d6..05dc777 100644 --- a/apps/docs/guides/slack-notifications.mdx +++ b/apps/docs/guides/slack-notifications.mdx @@ -280,6 +280,6 @@ To reduce alert fatigue: - [Return to notification providers](/guides/notification-providers) - [Set up Discord notifications](/guides/discord-notifications) -- [Manage all notifications](/settings/notifications) +- [Manage all notifications](https://bytesend.cloud/settings/notifications) - [Configure team members](/guides/plan-management) - [Explore billing operations](/guides/admin-operations) diff --git a/apps/docs/introduction.mdx b/apps/docs/introduction.mdx index b00fe67..9c140bb 100644 --- a/apps/docs/introduction.mdx +++ b/apps/docs/introduction.mdx @@ -32,7 +32,7 @@ ByteSend is a powerful, open-source email delivery platform designed for develop Generate an API key to start sending emails from your app. diff --git a/apps/docs/get-started/set-up-docker.mdx b/apps/docs/self-hosting/docker.mdx similarity index 87% rename from apps/docs/get-started/set-up-docker.mdx rename to apps/docs/self-hosting/docker.mdx index 0f6eb92..7c32875 100644 --- a/apps/docs/get-started/set-up-docker.mdx +++ b/apps/docs/self-hosting/docker.mdx @@ -19,7 +19,7 @@ This guide covers the fastest path to a running ByteSend instance using Docker C ```bash git clone https://github.com/NodeByteLTD/ByteSend.git -cd bytesend +cd ByteSend ``` --- @@ -76,15 +76,15 @@ Configure at least one provider. All three are supported: docker compose -f docker/prod/compose.yml --env-file docker/prod/.env up -d ``` -This starts: +This pulls the latest images from DockerHub and starts: -| Container | Description | -|---|---| -| `web` | ByteSend Next.js application | -| `postgres` | PostgreSQL 16 | -| `redis` | Redis 7 | -| `minio` | S3-compatible object storage (console on port 9001) | -| `smtp` | ByteSend SMTP relay server | +| Container | Image | Description | +|---|---|---| +| `web` | `bytesend/bytesend:latest` | ByteSend Next.js application | +| `postgres` | `postgres:16` | PostgreSQL 16 database | +| `redis` | `redis:7` | Redis 7 | +| `minio` | `minio/minio:latest` | S3-compatible object storage (console on port 9001) | +| `smtp` | `bytesend/smtp-proxy:latest` | ByteSend SMTP relay server | --- @@ -148,10 +148,10 @@ docker compose -f docker/prod/compose.yml --env-file docker/prod/.env up -d | `AWS_DEFAULT_REGION` | Yes | AWS region (e.g. `eu-west-1`) | | `GITHUB_ID` | No | GitHub OAuth app client ID | | `GITHUB_SECRET` | No | GitHub OAuth app client secret | +| `DISCORD_CLIENT_ID` | No | Discord OAuth app client ID | +| `DISCORD_CLIENT_SECRET` | No | Discord OAuth app client secret | +| `GOOGLE_CLIENT_ID` | No | Google OAuth app client ID | +| `GOOGLE_CLIENT_SECRET` | No | Google OAuth app client secret | | `STRIPE_SECRET_KEY` | No | Stripe secret key (billing) | | `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook signing secret | -| `GITLAB_URL` | No | GitLab URL for release tracking | -| `GITLAB_REPO_ID` | No | GitLab repository ID | -| `GITLAB_RELEASE_TOKEN` | No | GitLab API token for releases | -| `GITHUB_RELEASE_TOKEN` | No | GitHub API token for releases | diff --git a/apps/docs/self-hosting/overview.mdx b/apps/docs/self-hosting/overview.mdx index 30fd26d..69fe6ec 100644 --- a/apps/docs/self-hosting/overview.mdx +++ b/apps/docs/self-hosting/overview.mdx @@ -24,7 +24,7 @@ Both methods require: ## Docker Deployment -Docker is the fastest way to get ByteSend running. The `docker/prod/compose.yml` file in the repository starts the web app, PostgreSQL, Redis, MinIO (S3-compatible storage), and the SMTP relay together. +Docker is the fastest way to get ByteSend running. The `docker/prod/compose.yml` file in the repository pulls pre-built images from DockerHub and starts the web app, PostgreSQL, Redis, MinIO (S3-compatible storage), and the SMTP relay together. ### 1. Install Docker @@ -41,14 +41,14 @@ newgrp docker ```bash git clone https://github.com/NodeByteLTD/ByteSend.git -cd bytesend +cd ByteSend ``` ### 3. Configure environment variables ```bash -cp .env.example .env -nano .env +cp docker/prod/.env.example docker/prod/.env +nano docker/prod/.env ``` At minimum, set the following variables: @@ -59,7 +59,8 @@ NEXTAUTH_URL=https://your-domain.com NEXTAUTH_SECRET= # Database (managed by compose — change password) -DATABASE_URL=postgresql://bytesend:changeme@postgres:5432/bytesend +POSTGRES_PASSWORD= +DATABASE_URL=postgresql://bytesend:@postgres:5432/bytesend # Redis (managed by compose) REDIS_URL=redis://redis:6379 @@ -76,39 +77,35 @@ GITHUB_SECRET= # Stripe (optional — enables billing) STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= - -# Release tracking (optional) -GITLAB_URL= -GITLAB_REPO_ID= -GITLAB_RELEASE_TOKEN= -GITHUB_RELEASE_TOKEN= ``` ### 4. Start the stack ```bash -docker compose -f docker-compose.web.yml up -d +docker compose -f docker/prod/compose.yml --env-file docker/prod/.env up -d ``` -This starts three containers: +This pulls the latest images from DockerHub and starts: -| Container | Description | -|---|---| -| `web` | ByteSend Next.js application (port 3000) | -| `postgres` | PostgreSQL 16 database | -| `redis` | Redis 7 | +| Container | Image | Description | +|---|---|---| +| `web` | `bytesend/bytesend:latest` | ByteSend Next.js application (port 3000) | +| `postgres` | `postgres:16` | PostgreSQL 16 database | +| `redis` | `redis:7` | Redis 7 | +| `minio` | `minio/minio:latest` | S3-compatible object storage | +| `smtp` | `bytesend/smtp-proxy:latest` | SMTP relay server | ### 5. Run database migrations ```bash -docker compose -f docker-compose.web.yml exec web pnpm db:migrate-deploy +docker compose -f docker/prod/compose.yml --env-file docker/prod/.env exec web pnpm db:migrate-deploy ``` ### 6. Verify ```bash -docker compose -f docker-compose.web.yml ps -docker compose -f docker-compose.web.yml logs -f web +docker compose -f docker/prod/compose.yml --env-file docker/prod/.env ps +docker compose -f docker/prod/compose.yml --env-file docker/prod/.env logs -f web ``` The app is now accessible at **http://your-server-ip:3000**. diff --git a/apps/docs/self-hosting/smtp-server.mdx b/apps/docs/self-hosting/smtp-server.mdx index 81c7ba2..fabf648 100644 --- a/apps/docs/self-hosting/smtp-server.mdx +++ b/apps/docs/self-hosting/smtp-server.mdx @@ -60,35 +60,17 @@ The server supports two TLS configurations: - ByteSend instance accessible at a known URL - (Optional) SSL certificates if using `SMTP_TLS_MODE=manual` -#### Quick Start +#### Quick Start with DockerHub Image -1. **Clone the ByteSend repository:** - ```bash - git clone https://github.com/NodeByteLTD/ByteSend.git - cd ByteSend/apps/smtp-server - ``` - -2. **Set up environment variables:** - ```bash - # Copy the example config - cp .env.example .env - - # Edit with your values - # BYTESEND_BASE_URL=https://your-bytesend-instance.com - # SMTP_AUTH_USERNAME=bytesend # Optional legacy fallback when client omits username - # SMTP_TLS_MODE=none # or 'manual' if you have certs - ``` +The official ByteSend SMTP relay image is available on DockerHub as `bytesend/smtp-proxy:latest`. -3. **The docker-compose.yml file is pre-configured:** - The docker-compose.yml uses environment variable interpolation for easy configuration in platforms like Dokploy: +1. **Create a docker-compose.yml:** ```yaml version: '3.8' services: smtp: - build: - context: . - dockerfile: Dockerfile + image: bytesend/smtp-proxy:latest # Pull from DockerHub restart: unless-stopped ports: - "25:25" @@ -97,31 +79,55 @@ The server supports two TLS configurations: - "2465:2465" - "2587:2587" environment: - - NODE_ENV=${NODE_ENV:-production} - - SMTP_AUTH_USERNAME=${SMTP_AUTH_USERNAME:-bytesend} - - BYTESEND_BASE_URL=${BYTESEND_BASE_URL:-https://bytesend.cloud} - - SMTP_TLS_MODE=${SMTP_TLS_MODE:-none} + - NODE_ENV=production + - SMTP_AUTH_USERNAME=bytesend # Legacy fallback username + - BYTESEND_BASE_URL=https://your-bytesend-instance.com + - SMTP_TLS_MODE=none # or 'manual' if you have certs # Uncomment for manual TLS mode: - # - SMTP_TLS_CERT_PATH=${SMTP_TLS_CERT_PATH:-/certs/fullchain.pem} - # - SMTP_TLS_KEY_PATH=${SMTP_TLS_KEY_PATH:-/certs/privkey.pem} + # - SMTP_TLS_CERT_PATH=/certs/fullchain.pem + # - SMTP_TLS_KEY_PATH=/certs/privkey.pem # volumes: # - /etc/letsencrypt/live/smtp.example.com:/certs:ro ``` -4. **Start the server:** +2. **Start the server:** ```bash - # Load environment variables from .env and start - docker compose up -d --build + # With environment variables + BYTESEND_BASE_URL=https://your-instance.com \ + SMTP_TLS_MODE=manual \ + docker compose up -d ``` - Or with explicit environment variables: + Or edit a `.env` file and run: + ```bash + docker compose --env-file .env up -d + ``` + +3. **Verify it's running:** + ```bash + docker compose logs -f smtp + ``` + You should see: + ``` + SMTP server (plain (no TLS)) is listening on port 25 + ``` + +#### Building from Source + +If you prefer to build the image locally: + +1. **Clone the ByteSend repository:** + ```bash + git clone https://github.com/NodeByteLTD/ByteSend.git + cd ByteSend/apps/smtp-server + ``` + +2. **Build and run:** ```bash - BYTESEND_BASE_URL=https://your-instance.com \ - SMTP_TLS_MODE=manual \ docker compose up -d --build ``` -5. **Verify it's running:** +3. **Verify it's running:** ```bash docker compose logs -f smtp ``` diff --git a/apps/web/package.json b/apps/web/package.json index cc5b302..16db6f1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -79,6 +79,7 @@ "query-string": "^9.1.1", "react": "19.1.0", "react-dom": "19.1.0", + "react-icons": "5.6.0", "react-hook-form": "^7.56.1", "recharts": "^2.15.3", "server-only": "^0.0.1", @@ -126,4 +127,4 @@ "initVersion": "7.30.0" }, "packageManager": "pnpm@8.9.2" -} +} \ No newline at end of file diff --git a/apps/web/prisma/migrations/20260510120000_add_campaign_intent/migration.sql b/apps/web/prisma/migrations/20260510120000_add_campaign_intent/migration.sql new file mode 100644 index 0000000..be18de5 --- /dev/null +++ b/apps/web/prisma/migrations/20260510120000_add_campaign_intent/migration.sql @@ -0,0 +1,7 @@ +-- Add campaign intent to support separate Broadcast and Campaign views on shared data model +CREATE TYPE "CampaignIntent" AS ENUM ('CAMPAIGN', 'BROADCAST'); + +ALTER TABLE "Campaign" +ADD COLUMN "intent" "CampaignIntent" NOT NULL DEFAULT 'CAMPAIGN'; + +CREATE INDEX "Campaign_intent_createdAt_idx" ON "Campaign"("intent", "createdAt" DESC); diff --git a/apps/web/prisma/migrations/20260510153000_add_custom_plan_contract_fields/migration.sql b/apps/web/prisma/migrations/20260510153000_add_custom_plan_contract_fields/migration.sql new file mode 100644 index 0000000..4443a12 --- /dev/null +++ b/apps/web/prisma/migrations/20260510153000_add_custom_plan_contract_fields/migration.sql @@ -0,0 +1,6 @@ +-- Add custom plan contract fields for slider-based plans +ALTER TABLE "Team" +ADD COLUMN "customPlanEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "customMarketingEmailLimit" INTEGER, +ADD COLUMN "customTransactionalEmailLimit" INTEGER, +ADD COLUMN "customMonthlyPriceCents" INTEGER; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 7715c49..f62dd73 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -104,38 +104,42 @@ enum Plan { } model Team { - id Int @id @default(autoincrement()) - name String @unique - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - plan Plan @default(FREE) - stripeCustomerId String? @unique - isActive Boolean @default(true) - apiRateLimit Int @default(2) - billingEmail String? - sesTenantId String? - isVerified Boolean @default(false) - dailyEmailLimit Int @default(10000) - isBlocked Boolean @default(false) - extraDomainSlots Int @default(0) - extraMemberSlots Int @default(0) - smtpUsername String? - teamUsers TeamUser[] - domains Domain[] - apiKeys ApiKey[] - emails Email[] - contactBooks ContactBook[] - campaigns Campaign[] - templates Template[] - dailyEmailUsages DailyEmailUsage[] - subscription Subscription[] - invites TeamInvite[] - suppressionList SuppressionList[] - webhookEndpoints Webhook[] - webhookCalls WebhookCall[] - notificationProviders NotificationProvider[] - notificationLogs NotificationLog[] + id Int @id @default(autoincrement()) + name String @unique + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + plan Plan @default(FREE) + stripeCustomerId String? @unique + isActive Boolean @default(true) + apiRateLimit Int @default(2) + billingEmail String? + sesTenantId String? + isVerified Boolean @default(false) + dailyEmailLimit Int @default(10000) + isBlocked Boolean @default(false) + extraDomainSlots Int @default(0) + extraMemberSlots Int @default(0) + customPlanEnabled Boolean @default(false) + customMarketingEmailLimit Int? + customTransactionalEmailLimit Int? + customMonthlyPriceCents Int? + smtpUsername String? + teamUsers TeamUser[] + domains Domain[] + apiKeys ApiKey[] + emails Email[] + contactBooks ContactBook[] + campaigns Campaign[] + templates Template[] + dailyEmailUsages DailyEmailUsage[] + subscription Subscription[] + invites TeamInvite[] + suppressionList SuppressionList[] + webhookEndpoints Webhook[] + webhookCalls WebhookCall[] + notificationProviders NotificationProvider[] + notificationLogs NotificationLog[] } model TeamInvite { @@ -360,6 +364,11 @@ enum CampaignStatus { SENT } +enum CampaignIntent { + CAMPAIGN + BROADCAST +} + model Campaign { id String @id @default(cuid()) name String @@ -385,6 +394,7 @@ model Campaign { hardBounced Int @default(0) complained Int @default(0) isApi Boolean @default(false) + intent CampaignIntent @default(CAMPAIGN) status CampaignStatus @default(DRAFT) batchSize Int @default(500) batchWindowMinutes Int @default(0) @@ -396,6 +406,7 @@ model Campaign { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@index([createdAt(sort: Desc)]) + @@index([intent, createdAt(sort: Desc)]) @@index([status, scheduledAt]) } @@ -547,40 +558,40 @@ enum NotificationEventType { } model NotificationProvider { - id String @id @default(cuid()) - teamId Int - type NotificationProviderType - name String - description String? - config Json // Stores provider-specific config (webhookUrl, channelId, token, etc.) - eventTypes NotificationEventType[] @default([]) - isActive Boolean @default(true) - testMessageSentAt DateTime? - consecutiveFailures Int @default(0) - lastFailureAt DateTime? - lastSuccessAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + teamId Int + type NotificationProviderType + name String + description String? + config Json // Stores provider-specific config (webhookUrl, channelId, token, etc.) + eventTypes NotificationEventType[] @default([]) + isActive Boolean @default(true) + testMessageSentAt DateTime? + consecutiveFailures Int @default(0) + lastFailureAt DateTime? + lastSuccessAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@unique([teamId, type, name]) @@index([teamId, isActive]) } model NotificationLog { - id String @id @default(cuid()) - teamId Int - providerId String - eventType NotificationEventType - payload Json - status String @default("PENDING") // PENDING, SENT, FAILED - attempt Int @default(0) - lastError String? - responseStatus Int? - responseTimeMs Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + teamId Int + providerId String + eventType NotificationEventType + payload Json + status String @default("PENDING") // PENDING, SENT, FAILED + attempt Int @default(0) + lastError String? + responseStatus Int? + responseTimeMs Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@index([teamId, providerId, status]) @@index([createdAt(sort: Desc)]) diff --git a/apps/web/scripts/stripe-seed.ts b/apps/web/scripts/stripe-seed.ts index e9b3a7a..27d554f 100644 --- a/apps/web/scripts/stripe-seed.ts +++ b/apps/web/scripts/stripe-seed.ts @@ -25,8 +25,84 @@ import { import { STRIPE_ADDON_PRODUCTS } from "../../../packages/lib/src/stripe/products.ts"; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; -const DATABASE_URL = process.env.DATABASE_URL; -const ENVIRONMENT = process.argv[2] ?? process.env.NODE_ENV ?? "dev"; +const DATABASE_URL = process.env.DATABASE_URL; +const ENVIRONMENT = process.argv[2] ?? process.env.NODE_ENV ?? "dev"; +const FORCE_RECREATE_PRODUCTS = true; + +async function upsertAddonProductAndPrice(params: { + stripe: Stripe; + environment: string; + addonCode: "ADDITIONAL_DOMAIN" | "EXTRA_MEMBER"; + productName: string; + productDescription: string; + priceMonthly: number; + priceMetadataType: string; + forceRecreateProducts: boolean; +}): Promise<{ productId: string; priceId: string }> { + const { + stripe, + environment, + addonCode, + productName, + productDescription, + priceMonthly, + priceMetadataType, + forceRecreateProducts, + } = params; + + const displayName = `${productName} (${environment})`; + + const existingProducts = await stripe.products.search({ + query: `name:"${displayName}" AND active:'true'`, + limit: 100, + }); + + if (forceRecreateProducts) { + for (const existing of existingProducts.data) { + await stripe.products.update(existing.id, { active: false }); + console.log(` ✓ Archived existing add-on product: ${existing.id}`); + } + } + + let product: Stripe.Product; + if (!forceRecreateProducts && existingProducts.data.length > 0) { + product = existingProducts.data[0]; + await stripe.products.update(product.id, { + description: productDescription, + metadata: { bytesend_addon: addonCode, environment }, + }); + console.log(` ✓ Updated add-on product: ${product.id}`); + } else { + product = await stripe.products.create({ + name: displayName, + description: productDescription, + metadata: { bytesend_addon: addonCode, environment }, + }); + console.log(` ✓ Created add-on product: ${product.id}`); + } + + const existingPrices = await stripe.prices.search({ + query: `product:'${product.id}' AND metadata['type']:'${priceMetadataType}'`, + limit: 1, + }); + + let price: Stripe.Price; + if (existingPrices.data.length > 0) { + price = existingPrices.data[0]; + console.log(` ✓ Found add-on price: ${price.id}`); + } else { + price = await stripe.prices.create({ + product: product.id, + currency: "cad", + unit_amount: priceMonthly, + recurring: { interval: "month", usage_type: "licensed" }, + metadata: { type: priceMetadataType, environment }, + }); + console.log(` ✓ Created add-on price: ${price.id}`); + } + + return { productId: product.id, priceId: price.id }; +} async function main() { console.log("\n🚀 ByteSend Stripe + DB Seed"); @@ -42,14 +118,17 @@ async function main() { } const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2024-12-18.acacia" }); - const db = new PrismaClient(); + const db = new PrismaClient(); console.log(`📦 Environment : ${ENVIRONMENT}`); console.log(`🔑 Stripe key : ${STRIPE_SECRET_KEY.slice(0, 20)}...`); + console.log(`♻️ Recreate mode: ${FORCE_RECREATE_PRODUCTS ? "enabled" : "disabled"}`); console.log("\n📝 Syncing plans to Stripe...\n"); // ── Step 1: Sync core plans ────────────────────────────────────────────── - const result = await syncPlansToStripe(stripe, ENVIRONMENT); + const result = await syncPlansToStripe(stripe, ENVIRONMENT, { + forceRecreateProducts: FORCE_RECREATE_PRODUCTS, + }); if (!result.success) { console.error("\n❌ Stripe sync failed:"); @@ -60,57 +139,39 @@ async function main() { console.log(`\n✅ Synced ${result.products.length} core plans`); - // ── Step 2: Sync additional-domain add-on ──────────────────────────────── - console.log("\n📎 Syncing add-on: Additional Domain..."); - - const addonConfig = STRIPE_ADDON_PRODUCTS.ADDITIONAL_DOMAIN; - const addonProductName = `${addonConfig.name} (${ENVIRONMENT})`; - - const existingAddon = await stripe.products.search({ - query: `name:"${addonProductName}"`, - limit: 1, + // ── Step 2: Sync add-ons (domain + member) ─────────────────────────────── + console.log("\n📎 Syncing add-ons..."); + + const domainAddon = await upsertAddonProductAndPrice({ + stripe, + environment: ENVIRONMENT, + addonCode: "ADDITIONAL_DOMAIN", + productName: STRIPE_ADDON_PRODUCTS.ADDITIONAL_DOMAIN.name, + productDescription: STRIPE_ADDON_PRODUCTS.ADDITIONAL_DOMAIN.description, + priceMonthly: STRIPE_ADDON_PRODUCTS.ADDITIONAL_DOMAIN.priceMonthly, + priceMetadataType: "addon-domain-monthly", + forceRecreateProducts: FORCE_RECREATE_PRODUCTS, }); - let addonProduct: Stripe.Product; - if (existingAddon.data.length > 0) { - addonProduct = existingAddon.data[0]; - await stripe.products.update(addonProduct.id, { - description: addonConfig.description, - metadata: { bytesend_addon: "ADDITIONAL_DOMAIN", environment: ENVIRONMENT }, - }); - console.log(` ✓ Updated add-on product: ${addonProduct.id}`); - } else { - addonProduct = await stripe.products.create({ - name: addonProductName, - description: addonConfig.description, - metadata: { bytesend_addon: "ADDITIONAL_DOMAIN", environment: ENVIRONMENT }, - }); - console.log(` ✓ Created add-on product: ${addonProduct.id}`); - } - - // Find or create the add-on price - const existingAddonPrices = await stripe.prices.search({ - query: `product:'${addonProduct.id}' AND metadata['type']:'addon-domain-monthly'`, - limit: 1, + const memberAddon = await upsertAddonProductAndPrice({ + stripe, + environment: ENVIRONMENT, + addonCode: "EXTRA_MEMBER", + productName: STRIPE_ADDON_PRODUCTS.EXTRA_MEMBER.name, + productDescription: STRIPE_ADDON_PRODUCTS.EXTRA_MEMBER.description, + priceMonthly: STRIPE_ADDON_PRODUCTS.EXTRA_MEMBER.priceMonthly, + priceMetadataType: "addon-member-monthly", + forceRecreateProducts: FORCE_RECREATE_PRODUCTS, }); - let addonPrice: Stripe.Price; - if (existingAddonPrices.data.length > 0) { - addonPrice = existingAddonPrices.data[0]; - console.log(` ✓ Found add-on price: ${addonPrice.id}`); - } else { - addonPrice = await stripe.prices.create({ - product: addonProduct.id, - currency: "cad", - unit_amount: addonConfig.priceMonthly, // CA$1.00/month - recurring: { interval: "month" }, - metadata: { type: "addon-domain-monthly", environment: ENVIRONMENT }, - }); - console.log(` ✓ Created add-on price: ${addonPrice.id}`); - } - // ── Step 3: Build DB config map ────────────────────────────────────────── - const dbConfig = generateDbConfig(result, addonProduct.id, addonPrice.id); + const dbConfig = generateDbConfig( + result, + domainAddon.productId, + domainAddon.priceId, + memberAddon.productId, + memberAddon.priceId, + ); console.log(`\n💾 Writing ${Object.keys(dbConfig).length} config keys to AppSetting...\n`); diff --git a/apps/web/src/app/(dashboard)/admin/teams/page.tsx b/apps/web/src/app/(dashboard)/admin/teams/page.tsx index 8aba621..2d06202 100644 --- a/apps/web/src/app/(dashboard)/admin/teams/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/teams/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { z } from "zod"; -import { ChevronLeft, ChevronRight, Link2, Gift } from "lucide-react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { Button } from "@bytesend/ui/src/button"; @@ -35,6 +35,7 @@ import { SelectValue, } from "@bytesend/ui/src/select"; import { formatDistanceToNow } from "date-fns"; +import { useSession } from "next-auth/react"; import { api } from "~/trpc/react"; import type { AppRouter } from "~/server/api/root"; @@ -90,18 +91,18 @@ const updateSchema = z.object({ // -1 = unlimited override, 0 = use plan default, positive = custom cap dailyEmailLimit: z.coerce.number().int().min(-1).max(10_000_000), isBlocked: z.boolean(), - plan: z.enum(["FREE", "HOBBY", "LITE", "BASIC", "LIFETIME"]), }); const assignSchema = z.object({ plan: z.enum(["FREE", "HOBBY", "LITE", "BASIC", "LIFETIME"]), - method: z.enum(["complimentary", "checkout_link"]), }); type UpdateInput = z.infer; type AssignInput = z.infer; export default function AdminTeamsPage() { + const { data: session } = useSession(); + const canAssignPlans = Boolean(session?.user?.isEnvAdmin); const [team, setTeam] = useState(null); const [hasSearched, setHasSearched] = useState(false); const [teamsPage, setTeamsPage] = useState(1); @@ -121,7 +122,6 @@ export default function AdminTeamsPage() { apiRateLimit: 1, dailyEmailLimit: 0, isBlocked: false, - plan: "FREE", }, }); @@ -129,7 +129,6 @@ export default function AdminTeamsPage() { resolver: zodResolver(assignSchema), defaultValues: { plan: "LITE", - method: "checkout_link", }, }); @@ -141,7 +140,6 @@ export default function AdminTeamsPage() { apiRateLimit: team.apiRateLimit, dailyEmailLimit: isUnlimited ? -1 : team.dailyEmailLimit, isBlocked: team.isBlocked, - plan: team.plan, }); setGeneratedCheckoutUrl(null); } @@ -179,7 +177,6 @@ export default function AdminTeamsPage() { apiRateLimit: updated.apiRateLimit, dailyEmailLimit: isUnlimited ? -1 : updated.dailyEmailLimit, isBlocked: updated.isBlocked, - plan: updated.plan, }); toast.success("Team settings updated"); }, @@ -211,13 +208,13 @@ export default function AdminTeamsPage() { const onUpdateSubmit = (values: UpdateInput) => { if (!team) return; - updateTeam.mutate({ teamId: team.id, ...values }); + updateTeam.mutate({ teamId: team.id, ...values, plan: team.plan }); }; const onAssignSubmit = (values: AssignInput) => { if (!team) return; setGeneratedCheckoutUrl(null); - assignPlan.mutate({ teamId: team.id, ...values }); + assignPlan.mutate({ teamId: team.id, plan: values.plan, method: "checkout_link" }); }; const listTeams = api.admin.listTeams.useQuery( @@ -408,34 +405,6 @@ export default function AdminTeamsPage() { )} /> - ( - - Plan - - - - - - )} - /> - {/* Plan Assignment */} -
-
-

Assign Plan

-

- Assign a plan complimentarily (no Stripe charge) or generate a checkout link for the team to pay via Stripe. -

-
-
- - ( - - Plan - - - - - - )} - /> - ( - - Method - - - - - - )} - /> - - - - {generatedCheckoutUrl && ( -
-

Checkout link — share with the team:

-
- - {generatedCheckoutUrl} - - + + + {generatedCheckoutUrl && ( +
+

Checkout link — share with the team:

+
+ + {generatedCheckoutUrl} + + +
-
- )} -
+ )} +
+ ) : null} ) : null} diff --git a/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/edit/page.tsx b/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/edit/page.tsx new file mode 100644 index 0000000..aa2bce2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/edit/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { use } from "react"; +import EditCampaignPage from "../../../campaigns/[campaignId]/edit/page"; + +export default function EditBroadcastPage({ + params, +}: { + params: Promise<{ broadcastId: string }>; +}) { + const { broadcastId } = use(params); + + return ; +} \ No newline at end of file diff --git a/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/page.tsx b/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/page.tsx new file mode 100644 index 0000000..0198d99 --- /dev/null +++ b/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { use } from "react"; +import CampaignDetailsPage from "../../campaigns/[campaignId]/page"; + +export default function BroadcastDetailsPage({ + params, +}: { + params: Promise<{ broadcastId: string }>; +}) { + const { broadcastId } = use(params); + + return ; +} \ No newline at end of file diff --git a/apps/web/src/app/(dashboard)/broadcasts/page.tsx b/apps/web/src/app/(dashboard)/broadcasts/page.tsx new file mode 100644 index 0000000..52a4c4b --- /dev/null +++ b/apps/web/src/app/(dashboard)/broadcasts/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import CampaignList from "../campaigns/campaign-list"; +import CreateCampaign from "../campaigns/create-campaign"; +import { H1 } from "@bytesend/ui"; + +export default function BroadcastsPage() { + return ( +
+
+
+

Broadcasts

+

+ Send now or schedule one-off email broadcasts +

+
+ +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx index 0e57fc3..df9032b 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -100,6 +100,7 @@ function CampaignEditor({ }) { const router = useRouter(); const isApiCampaign = campaign.isApi; + const basePath = campaign.intent === "BROADCAST" ? "/broadcasts" : "/campaigns"; const contactBooksQuery = api.contacts.getContactBooks.useQuery({}); const utils = api.useUtils(); @@ -179,7 +180,7 @@ function CampaignEditor({ {/* Sticky top bar */}
- + { - router.push(`/campaigns/${campaign.id}`); + router.push(`${basePath}/${campaign.id}`); }} />
diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx index 5d8a3f1..3b36d91 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx @@ -100,6 +100,8 @@ export default function CampaignDetailsPage({ const total = campaign.total ?? 0; const processed = campaign.sent ?? 0; + const basePath = campaign.intent === "BROADCAST" ? "/broadcasts" : "/campaigns"; + const collectionLabel = campaign.intent === "BROADCAST" ? "Broadcasts" : "Campaigns"; return (
@@ -108,8 +110,8 @@ export default function CampaignDetailsPage({ - - Campaigns + + {collectionLabel} @@ -117,7 +119,7 @@ export default function CampaignDetailsPage({
-
{campaign.name}
+
{campaign.name}
@@ -125,7 +127,7 @@ export default function CampaignDetailsPage({
{campaign.status === "SCHEDULED" ? ( - + ) : ( @@ -185,7 +187,7 @@ export default function CampaignDetailsPage({ Live activity -
+
{latestEmailsLoading ? (
@@ -245,15 +247,15 @@ export default function CampaignDetailsPage({
-
Subject
+
Subject
{campaign.subject}
-
From
+
From
{campaign.from}
-
Contact
+
Contact
0 ? Math.round((campaign.sent / campaign.total) * 100) : 0; const pendingCount = campaign.total - campaign.sent; @@ -47,8 +48,8 @@ export default function CampaignCard({ campaign }: CampaignCardProps) { href={ campaign.status === CampaignStatus.DRAFT || campaign.status === CampaignStatus.SCHEDULED - ? `/campaigns/${campaign.id}/edit` - : `/campaigns/${campaign.id}` + ? `${basePath}/${campaign.id}/edit` + : `${basePath}/${campaign.id}` } className="text-sm font-medium underline decoration-dashed underline-offset-2 truncate" > @@ -94,8 +95,8 @@ export default function CampaignCard({ campaign }: CampaignCardProps) { href={ campaign.status === CampaignStatus.DRAFT || campaign.status === CampaignStatus.SCHEDULED - ? `/campaigns/${campaign.id}/edit` - : `/campaigns/${campaign.id}` + ? `${basePath}/${campaign.id}/edit` + : `${basePath}/${campaign.id}` } className="text-sm font-medium underline decoration-dashed underline-offset-2" > diff --git a/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx b/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx index cac5b85..d6382b5 100644 --- a/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx @@ -14,24 +14,35 @@ import { import { Input } from "@bytesend/ui/src/input"; import { Search } from "lucide-react"; import { useDebouncedCallback } from "use-debounce"; +import { FaBullhorn } from "react-icons/fa6"; import CampaignCard from "./campaign-card"; -export default function CampaignList() { +type MarketingIntent = "CAMPAIGN" | "BROADCAST"; + +interface CampaignListProps { + intent: MarketingIntent; + basePath: "/campaigns" | "/broadcasts"; +} + +export default function CampaignList({ intent, basePath }: CampaignListProps) { const [page, setPage] = useUrlState("page", "1"); const [status, setStatus] = useUrlState("status"); const [search, setSearch] = useUrlState("search"); const debouncedSearch = useDebouncedCallback((value: string) => { setSearch(value); - }, 1000); + }, 300); const pageNumber = Number(page); + const noun = intent === "BROADCAST" ? "broadcast" : "campaign"; + const nounPlural = `${noun}s`; const campaignsQuery = api.campaign.getCampaigns.useQuery( { page: pageNumber, status: status as CampaignStatus | null, search, + intent, }, { refetchInterval: (query) => { @@ -40,28 +51,26 @@ export default function CampaignList() { const shouldPoll = c.some( (campaign) => campaign.status === CampaignStatus.RUNNING || - campaign.status === CampaignStatus.SCHEDULED + campaign.status === CampaignStatus.SCHEDULED, ); return shouldPoll ? 5000 : false; }, - } + }, ); return (
-
- {/* Search input */} +
- + debouncedSearch(e.target.value)} className="pl-10" />
- {/* Status filter */}
- {/* Campaign cards */} +
{campaignsQuery.isLoading ? (
- +
) : campaignsQuery.data?.campaigns.length ? ( - campaignsQuery.data?.campaigns.map((campaign) => ( - + campaignsQuery.data.campaigns.map((campaign) => ( + )) ) : ( -
- - - +
+

- {search || status ? "No campaigns match your filters" : "No campaigns yet"} + {search || status + ? `No ${nounPlural} match your filters` + : `No ${nounPlural} yet`}

-

+

{search || status ? "Try adjusting your search or status filter" - : "Create your first campaign to get started"} + : `Create your first ${noun} to get started`}

)}
-
+ +
- Create new campaign + + {intent === "BROADCAST" ? "Create new broadcast" : "Create new campaign"} +
@@ -106,7 +120,10 @@ export default function CreateCampaign() { Name - + {formState.errors.name ? : null} @@ -135,7 +152,10 @@ export default function CreateCampaign() { Subject - + {formState.errors.subject ? : null} @@ -145,8 +165,8 @@ export default function CreateCampaign() { Don't worry, you can change it later.

- -
- ); - } - return (

Campaigns

-

Create and manage email campaigns

+

Plan and manage email campaigns

- +
- +
); } diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx index dc07ef4..7344452 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx @@ -236,7 +236,7 @@ export default function ContactsPage({ {contactBookDetailQuery.data?.emoji} - + { // Handle emoji selection here @@ -367,7 +367,7 @@ export default function ContactsPage({ - {/* Recent Campaigns Card */} + {/* Recent Broadcasts Card */}
@@ -375,7 +375,7 @@ export default function ContactsPage({
- Recent Campaigns + Recent Broadcasts
@@ -383,7 +383,7 @@ export default function ContactsPage({ {!contactBookDetailQuery.isLoading && contactBookDetailQuery.data?.campaigns.length === 0 ? (
- No campaigns yet. + No broadcasts yet.
) : (
@@ -392,16 +392,16 @@ export default function ContactsPage({ .map((campaign) => (
- + {campaign.name}
-
+
{formatDistanceToNow(campaign.createdAt, { @@ -414,10 +414,10 @@ export default function ContactsPage({ ))} {(contactBookDetailQuery.data?.campaigns.length || 0) > 5 && ( - View all campaigns + View all broadcasts )}
diff --git a/apps/web/src/app/(dashboard)/contacts/page.tsx b/apps/web/src/app/(dashboard)/contacts/page.tsx index bb86484..91437a0 100644 --- a/apps/web/src/app/(dashboard)/contacts/page.tsx +++ b/apps/web/src/app/(dashboard)/contacts/page.tsx @@ -3,36 +3,8 @@ import AddContactBook from "./add-contact-book"; import ContactBooksList from "./contact-books-list"; import { H1 } from "@bytesend/ui"; -import { useTeam } from "~/providers/team-context"; -import { useUpgradeModalStore } from "~/store/upgradeModalStore"; -import { LimitReason } from "~/lib/constants/plans"; -import { isCloud } from "~/utils/common"; -import { LockIcon } from "lucide-react"; -import { Button } from "@bytesend/ui/src/button"; export default function ContactsPage() { - const { currentTeam } = useTeam(); - const openUpgradeModal = useUpgradeModalStore((s) => s.action.openModal); - - if (isCloud() && currentTeam?.plan === "FREE") { - return ( -
-
- -
-
-

Contacts require a paid plan

-

- Manage contact books and audience lists for your email campaigns. Upgrade to unlock this feature. -

-
- -
- ); - } - return (
diff --git a/apps/web/src/app/(dashboard)/dasboard-layout.tsx b/apps/web/src/app/(dashboard)/dasboard-layout.tsx index 588d9fc..007cc0c 100644 --- a/apps/web/src/app/(dashboard)/dasboard-layout.tsx +++ b/apps/web/src/app/(dashboard)/dasboard-layout.tsx @@ -13,11 +13,13 @@ import { ChevronRight } from "lucide-react"; const ROUTE_LABELS: Record = { dashboard: "Analytics", + logs: "Logs", emails: "Emails", templates: "Templates", suppressions: "Suppressions", contacts: "Contacts", campaigns: "Campaigns", + broadcasts: "Broadcasts", domains: "Domains", webhooks: "Webhooks", "dev-settings": "Developer Settings", @@ -74,11 +76,10 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { )} {crumb.label} diff --git a/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx b/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx index f7ea4d4..40008a4 100644 --- a/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/email-chart.tsx @@ -17,10 +17,14 @@ import { api } from "~/trpc/react"; import Spinner from "@bytesend/ui/src/spinner"; import { useTheme } from "@bytesend/ui"; import { useColors } from "./hooks/useColors"; +import { Button } from "@bytesend/ui/src/button"; +import { useUpgradeModalStore } from "~/store/upgradeModalStore"; +import { LimitReason } from "~/lib/constants/plans"; interface EmailChartProps { days: number; domain: string | null; + isPaidTeam: boolean; } const STACK_ORDER = [ @@ -56,8 +60,9 @@ function createRoundedTopShape( }; } -export default function EmailChart({ days, domain }: EmailChartProps) { +export default function EmailChart({ days, domain, isPaidTeam }: EmailChartProps) { const [selectedMetrics, setSelectedMetrics] = React.useState([]); + const openUpgradeModal = useUpgradeModalStore((s) => s.action.openModal); const domainId = domain ? Number(domain) : undefined; const statusQuery = api.dashboard.emailTimeSeries.useQuery({ days: days, @@ -79,6 +84,22 @@ export default function EmailChart({ days, domain }: EmailChartProps) { ? [...STACK_ORDER] : STACK_ORDER.filter((key) => selectedMetrics.includes(key)); + const totals = statusQuery.data?.totalCounts; + const sent = totals?.sent ?? 0; + const delivered = totals?.delivered ?? 0; + const opened = totals?.opened ?? 0; + const clicked = totals?.clicked ?? 0; + const bounced = totals?.bounced ?? 0; + const complained = totals?.complained ?? 0; + + const deliveryRate = ratio(delivered, sent); + const bounceRate = ratio(bounced, sent); + const complaintRate = ratio(complained, sent); + const openRate = ratio(opened, delivered); + const clickRate = ratio(clicked, delivered); + const clickToOpenRate = ratio(clicked, opened); + const avgDailyVolume = days > 0 ? sent / days : 0; + const toggleMetric = (metric: StackKey) => { setSelectedMetrics((prev) => { const exists = prev.includes(metric); @@ -95,7 +116,7 @@ export default function EmailChart({ days, domain }: EmailChartProps) { return (
{!statusQuery.isLoading && statusQuery.data ? ( -
+
@@ -173,99 +194,135 @@ export default function EmailChart({ days, domain }: EmailChartProps) { />
- - - - {/* */} - { - if (!payload || payload.length === 0) return null; - - const data = payload[0]?.payload as Record< - | "sent" - | "delivered" - | "opened" - | "clicked" - | "bounced" - | "complained", - number - > & { date: string }; - - if (!data) return null; - - const hasAnyData = - visibleMetrics.reduce( - (sum, key) => sum + (data[key] || 0), - 0, - ) > 0; - - if (!hasAnyData) return null; - - return ( -
-

- {data.date} -

- {visibleMetrics.map((metricKey) => { - const metricValue = data[metricKey] || 0; - if (!metricValue) return null; - - return ( -
-
-

- {metricMeta[metricKey].label} -

-

{metricValue}

-
- ); - })} -
- ); +
+ + - {visibleMetrics.map((metricKey) => ( - + + {/* */} + { + if (!payload || payload.length === 0) return null; + + const data = payload[0]?.payload as Record< + | "sent" + | "delivered" + | "opened" + | "clicked" + | "bounced" + | "complained", + number + > & { date: string }; + + if (!data) return null; + + const hasAnyData = + visibleMetrics.reduce( + (sum, key) => sum + (data[key] || 0), + 0, + ) > 0; + + if (!hasAnyData) return null; + + return ( +
+

+ {data.date} +

+ {visibleMetrics.map((metricKey) => { + const metricValue = data[metricKey] || 0; + if (!metricValue) return null; + + return ( +
+
+

+ {metricMeta[metricKey].label} +

+

{metricValue}

+
+ ); + })} +
+ ); + }} + cursor={false} /> - ))} -
-
+ {visibleMetrics.map((metricKey) => ( + + ))} + + +
+ +
+ + + + +
+ + {isPaidTeam ? ( +
+ + + + +
+ ) : ( +
+
+
+

Advanced analytics

+

+ Open rate, click rate, click-to-open rate, and deeper engagement insights are available on paid plans. +

+
+ +
+
+ )}
) : ( -
+
)}
); @@ -286,7 +343,7 @@ const DashboardItemCard: React.FC = ({ percentage, }) => { return ( -
+
{status !== "total" ? : null}
{status.toLowerCase()}
@@ -339,11 +396,9 @@ const EmailChartItem: React.FC = ({ onClick={onClick} disabled={!isClickable} aria-pressed={isClickable ? isActive : undefined} - className={`flex gap-3 items-stretch font-mono transition-opacity ${ - isClickable ? "cursor-pointer" : "cursor-default" - } ${isActive ? "opacity-100" : "opacity-45 hover:opacity-100"} ${ - isClickable ? "" : "pointer-events-none" - }`} + className={`flex gap-3 items-stretch font-mono transition-opacity ${isClickable ? "cursor-pointer" : "cursor-default" + } ${isActive ? "opacity-100" : "opacity-45 hover:opacity-100"} ${isClickable ? "" : "pointer-events-none" + }`} >
@@ -368,3 +423,36 @@ const EmailChartItem: React.FC = ({ ); }; + +function ratio(numerator: number, denominator: number): number { + if (!denominator || denominator <= 0) return 0; + return (numerator / denominator) * 100; +} + +function formatPercent(value: number): string { + return `${value.toFixed(2)}%`; +} + +function AnalyticsStatCard({ + label, + value, + isAdvanced, +}: { + label: string; + value: string; + isAdvanced?: boolean; +}) { + return ( +
+
+

{label}

+ {isAdvanced ? ( + + Paid + + ) : null} +
+

{value}

+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index b53bdad..0e3006c 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -5,10 +5,13 @@ import DashboardFilters from "./dashboard-filters"; import { H1 } from "@bytesend/ui"; import { useUrlState } from "~/hooks/useUrlState"; import { ReputationMetrics } from "./reputation-metrics"; +import { useTeam } from "~/providers/team-context"; export default function Dashboard() { const [days, setDays] = useUrlState("days", "30"); const [domain, setDomain] = useUrlState("domain"); + const { currentTeam } = useTeam(); + const isPaidTeam = currentTeam?.plan !== "FREE"; return (
@@ -25,10 +28,10 @@ export default function Dashboard() { />
- + - -
+
+
); } diff --git a/apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx b/apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx index bd42790..282ce3a 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx @@ -1,17 +1,5 @@ -"use client"; +import { redirect } from "next/navigation"; -import AddApiKey from "./add-api-key"; -import ApiList from "./api-list"; -import { H1 } from "@bytesend/ui"; - -export default function ApiKeysPage() { - return ( -
-
-

API Keys

- -
- -
- ); +export default function DevSettingsApiKeysPage() { + redirect("/settings/api-keys"); } diff --git a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx index 0f819f1..6205814 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx @@ -18,8 +18,8 @@ export default function ApiKeysPage({

- API Keys - SMTP + API Keys + SMTP
{children}
diff --git a/apps/web/src/app/(dashboard)/dev-settings/page.tsx b/apps/web/src/app/(dashboard)/dev-settings/page.tsx index c0502c8..f7ea072 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/page.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/page.tsx @@ -1,20 +1,5 @@ -"use client"; +import { redirect } from "next/navigation"; -import AddApiKey from "./api-keys/add-api-key"; -import ApiList from "./api-keys/api-list"; -import { H1 } from "@bytesend/ui"; - -export default function ApiKeysPage() { - return ( -
-
-
-

API Keys

-

Manage API keys for programmatic access

-
- -
- -
- ); +export default function DevSettingsPage() { + redirect("/settings/api-keys"); } diff --git a/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx b/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx index 50b9b92..d6ceedb 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx @@ -1,18 +1,5 @@ -import { SmtpSettingsClient } from "./smtp-settings-client"; -import { env } from "~/env"; -import { api } from "~/trpc/server"; +import { redirect } from "next/navigation"; -export const dynamic = "force-dynamic"; - -export default async function SmtpPage() { - const teamDetails = await api.team.getTeamDetails(); - - return ( - - ); +export default function DevSettingsSmtpPage() { + redirect("/settings/smtp"); } diff --git a/apps/web/src/app/(dashboard)/logs/page.tsx b/apps/web/src/app/(dashboard)/logs/page.tsx new file mode 100644 index 0000000..b4ce4f5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/logs/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { H1 } from "@bytesend/ui"; +import { api } from "~/trpc/react"; +import Spinner from "@bytesend/ui/src/spinner"; +import { Input } from "@bytesend/ui/src/input"; +import { Badge } from "@bytesend/ui/src/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bytesend/ui/src/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@bytesend/ui/src/table"; + +const SOURCE_FILTERS = ["ALL", "EMAIL", "WEBHOOK", "NOTIFICATION"] as const; + +type SourceFilter = (typeof SOURCE_FILTERS)[number]; + +export default function LogsPage() { + const [source, setSource] = useState("ALL"); + const [status, setStatus] = useState("ALL"); + const [search, setSearch] = useState(""); + + const logsQuery = api.logs.list.useQuery({ + limit: 200, + source: source === "ALL" ? undefined : source, + }); + + const rows = useMemo(() => { + const base = logsQuery.data ?? []; + const searchTerm = search.trim().toLowerCase(); + + return base.filter((row) => { + if (status !== "ALL" && row.status !== status) return false; + + if (!searchTerm) return true; + + const haystack = [ + row.title, + row.kind, + row.status, + row.target, + row.metadata?.lastError, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return haystack.includes(searchTerm); + }); + }, [logsQuery.data, search, status]); + + const statusOptions = useMemo(() => { + const unique = new Set(); + for (const row of logsQuery.data ?? []) unique.add(row.status); + return Array.from(unique).sort(); + }, [logsQuery.data]); + + return ( +
+
+
+

Logs

+

+ Unified audit trail for email events, webhook deliveries, and notifications. +

+
+ +
+ setSearch(e.target.value)} + placeholder="Search logs" + className="sm:col-span-1" + /> + + + + +
+
+ +
+ + + + Time + Source + Event + Status + Target + Details + + + + {logsQuery.isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No logs found for this filter set. + + + ) : ( + rows.map((row) => ( + + + {formatDistanceToNow(new Date(row.createdAt), { addSuffix: true })} + + + {row.source} + + +
{row.title}
+
{row.kind}
+
+ + + + + {row.target} + + + {row.metadata?.lastError + ? `Error: ${row.metadata.lastError}` + : row.metadata?.responseStatus + ? `HTTP ${row.metadata.responseStatus}` + : "-"} + +
+ )) + )} +
+
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const variant = statusColor(status); + + return ( + + {status} + + ); +} + +function statusColor(status: string): string { + if (["DELIVERED", "SENT", "OPENED", "CLICKED"].includes(status)) { + return "border-green/40 text-green"; + } + if (["FAILED", "BOUNCED", "COMPLAINED", "REJECTED", "DISCARDED"].includes(status)) { + return "border-red/40 text-red"; + } + if (["PENDING", "IN_PROGRESS", "QUEUED", "SCHEDULED"].includes(status)) { + return "border-yellow/40 text-yellow"; + } + return "border-border/60 text-muted-foreground"; +} diff --git a/apps/web/src/app/(dashboard)/settings/api-keys/page.tsx b/apps/web/src/app/(dashboard)/settings/api-keys/page.tsx new file mode 100644 index 0000000..2eb2db5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/api-keys/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import AddApiKey from "../../dev-settings/api-keys/add-api-key"; +import ApiList from "../../dev-settings/api-keys/api-list"; +import { H1 } from "@bytesend/ui"; + +export default function SettingsApiKeysPage() { + return ( +
+
+
+

API Keys

+

+ Manage API keys for programmatic access +

+
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 8f764ea..52f6888 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -4,23 +4,15 @@ import { useState } from "react"; import { Button } from "@bytesend/ui/src/button"; import { Card } from "@bytesend/ui/src/card"; import { Spinner } from "@bytesend/ui/src/spinner"; -import { CheckCircle2 } from "lucide-react"; import { format } from "date-fns"; import { useTeam } from "~/providers/team-context"; import { api } from "~/trpc/react"; import { PlanDetails } from "~/components/payments/PlanDetails"; -import { UpgradeButton } from "~/components/payments/UpgradeButton"; -import { PLANS, getAllPlans } from "@bytesend/lib"; -import { PLAN_PERKS } from "~/lib/constants/payments"; - -type UpgradePlan = "HOBBY" | "LITE" | "BASIC"; - -// Plans shown as upgrade options when on FREE plan (exclude FREE and LIFETIME) -const UPGRADE_PLANS: { plan: UpgradePlan; highlight?: boolean }[] = [ - { plan: "HOBBY" }, - { plan: "LITE", highlight: true }, - { plan: "BASIC" }, -]; +import { + UpgradeButton, +} from "~/components/payments/UpgradeButton"; +import { PLANS } from "@bytesend/lib"; +import { BillingPlanSelector } from "~/components/payments/BillingPlanSelector"; function formatPlanPrice(plan: (typeof PLANS)[keyof typeof PLANS]): string { if (plan.oneTimePrice) return `CA$${plan.oneTimePrice / 100} one-time`; @@ -102,44 +94,48 @@ export default function SettingsPage() {
- {currentTeam?.plan === "FREE" && ( -
-

Upgrade Your Plan

-
- {UPGRADE_PLANS.map(({ plan, highlight }) => { - const planData = PLANS[plan]; - const perks = PLAN_PERKS[plan] ?? []; - return ( -
-
- {planData.displayName} - - {formatPlanPrice(planData)} - -
-
    - {perks.map((perk, i) => ( -
  • - - {perk} -
  • - ))} -
- -
- ); - })} -
+
+

Available Plans

+

+ Select monthly marketing and transactional limits, then lock in that exact Stripe contract here. +

+
+ + + +
+
+

Lifetime

+

+ One-time purchase with a high hard cap and no recurring monthly plan charge. +

+
+
+ + {formatPlanPrice(PLANS.LIFETIME)} + + {currentTeam.plan === "LIFETIME" ? ( + + ) : currentTeam.plan === "FREE" ? ( + + ) : ( + + )} +
+
+
- )} +
@@ -148,7 +144,7 @@ export default function SettingsPage() {
{subscription.paymentMethod && - subscription.paymentMethod !== "null" ? ( + subscription.paymentMethod !== "null" ? ( <> 💳 @@ -170,9 +166,9 @@ export default function SettingsPage() { Next billing date:{" "} {subscription.currentPeriodEnd ? format( - new Date(subscription.currentPeriodEnd), - "MMM dd, yyyy", - ) + new Date(subscription.currentPeriodEnd), + "MMM dd, yyyy", + ) : "N/A"}
diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx index 953f3c2..83d94a1 100644 --- a/apps/web/src/app/(dashboard)/settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/settings/layout.tsx @@ -23,6 +23,8 @@ export default function ApiKeysPage({
General + API Keys + SMTP {isCloud() ? ( Usage ) : null} diff --git a/apps/web/src/app/(dashboard)/settings/smtp/page.tsx b/apps/web/src/app/(dashboard)/settings/smtp/page.tsx new file mode 100644 index 0000000..f742345 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/smtp/page.tsx @@ -0,0 +1,18 @@ +import { SmtpSettingsClient } from "../../dev-settings/smtp/smtp-settings-client"; +import { env } from "~/env"; +import { api } from "~/trpc/server"; + +export const dynamic = "force-dynamic"; + +export default async function SettingsSmtpPage() { + const teamDetails = await api.team.getTeamDetails(); + + return ( + + ); +} \ No newline at end of file diff --git a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx index fa38d25..c01ae7a 100644 --- a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx +++ b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx @@ -239,7 +239,7 @@ export default function InviteTeamMember() { @@ -268,7 +268,7 @@ export default function InviteTeamMember() { )} {!isAtLimit && (

- Need more seats? Purchase extra member slots — CA$5/mo each. + Need more seats? Purchase extra member slots — CA$2/mo each.

)}
diff --git a/apps/web/src/app/(dashboard)/settings/usage/usage.tsx b/apps/web/src/app/(dashboard)/settings/usage/usage.tsx index d00ddff..9474f5e 100644 --- a/apps/web/src/app/(dashboard)/settings/usage/usage.tsx +++ b/apps/web/src/app/(dashboard)/settings/usage/usage.tsx @@ -33,6 +33,57 @@ function ChoosePlanButton() { const FREE_PLAN_LIMIT = PLANS.FREE.limits.monthlyEmailLimit; const FREE_PLAN_DAILY_LIMIT = PLANS.FREE.limits.dailyEmailLimit; +function getUsageByType( + usage: { type: EmailUsageType; sent: number }[], + type: EmailUsageType, +): number { + return usage.find((u) => u.type === type)?.sent ?? 0; +} + +function getEffectiveEmailLimits( + plan: "FREE" | "BASIC" | "HOBBY" | "LITE" | "LIFETIME", + customContract?: { + customPlanEnabled: boolean; + customMarketingEmailLimit: number | null; + customTransactionalEmailLimit: number | null; + } | null, +) { + const hasCustom = Boolean( + customContract?.customPlanEnabled && + customContract.customMarketingEmailLimit && + customContract.customTransactionalEmailLimit, + ); + + if (hasCustom) { + const marketing = customContract?.customMarketingEmailLimit ?? 0; + const transactional = customContract?.customTransactionalEmailLimit ?? 0; + return { + monthly: marketing + transactional, + daily: -1, + marketing, + transactional, + isCustom: true, + }; + } + + const planLimits = PLANS[plan].limits; + return { + monthly: planLimits.monthlyEmailLimit, + daily: + Number.isFinite(planLimits.dailyEmailLimit) && planLimits.dailyEmailLimit > 0 + ? planLimits.dailyEmailLimit + : -1, + marketing: null, + transactional: null, + isCustom: false, + }; +} + +function pct(current: number, limit: number): number { + if (limit <= 0) return 0; + return Math.min((current / limit) * 100, 100); +} + /* ────────── Free-tier usage ────────── */ function FreePlanUsage({ @@ -113,6 +164,86 @@ function FreePlanUsage({ ); } +function EmailLimitsBreakdown({ + usage, + dayUsage, + plan, + customContract, +}: { + usage: { type: EmailUsageType; sent: number }[]; + dayUsage: { type: EmailUsageType; sent: number }[]; + plan: "FREE" | "BASIC" | "HOBBY" | "LITE" | "LIFETIME"; + customContract?: { + customPlanEnabled: boolean; + customMarketingEmailLimit: number | null; + customTransactionalEmailLimit: number | null; + } | null; +}) { + const limits = getEffectiveEmailLimits(plan, customContract); + + const monthlyTotalSent = usage.reduce((acc, item) => acc + item.sent, 0); + const dailyTotalSent = dayUsage.reduce((acc, item) => acc + item.sent, 0); + const monthlyMarketingSent = getUsageByType(usage, "MARKETING"); + const monthlyTransactionalSent = getUsageByType(usage, "TRANSACTIONAL"); + + return ( + + + Email limits breakdown + + + + + + + {limits.marketing !== null ? ( + + ) : ( +
+
+ Marketing emails + + {monthlyMarketingSent.toLocaleString()} / shared monthly pool + +
+ +
+ )} + + {limits.transactional !== null ? ( + + ) : ( +
+
+ Transactional emails + + {monthlyTransactionalSent.toLocaleString()} / shared monthly pool + +
+ +
+ )} +
+
+ ); +} + /* ────────── Paid-tier usage ────────── */ function PaidPlanUsage({ @@ -250,10 +381,13 @@ function ResourceLimitRow({ function ResourceLimits() { const { data: domainLimit, isLoading: dl } = api.limits.get.useQuery({ type: LimitReason.DOMAIN }); + const { data: contactBooksLimit, isLoading: cbl } = api.limits.get.useQuery({ type: LimitReason.CONTACT_BOOK }); + const { data: contactsLimit, isLoading: cl } = api.limits.get.useQuery({ type: LimitReason.CONTACTS }); + const { data: campaignLimit, isLoading: cal } = api.limits.get.useQuery({ type: LimitReason.CAMPAIGN }); const { data: memberLimit, isLoading: ml } = api.limits.get.useQuery({ type: LimitReason.TEAM_MEMBER }); const { data: webhookLimit, isLoading: wl } = api.limits.get.useQuery({ type: LimitReason.WEBHOOK }); - const isLoading = dl || ml || wl; + const isLoading = dl || cbl || cl || cal || ml || wl; if (isLoading) { return ( @@ -266,7 +400,7 @@ function ResourceLimits() { return ( - Resource limits + Resource limits breakdown {domainLimit && ( @@ -276,6 +410,27 @@ function ResourceLimits() { limit={domainLimit.limit} /> )} + {contactBooksLimit && ( + + )} + {contactsLimit && ( + + )} + {campaignLimit && ( + + )} {memberLimit && ( {billingPeriod}

+ {currentTeam?.plan ? ( + + ) : null} + {isLoading ? (
diff --git a/apps/web/src/app/(marketing)/legal/page.tsx b/apps/web/src/app/(marketing)/legal/page.tsx index e973726..5939736 100644 --- a/apps/web/src/app/(marketing)/legal/page.tsx +++ b/apps/web/src/app/(marketing)/legal/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; +import { FaArrowRightLong } from "react-icons/fa6"; import { env } from "~/env"; export const metadata: Metadata = { @@ -80,9 +81,7 @@ export default function LegalPage() {

{description}

- - - + Updated {updated}
diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index a1ee24f..b3a69ce 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -2,29 +2,19 @@ import Image from "next/image"; import Link from "next/link"; import { redirect } from "next/navigation"; import { Button } from "@bytesend/ui/src/button"; +import { PricingCalculator } from "~/components/marketing/PricingCalculator"; +import { CallToAction } from "~/components/marketing/CallToAction"; +import { ComparisonTable } from "~/components/marketing/Comparison"; +import { TrustStrip } from "~/components/marketing/TrustStrip"; +import { Pricing } from "~/components/marketing/PricingSection"; +import { DevSection } from "~/components/marketing/DevSection"; +import { Features } from "~/components/marketing/Features"; +import { Hero } from "~/components/marketing/Hero"; + import { env } from "~/env"; export const dynamic = "force-static"; -const APP_URL = "/login"; - -// Static code snippet shown in the developer section (no Shiki needed) -const TS_SNIPPET = `import { ByteSend } from "bytesend-js"; - -const client = new ByteSend("bs_••••••••"); - -await client.emails.send({ - to: "user@acme.com", - from: "noreply@yourapp.com", - subject: "Welcome to Acme!", - html: "

Welcome aboard 🎉

", - text: "Welcome aboard!", -}); - -// → { id: "em_abc123", success: true }`; - -/* ─────────────────────── Page ─────────────────────── */ - export default function Page() { if (!env.NEXT_PUBLIC_IS_CLOUD) redirect("/login"); @@ -36,542 +26,7 @@ export default function Page() { - + ); -} - -/* ─────────────────────── Hero ─────────────────────── */ - -function Hero() { - return ( -
-
- {/* Badge */} -
- - Open source · Self-hostable · Free tier included -
- -

- Email infrastructure -
- that just works -

- -

- Transactional emails, marketing campaigns, and analytics. - One platform, one bill. Start free and pay only for what you send. -

- -
- - -
- -

- Free forever · No credit card · Self-host with Docker -

- - {/* Dashboard screenshot */} -
-
-
- ByteSend dashboard - ByteSend dashboard -
-
-
-
-
- ); -} - -/* ─────────────────────── Trust Strip ─────────────────────── */ - -function TrustStrip() { - const stats = [ - { value: "12,500", label: "Free emails/month" }, - { value: "96.6%", label: "Uptime SLA" }, - { value: "<2s", label: "Avg. delivery" }, - { value: "24/7", label: "Monitoring" }, - ]; - - return ( -
-
-
- {stats.map((s) => ( -
-
{s.value}
-
{s.label}
-
- ))} -
-
-
- ); -} - -/* ─────────────────────── Features ─────────────────────── */ - -const features = [ - { - icon: BarChartIcon, - title: "Real-time analytics", - description: - "Track deliveries, opens, clicks, bounces, and complaints as they happen. Full visibility across transactional and marketing sends.", - accent: "bg-blue-500/10 text-blue-500", - }, - { - icon: PaintbrushIcon, - title: "Visual email editor", - description: - "Design beautiful campaigns with a drag-and-drop WYSIWYG editor. No code, no external tools — works for developers and non-technical teams alike.", - accent: "bg-purple-500/10 text-purple-500", - }, - { - icon: UsersIcon, - title: "Contact management", - description: "Manage subscribers, consent, and lists. Auto-updated from bounce and complaint events.", - accent: "bg-emerald-500/10 text-emerald-500", - }, - { - icon: ShieldIcon, - title: "Suppression lists", - description: "Block accidental sends. Auto-populated from bounces and spam complaints.", - accent: "bg-amber-500/10 text-amber-500", - }, - { - icon: ServerIcon, - title: "SMTP relay", - description: "Drop-in SMTP that works with any existing app. Change one config line and you're sending through ByteSend.", - accent: "bg-rose-500/10 text-rose-500", - }, - { - icon: WebhookIcon, - title: "Webhooks", - description: "Real-time event push for every email event. Build automations on top of delivery, opens, clicks, and more.", - accent: "bg-cyan-500/10 text-cyan-500", - }, -]; - -function Features() { - return ( -
-
-
-

Features

-

- Everything you need to send email -

-

- Transactional receipts, marketing campaigns, and everything in between — one platform, one bill. -

-
- -
- {features.map((f, i) => ( -
-
- -
-

{f.title}

-

{f.description}

-
- ))} -
-
-
- ); -} - -/* ─────────────────────── Developer Section ─────────────────────── */ - -function DevSection() { - return ( -
-
-
- {/* Left: text */} -
-

Developer-first

-

- Up and running
in minutes -

-

- Typed SDK for TypeScript. Simple REST API for every language. Fully documented, - consistently designed, with no surprises. -

- -
    - {[ - "TypeScript SDK with full type safety", - "Simple REST API with Bearer auth", - "Webhooks for real-time event delivery", - "Drop-in SMTP relay — one config change", - "OpenAPI spec included", - ].map((item) => ( -
  • - - {item} -
  • - ))} -
- -
- - -
-
- - {/* Right: code block */} -
-
-
- - - -
- send-email.ts -
-
-              {TS_SNIPPET}
-            
-
-
-
-
- ); -} - -/* ─────────────────────── Pricing ─────────────────────── */ - -const pricingPlans: { - name: string; - price: string; - period: string; - cta: string; - popular?: boolean; - href?: string; -}[] = [ - { name: "Free", price: "CA$0", period: "forever", cta: "Get started free", href: APP_URL }, - { name: "Hobby", price: "CA$5", period: "/mo", cta: "Start with Hobby", href: APP_URL }, - { name: "Lite", price: "CA$10", period: "/mo", cta: "Start with Lite", href: APP_URL, popular: true }, -]; - -// Values align with pricingPlans order: [Free, Hobby, Lite] -const pricingFeatures: { label: string; values: (string | boolean)[] }[] = [ - { label: "Monthly emails included", values: ["12,500", "25,000", "50,000"] }, - { label: "Daily email limit", values: ["5,000", "12,500", "25,000"] }, - { label: "Transactional emails", values: ["Included", "Included", "Included"] }, - { label: "Marketing emails", values: [false, "CA$0.05/ea†", "CA$0.02/ea†"] }, - { label: "Domains", values: ["2 + $1/extra", "4 + $1/extra", "6 + $1/extra"] }, - { label: "Members per team", values: ["5", "10", "15"] }, - { label: "Contacts", values: ["100", "200", "300"] }, - { label: "Priority support", values: [false, false, true] }, -]; - -function Pricing() { - return ( -
-
-
-

Pricing

-

- Simple, transparent pricing -

-

- Every plan includes transactional emails. Paid plans unlock marketing email sending. - Usage-based overage rates apply only after the included monthly limit is reached. - Free plan is a hard cap — no overage billing. -

-
- -
- {pricingPlans.map((plan, planIdx) => ( -
- {plan.popular && ( -
- Most popular -
- )} - -
-

{plan.name}

-
- {plan.price} - {plan.period} -
-
- -
    - {pricingFeatures.map((row) => { - const val = row.values[planIdx]; - if (typeof val === "boolean" && !val) return null; - return ( -
  • - - - {typeof val === "boolean" ? row.label : ( - <> - {val}{" "} - {row.label.toLowerCase()} - - )} - -
  • - ); - })} -
- - -
- ))} -
- - {/* Custom / Enterprise row */} -
-
-

Need more? Custom plans available.

-

- Higher volumes, dedicated infrastructure, SLA guarantees, or a white-label deployment we'll build a plan that fits. -

-
- -
- -

- † Overage rates apply only after the plan's included monthly email limit is reached. Free plan is a hard cap no overage billing. -

-
-
- ); -} - -/* ─────────────────────── Comparison Table ─────────────────────── */ - -const comparisonRows: { feature: string; bs: string | boolean; resend: string | boolean; sendgrid: string | boolean; postmark: string | boolean; ses: string | boolean }[] = [ - { feature: "Free tier", bs: "12,500/mo", resend: "3,000/mo", sendgrid: "100/day", postmark: "100 test/mo", ses: "Pay-per-use" }, - { feature: "Marketing campaigns", bs: true, resend: false, sendgrid: true, postmark: false, ses: false }, - { feature: "Visual email editor", bs: true, resend: false, sendgrid: "Basic", postmark: false, ses: false }, - { feature: "Self-hostable", bs: true, resend: false, sendgrid: false, postmark: false, ses: false }, - { feature: "Contact management", bs: true, resend: false, sendgrid: true, postmark: "Lists only", ses: false }, - { feature: "Webhooks", bs: true, resend: true, sendgrid: true, postmark: true, ses: false }, - { feature: "SMTP relay", bs: true, resend: true, sendgrid: true, postmark: true, ses: true }, - { feature: "Analytics dashboard", bs: true, resend: "Basic", sendgrid: true, postmark: "Basic", ses: false }, - { feature: "Custom plans", bs: true, resend: false, sendgrid: false, postmark: false, ses: false }, -]; - -function CompCell({ val }: { val: string | boolean }) { - if (val === true) return ; - if (val === false) return ; - return {val}; -} - -function ComparisonTable() { - const cols = ["ByteSend", "Resend", "SendGrid", "Postmark", "AWS SES"] as const; - const vals = (row: (typeof comparisonRows)[0]) => [row.bs, row.resend, row.sendgrid, row.postmark, row.ses] as (string | boolean)[]; - - return ( -
-
-
-

Compare

-

Why teams choose ByteSend

-

- See how we stack up against Resend, SendGrid, Postmark, and AWS SES. -

-
- -
- - - - - {cols.map((col, i) => ( - - ))} - - - - {comparisonRows.map((row, ri) => ( - - - {vals(row).map((val, vi) => ( - - ))} - - ))} - -
Feature - {col} -
{row.feature} - -
-
- -
-

Ready to switch? Migration takes minutes.

- -
-
-
- ); -} - -/* ─────────────────────── CTA ─────────────────────── */ - -function Cta() { - return ( -
-
-
- - Free tier available — no credit card required -
- -

- Start sending today. -

-

- Create your free account in seconds. 12,500 emails per month, forever. -

- -
- - -
- -

- Also available as a Docker container for self-hosting.{" "} - - Learn more → - -

-
-
- ); -} - -/* ─────────────────────── Icons ─────────────────────── */ - -function CheckIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function BarChartIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function PaintbrushIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function UsersIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function ShieldIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function ServerIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} - -function WebhookIcon({ className = "" }: { className?: string }) { - return ( - - - - ); -} +} \ No newline at end of file diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx index a7e0bf1..9e9acfd 100644 --- a/apps/web/src/app/error.tsx +++ b/apps/web/src/app/error.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { FaArrowLeftLong, FaRotateRight, FaTriangleExclamation } from "react-icons/fa6"; export default function GlobalError({ error, @@ -25,19 +26,7 @@ export default function GlobalError({
- - - +
@@ -62,18 +51,14 @@ export default function GlobalError({ onClick={reset} className="inline-flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors shadow-lg shadow-primary/20" > - - - + Try again - - - + Home
diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx index 9cfaa76..7905e25 100644 --- a/apps/web/src/app/login/login-page.tsx +++ b/apps/web/src/app/login/login-page.tsx @@ -9,28 +9,14 @@ import { BuiltInProviderType } from "next-auth/providers/index"; import Spinner from "@bytesend/ui/src/spinner"; import Link from "next/link"; import { useSearchParams as useNextSearchParams } from "next/navigation"; +import { FaDiscord, FaEnvelope, FaGithub, FaGoogle } from "react-icons/fa6"; const SESSION_EMAIL_KEY = "bytesend_login_email"; const providerIcons: Record = { - github: ( - - - - ), - google: ( - - - - - - - ), - discord: ( - - - - ), + github: , + google: , + discord: , }; export default function LoginPage({ @@ -206,19 +192,7 @@ export default function LoginPage({
- - - +

Check your email

@@ -330,9 +304,7 @@ export default function LoginPage({ ) : ( <> - - - + Continue with email )} diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx index 4ba85f0..1eb1bcb 100644 --- a/apps/web/src/app/not-found.tsx +++ b/apps/web/src/app/not-found.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { FaArrowLeftLong, FaHouse } from "react-icons/fa6"; export default function NotFound() { return ( @@ -34,19 +35,14 @@ export default function NotFound() { href="/dashboard" className="inline-flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors shadow-lg shadow-primary/20" > - - - - + Dashboard - - - + Home

diff --git a/apps/web/src/components/AppSideBar.tsx b/apps/web/src/components/AppSideBar.tsx index 79c6636..3c0ddba 100644 --- a/apps/web/src/components/AppSideBar.tsx +++ b/apps/web/src/components/AppSideBar.tsx @@ -23,23 +23,10 @@ import { ChevronsUpDown, Check, PlusIcon, - LockIcon, + ScrollText, } from "lucide-react"; +import { SiDiscord } from "react-icons/si"; import { useEffect, useState } from "react"; - -function DiscordIcon({ className }: { className?: string }) { - return ( - - - - ); -} import { signOut } from "next-auth/react"; import { useTeam } from "~/providers/team-context"; @@ -75,20 +62,28 @@ import { } from "@bytesend/ui/src/dropdown-menu"; import { FeedbackDialog } from "./FeedbackDialog"; import { env } from "~/env"; -import { useUpgradeModalStore } from "~/store/upgradeModalStore"; -import { LimitReason } from "~/lib/constants/plans"; // General items const generalItems = [ + { + title: "Emails", + url: "/emails", + icon: Mail, + }, { title: "Analytics", url: "/dashboard", icon: BarChart3, }, { - title: "Emails", - url: "/emails", - icon: Mail, + title: "Domains", + url: "/domains", + icon: Globe, + }, + { + title: "Webhooks", + url: "/webhooks", + icon: Webhook, }, { title: "Templates", @@ -100,6 +95,16 @@ const generalItems = [ url: "/suppressions", icon: UserRoundX, }, + { + title: "Audit Logs", + url: "/logs", + icon: ScrollText, + }, + { + title: "Settings", + url: "/settings", + icon: Cog, + } ]; // Marketing items @@ -110,33 +115,14 @@ const marketingItems = [ icon: BookUser, }, { - title: "Campaigns", - url: "/campaigns", + title: "Broadcasts", + url: "/broadcasts", icon: Volume2, }, -]; - -// Settings items -const settingsItems = [ { - title: "Domains", - url: "/domains", - icon: Globe, - }, - { - title: "Webhooks", - url: "/webhooks", - icon: Webhook, - }, - { - title: "Developer settings", - url: "/dev-settings", - icon: Code, - }, - { - title: "Team Settings", - url: "/settings", - icon: Cog, + title: "Campaigns", + url: "/campaigns", + icon: BookOpenText, }, ]; @@ -144,9 +130,6 @@ export function AppSidebar() { const { data: session } = useSession(); const showFeedback = isCloud(); const { currentTeam, teams, setCurrentTeam } = useTeam(); - const openUpgradeModal = useUpgradeModalStore((s) => s.action.openModal); - - const isMarketingLocked = isCloud() && currentTeam?.plan === "FREE"; const pathname = usePathname(); @@ -270,111 +253,33 @@ export function AppSidebar() { Marketing - {isMarketingLocked && ( - - Paid - - )} {marketingItems.map((item) => { - const isActive = !isMarketingLocked && pathname?.startsWith(item.url); + const isActive = pathname?.startsWith(item.url); return ( - {isMarketingLocked ? ( - openUpgradeModal(LimitReason.MARKETING_NOT_AVAILABLE)} - > + + {item.title} - - - ) : ( - - - - {item.title} - - - )} + + ); })} - - - Settings - - - - {settingsItems.map((item) => { - const isActive = pathname?.startsWith(item.url); - return ( - - - - - {item.title} - - - - ); - })} - - - - - {(session?.user.isAdmin || isSelfHosted()) && ( - - - Admin - - - - - - - - Admin Panel - - - - - - - )} - - {/* Pinned-to-bottom links inside the scrollable content zone */} - - - - - Documentation - - - {showFeedback ? ( - + Discord @@ -425,7 +330,9 @@ export function NavUser({ avatar?: string | null; }; }) { + const { data: session } = useSession(); const { isMobile } = useSidebar(); + const canAccessAdminPanel = isSelfHosted() || Boolean(session?.user?.isAdmin); return ( @@ -494,22 +401,24 @@ export function NavUser({ Home + {canAccessAdminPanel && ( + + + + Admin Panel + + + )} - - - Team - - - - + - Usage + Usage Breakdown - Docs + Documentation
diff --git a/apps/web/src/components/marketing/CallToAction.tsx b/apps/web/src/components/marketing/CallToAction.tsx new file mode 100644 index 0000000..a86b1d8 --- /dev/null +++ b/apps/web/src/components/marketing/CallToAction.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { Button } from "@bytesend/ui/src/button"; + +export function CallToAction() { + + const APP_URL = "/login"; + + return ( +
+
+
+ + Free tier available — no credit card required +
+ +

+ Start sending today. +

+

+ Create your free account in seconds. 12,500 emails per month, forever. +

+ +
+ + +
+ +

+ Also available as a Docker container for self-hosting.{" "} + + Learn more → + +

+
+
+ ); +} + +export default CallToAction; diff --git a/apps/web/src/components/marketing/CodeExample.tsx b/apps/web/src/components/marketing/CodeExample.tsx deleted file mode 100644 index 7e694f1..0000000 --- a/apps/web/src/components/marketing/CodeExample.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { Button } from "@bytesend/ui/src/button"; -import { CodeBlock } from "@bytesend/ui/src/code-block"; -import { CodeBlockWithCopy } from "@bytesend/ui/src/code-block-with-copy"; -import { LangToggle } from "./CodeLangToggle"; - -const TS_CODE = `import { ByteSend } from "bytesend-js"; - -const client = new ByteSend("bs_12345"); - -client.emails.send({ - to: "hello@acme.com", - from: "hello@company.com", - subject: "Hello from ByteSend", - html: "

Sending emails has never been this easy with ByteSend.

", - text: "Sending emails has never been this easy with ByteSend.", -});`; - -const PY_CODE = `from bytesend import ByteSend - -client = ByteSend("bs_12345") - -data, err = client.emails.send({ - "to": "hello@acme.com", - "from": "hello@company.com", - "subject": "Hello from ByteSend", - "html": "

Sending emails has never been this easy with ByteSend.

", - "text": "Sending emails has never been this easy with ByteSend.", -}) - -print(data or err)`; - -const GO_CODE = `package main - -import ( - "fmt" - "io" - "net/http" - "strings" -) - -func main() { - url := "https://bytesend.cloud/api/v1/emails" - - payload := strings.NewReader(\`{ - "to": "hello@acme.com", - "from": "hello@company.com", - "subject": "Hello from ByteSend", - "html": "

Sending emails has never been this easy.

", - "text": "Sending emails has never been this easy." - }\`) - - req, _ := http.NewRequest("POST", url, payload) - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Authorization", "Bearer bs_12345") - - res, _ := http.DefaultClient.Do(req) - defer res.Body.Close() - - body, _ := io.ReadAll(res.Body) - fmt.Println(res) - fmt.Println(string(body)) -}`; - -const PHP_CODE = ` true, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Authorization: Bearer bs_12345', - ], - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode([ - 'to' => 'hello@acme.com', - 'from' => 'hello@company.com', - 'subject' => 'Hello from ByteSend', - 'html' => '

Sending emails has never been this easy.

', - 'text' => 'Sending emails has never been this easy.', - ]), -]); - -$response = curl_exec($ch); -if ($response === false) { - echo 'cURL error: ' . curl_error($ch); -} else { - echo $response; -} -curl_close($ch);`; - -export function CodeExample() { - const containerId = "code-example"; - const languages = [ - { key: "ts", label: "TypeScript", kind: "ts", shiki: "typescript" as const, code: TS_CODE }, - //{ key: "py", label: "Python", kind: "py", shiki: "python" as const, code: PY_CODE }, - //{ key: "go", label: "Go", kind: "go", shiki: "go" as const, code: GO_CODE }, - //{ key: "php", label: "PHP", kind: "php", shiki: "php" as const, code: PHP_CODE }, - ]; - - return ( -
-
-
-
- Developers -
-

- Typed SDKs and simple APIs, so you can focus on product not plumbing. -

-
- -
-
- ({ key, label, kind }))} - /> -
-
-
-
- {languages.map((l, idx) => ( -
- - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {l.code} - - -
- ))} -
-
-
-
- Language example toggled -
-
- - -
-
- ); -} - -export default CodeExample; diff --git a/apps/web/src/components/marketing/CodeLangToggle.tsx b/apps/web/src/components/marketing/CodeLangToggle.tsx index cbb2112..1ee45ad 100644 --- a/apps/web/src/components/marketing/CodeLangToggle.tsx +++ b/apps/web/src/components/marketing/CodeLangToggle.tsx @@ -1,8 +1,15 @@ "use client"; import { useEffect, useState } from "react"; -import Image from "next/image"; -import { Button } from "@bytesend/ui/src/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bytesend/ui/src/select"; +import { FaRegCircleDot } from "react-icons/fa6"; +import { SiGo, SiPhp, SiPython, SiRuby, SiRust, SiTypescript } from "react-icons/si"; type LangItem = { key: string; @@ -41,65 +48,41 @@ export function LangToggle({ }, [active, containerId]); return ( -
- {languages.map((l) => ( - - ))} -
+ ); } function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: string }) { - const [failed, setFailed] = useState(false); - if (failed) { - return ( - - ); - } + const iconMap = { + ts: SiTypescript, + py: SiPython, + go: SiGo, + php: SiPhp, + rs: SiRust, + rb: SiRuby, + } as const; - const iconMap: Record = { - ts: { src: "/typescript.svg", alt: "TypeScript logo" }, - py: { src: "/python.svg", alt: "Python logo" }, - go: { src: "/go.svg", alt: "Go logo" }, - php: { src: "/php.svg", alt: "PHP logo" }, - }; + const Icon = iconMap[kind as keyof typeof iconMap]; - const icon = iconMap[kind]; - if (icon) { - return ( - {icon.alt} setFailed(true)} - /> - ); + if (!Icon) { + return