From 691d0a22d2d8c4bb85ecae108a55ecbb18665b08 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 10 May 2026 01:20:58 -0600 Subject: [PATCH 1/4] new components, pricing structures and more --- .vscode/settings.json | 7 +- apps/docs/api-reference/introduction.mdx | 11 + apps/docs/api-reference/openapi.json | 57 ++ apps/docs/api-reference/smtp/auth.mdx | 88 +++ apps/docs/docs.json | 22 +- apps/docs/get-started/go.mdx | 402 +++++++++-- .../docker.mdx} | 26 +- apps/docs/self-hosting/overview.mdx | 39 +- apps/docs/self-hosting/smtp-server.mdx | 76 +- apps/web/package.json | 3 +- apps/web/scripts/stripe-seed.ts | 161 +++-- .../src/app/(dashboard)/admin/teams/page.tsx | 4 +- .../(dashboard)/campaigns/campaign-list.tsx | 12 +- .../dev-settings/api-keys/page.tsx | 18 +- .../app/(dashboard)/dev-settings/layout.tsx | 4 +- .../src/app/(dashboard)/dev-settings/page.tsx | 21 +- .../(dashboard)/dev-settings/smtp/page.tsx | 19 +- .../(dashboard)/settings/api-keys/page.tsx | 22 + .../app/(dashboard)/settings/billing/page.tsx | 116 +-- .../src/app/(dashboard)/settings/layout.tsx | 2 + .../app/(dashboard)/settings/smtp/page.tsx | 18 + .../settings/team/invite-team-member.tsx | 8 +- apps/web/src/app/(marketing)/legal/page.tsx | 5 +- apps/web/src/app/(marketing)/page.tsx | 567 +-------------- apps/web/src/app/error.tsx | 23 +- apps/web/src/app/login/login-page.tsx | 40 +- apps/web/src/app/not-found.tsx | 10 +- apps/web/src/components/AppSideBar.tsx | 138 +--- .../src/components/marketing/CallToAction.tsx | 48 ++ .../src/components/marketing/CodeExample.tsx | 157 ----- .../components/marketing/CodeLangToggle.tsx | 27 +- .../src/components/marketing/Comparison.tsx | 91 +++ .../src/components/marketing/DevSection.tsx | 222 ++++++ .../src/components/marketing/FeatureCard.tsx | 86 --- .../components/marketing/FeatureCardPlain.tsx | 28 - .../web/src/components/marketing/Features.tsx | 89 +++ apps/web/src/components/marketing/Hero.tsx | 77 ++ .../src/components/marketing/HomeIcons.tsx | 40 ++ .../marketing/PricingCalculator.tsx | 113 ++- .../components/marketing/PricingSection.tsx | 46 ++ .../src/components/marketing/PricingTiers.tsx | 267 ------- .../src/components/marketing/SiteFooter.tsx | 35 +- .../src/components/marketing/TopNavClient.tsx | 9 +- .../src/components/marketing/TrustStrip.tsx | 30 + .../src/components/payments/PlanDetails.tsx | 13 +- .../src/components/payments/UpgradeButton.tsx | 1 + .../src/components/payments/UpgradeModal.tsx | 77 +- apps/web/src/lib/constants/payments.ts | 20 +- apps/web/src/lib/constants/plans.ts | 8 +- apps/web/src/server/api/routers/billing.ts | 2 +- apps/web/src/server/service/limit-service.ts | 5 +- packages/go-sdk/.gitignore | 1 + packages/go-sdk/LICENSE | 660 ++++++++++++++++++ packages/go-sdk/README.md | 492 +++++++++++++ packages/go-sdk/analytics.go | 67 ++ packages/go-sdk/campaigns.go | 134 ++++ packages/go-sdk/client.go | 153 ++++ packages/go-sdk/contact_books.go | 85 +++ packages/go-sdk/contacts.go | 117 ++++ packages/go-sdk/domains.go | 84 +++ packages/go-sdk/emails.go | 151 ++++ packages/go-sdk/go.mod | 3 + packages/go-sdk/types.go | 45 ++ packages/lib/src/stripe/plans.ts | 28 +- packages/lib/src/stripe/products.ts | 23 +- packages/lib/src/stripe/seed.ts | 51 +- 66 files changed, 3721 insertions(+), 1783 deletions(-) create mode 100644 apps/docs/api-reference/smtp/auth.mdx rename apps/docs/{get-started/set-up-docker.mdx => self-hosting/docker.mdx} (87%) create mode 100644 apps/web/src/app/(dashboard)/settings/api-keys/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/smtp/page.tsx create mode 100644 apps/web/src/components/marketing/CallToAction.tsx delete mode 100644 apps/web/src/components/marketing/CodeExample.tsx create mode 100644 apps/web/src/components/marketing/Comparison.tsx create mode 100644 apps/web/src/components/marketing/DevSection.tsx delete mode 100644 apps/web/src/components/marketing/FeatureCard.tsx delete mode 100644 apps/web/src/components/marketing/FeatureCardPlain.tsx create mode 100644 apps/web/src/components/marketing/Features.tsx create mode 100644 apps/web/src/components/marketing/Hero.tsx create mode 100644 apps/web/src/components/marketing/HomeIcons.tsx create mode 100644 apps/web/src/components/marketing/PricingSection.tsx delete mode 100644 apps/web/src/components/marketing/PricingTiers.tsx create mode 100644 apps/web/src/components/marketing/TrustStrip.tsx create mode 100644 packages/go-sdk/.gitignore create mode 100644 packages/go-sdk/LICENSE create mode 100644 packages/go-sdk/README.md create mode 100644 packages/go-sdk/analytics.go create mode 100644 packages/go-sdk/campaigns.go create mode 100644 packages/go-sdk/client.go create mode 100644 packages/go-sdk/contact_books.go create mode 100644 packages/go-sdk/contacts.go create mode 100644 packages/go-sdk/domains.go create mode 100644 packages/go-sdk/emails.go create mode 100644 packages/go-sdk/go.mod create mode 100644 packages/go-sdk/types.go 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/apps/docs/api-reference/introduction.mdx b/apps/docs/api-reference/introduction.mdx index 8df9825..15bad36 100644 --- a/apps/docs/api-reference/introduction.mdx +++ b/apps/docs/api-reference/introduction.mdx @@ -22,3 +22,14 @@ Authorization: Bearer bs_12345 ``` You can create a new token/API key under your ByteSend [Developer Settings](https://bytesend.cloud/dev-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..a9fb9df 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 +- Go 1.21+ - [ByteSend API key](https://bytesend.cloud/dev-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/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/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..a731140 100644 --- a/apps/web/src/app/(dashboard)/admin/teams/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/teams/page.tsx @@ -427,7 +427,7 @@ export default function AdminTeamsPage() { Free Hobby Lite - Professional + Pro Lifetime @@ -498,7 +498,7 @@ export default function AdminTeamsPage() { Free Hobby — CA$5/mo Lite — CA$10/mo - Professional — CA$20/mo + Pro — CA$30/mo Lifetime — CA$199 one-time diff --git a/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx b/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx index cac5b85..7f220fe 100644 --- a/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx @@ -104,18 +104,10 @@ export default function CampaignList() { campaignsQuery.data?.campaigns.map((campaign) => ( )) +import { FaBullhorn } from "react-icons/fa6"; ) : (
- - - +

{search || status ? "No campaigns match your filters" : "No campaigns yet"}

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)/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..5b18ac6 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -9,18 +9,16 @@ 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 { + UpgradeButton, + type CheckoutPlan, +} 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" }, -]; +const BILLING_PLAN_OPTIONS = getAllPlans().filter( + (plan) => plan.plan !== "FREE", +); function formatPlanPrice(plan: (typeof PLANS)[keyof typeof PLANS]): string { if (plan.oneTimePrice) return `CA$${plan.oneTimePrice / 100} one-time`; @@ -102,44 +100,72 @@ 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

+

+ Plan details and checkout are managed here for consistency. Homepage pricing is estimate-only. +

+
+ {BILLING_PLAN_OPTIONS.map((planData) => { + const plan = planData.plan as CheckoutPlan; + const perks = PLAN_PERKS[plan] ?? []; + const isCurrent = currentTeam.plan === plan; + const highlight = plan === "LITE" || plan === "BASIC"; + + return ( +
+
+ {planData.displayName} + + {formatPlanPrice(planData)} +
- ); - })} -
+
    + {perks.map((perk, i) => ( +
  • + + {perk} +
  • + ))} +
+ + {isCurrent ? ( + + ) : currentTeam.plan === "FREE" ? ( + + ) : ( + + )} +
+ ); + })}
- )} +
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/(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..42a076e 100644 --- a/apps/web/src/components/AppSideBar.tsx +++ b/apps/web/src/components/AppSideBar.tsx @@ -25,21 +25,8 @@ import { PlusIcon, LockIcon, } 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"; @@ -80,16 +67,21 @@ import { LimitReason } from "~/lib/constants/plans"; // General items const generalItems = [ - { - 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", url: "/templates", @@ -100,6 +92,16 @@ const generalItems = [ url: "/suppressions", icon: UserRoundX, }, + { + title: "Analytics", + url: "/dashboard", + icon: BarChart3, + }, + { + title: "Settings", + url: "/settings", + icon: Cog, + } ]; // Marketing items @@ -116,30 +118,6 @@ const marketingItems = [ }, ]; -// 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, - }, -]; - export function AppSidebar() { const { data: session } = useSession(); const showFeedback = isCloud(); @@ -312,69 +290,9 @@ export function AppSidebar() { - - - 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 @@ -495,21 +413,21 @@ export function NavUser({ - - - Team + + + Admin Panel - + - 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..97b7b38 100644 --- a/apps/web/src/components/marketing/CodeLangToggle.tsx +++ b/apps/web/src/components/marketing/CodeLangToggle.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Image from "next/image"; import { Button } from "@bytesend/ui/src/button"; +import { FaRegCircleDot } from "react-icons/fa6"; type LangItem = { key: string; @@ -41,21 +42,23 @@ export function LangToggle({ }, [active, containerId]); return ( -
+
{languages.map((l) => ( ))} @@ -66,11 +69,7 @@ export function LangToggle({ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: string }) { const [failed, setFailed] = useState(false); if (failed) { - return ( - - ); + return