diff --git a/apps/web/package.json b/apps/web/package.json index fa54891..06bcc50 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@trpc/react-query": "next", "@trpc/server": "next", "@unsend/ui": "workspace:*", + "date-fns": "^3.6.0", "install": "^0.13.0", "lucide-react": "^0.359.0", "next": "^14.1.3", diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 8f1fd3e..f19661d 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -101,17 +101,19 @@ enum DomainStatus { } model Domain { - id Int @id @default(autoincrement()) - name String @unique - teamId Int - status DomainStatus @default(PENDING) - region String @default("us-east-1") - publicKey String - dkimStatus String? - spfDetails String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + name String @unique + teamId Int + status DomainStatus @default(PENDING) + region String @default("us-east-1") + clickTracking Boolean @default(false) + openTracking Boolean @default(false) + publicKey String + dkimStatus String? + spfDetails String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) } enum ApiPermission { diff --git a/apps/web/src/app/(dashboard)/domains/domain-list.tsx b/apps/web/src/app/(dashboard)/domains/domain-list.tsx index a13b8ed..24884f8 100644 --- a/apps/web/src/app/(dashboard)/domains/domain-list.tsx +++ b/apps/web/src/app/(dashboard)/domains/domain-list.tsx @@ -1,6 +1,9 @@ "use client"; +import { DomainStatus } from "@prisma/client"; +import { formatDistanceToNow } from "date-fns"; import Link from "next/link"; +import { Switch } from "@unsend/ui/src/switch"; import { api } from "~/trpc/react"; export default function DomainsList() { @@ -8,15 +11,54 @@ export default function DomainsList() { return (
-
+
{!domainsQuery.isLoading && domainsQuery.data?.length ? ( domainsQuery.data?.map((domain) => ( - -
-

{domain.name}

-

{domain.status.toLowerCase()}

+
+
+ +
+
+ + {domain.name} + + +
+
+
+

+ Created at +

+

+ {formatDistanceToNow(new Date(domain.createdAt), { + addSuffix: true, + })} +

+
+
+

Region

+ +

+ 🇺🇸 {domain.region} +

+
+
+
+
+

Click tracking

+ +
+
+

Open tracking

+ +
+
+
- +
)) ) : (
No domains
@@ -25,3 +67,55 @@ export default function DomainsList() {
); } + +const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({ status }) => { + let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color + switch (status) { + case DomainStatus.NOT_STARTED: + badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; + break; + case DomainStatus.SUCCESS: + badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"; + break; + case DomainStatus.FAILED: + badgeColor = "bg-red-500/10 text-red-800 border-red-600/10"; + break; + case DomainStatus.TEMPORARY_FAILURE: + case DomainStatus.PENDING: + badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"; + break; + default: + badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; + } + + return ( +
+ {status === "SUCCESS" ? "Verified" : status.toLowerCase()} +
+ ); +}; + +const StatusIndicator: React.FC<{ status: DomainStatus }> = ({ status }) => { + let badgeColor = "bg-gray-400"; // Default color + switch (status) { + case DomainStatus.NOT_STARTED: + badgeColor = "bg-gray-400"; + break; + case DomainStatus.SUCCESS: + badgeColor = "bg-emerald-500"; + break; + case DomainStatus.FAILED: + badgeColor = "bg-red-500"; + break; + case DomainStatus.TEMPORARY_FAILURE: + case DomainStatus.PENDING: + badgeColor = "bg-yellow-500"; + break; + default: + badgeColor = "bg-gray-400"; + } + + return
; +}; diff --git a/apps/web/src/app/(dashboard)/emails/email-list.tsx b/apps/web/src/app/(dashboard)/emails/email-list.tsx index dec95b0..57d6091 100644 --- a/apps/web/src/app/(dashboard)/emails/email-list.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-list.tsx @@ -1,31 +1,149 @@ "use client"; import Link from "next/link"; +import { + Table, + TableCaption, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@unsend/ui/src/table"; +import { Badge } from "@unsend/ui/src/badge"; import { api } from "~/trpc/react"; +import { + Mail, + MailCheck, + MailOpen, + MailSearch, + MailWarning, + MailX, +} from "lucide-react"; +import { formatDistance, formatDistanceToNow } from "date-fns"; -export default function DomainsList() { +export default function EmailsList() { const emailsQuery = api.email.emails.useQuery(); return (
-
- {!emailsQuery.isLoading && emailsQuery.data?.length ? ( - emailsQuery.data?.map((email) => ( - -
-

{email.to}

-

- {email.latestStatus?.toLowerCase()} -

-

{email.subject}

-

{email.createdAt.toLocaleDateString()}

-
- - )) - ) : ( -
No domains
- )} +
+ + + + To + Status + Subject + + Sent at + + + + + {emailsQuery.data?.map((email) => ( + + + +

{email.to}

+
+ + + {/* + {email.latestStatus ?? "Sent"} + */} + + {email.subject} + + {formatDistanceToNow(email.createdAt, { addSuffix: true })} + +
+ ))} +
+
); } + +const EmailIcon: React.FC<{ status: string }> = ({ status }) => { + switch (status) { + case "Send": + return ( + //
+ + //
+ ); + case "Delivery": + case "Delayed": + return ( + //
+ + //
+ ); + case "Bounced": + return ( + //
+ + //
+ ); + case "Clicked": + return ( + //
+ + //
+ ); + case "Opened": + return ( + //
+ + //
+ ); + case "Complained": + return ( + //
+ + //
+ ); + default: + return ( + //
+ + //
+ ); + } +}; + +const EmailStatusBadge: React.FC<{ status: string }> = ({ status }) => { + let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color + switch (status) { + case "Send": + badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; + break; + case "Delivery": + case "Delayed": + badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"; + break; + case "Bounced": + badgeColor = "bg-red-500/10 text-red-800 border-red-600/10"; + break; + case "Clicked": + badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10"; + break; + case "Opened": + badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10"; + break; + case "Complained": + badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"; + break; + default: + badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; + } + + return ( +
+ {status} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index a95f76e..66dd980 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -176,7 +176,7 @@ export default async function AuthenticatedDashboardLayout({ -
+
{children}
diff --git a/packages/ui/package.json b/packages/ui/package.json index 78ba31a..82beaa7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", "add": "^2.0.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/packages/ui/src/badge.tsx b/packages/ui/src/badge.tsx new file mode 100644 index 0000000..adb1108 --- /dev/null +++ b/packages/ui/src/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "../lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/ui/src/switch.tsx b/packages/ui/src/switch.tsx new file mode 100644 index 0000000..78ca024 --- /dev/null +++ b/packages/ui/src/switch.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { cn } from "../lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/packages/ui/src/table.tsx b/packages/ui/src/table.tsx new file mode 100644 index 0000000..6fbc81e --- /dev/null +++ b/packages/ui/src/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; + +import { cn } from "../lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css index 2c8e065..abc2f19 100644 --- a/packages/ui/styles/globals.css +++ b/packages/ui/styles/globals.css @@ -37,7 +37,7 @@ .dark { --background: 223 3% 3%; - --foreground: 210 40% 98%; + --foreground: 210 3% 82%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7f6220..cac2c23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@unsend/ui': specifier: workspace:* version: link:../../packages/ui + date-fns: + specifier: ^3.6.0 + version: 3.6.0 install: specifier: ^0.13.0 version: 0.13.0 @@ -257,6 +260,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-switch': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) add: specifier: ^2.0.6 version: 2.0.6 @@ -2201,6 +2207,33 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} peerDependencies: @@ -2259,6 +2292,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@types/react': 18.2.66 + react: 18.2.0 + dev: false + /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} peerDependencies: @@ -3661,6 +3708,10 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true + /date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + dev: false + /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: