@m5kdev/web-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +17 -0
  3. package/package.json +169 -0
  4. package/src/animations/card.motion.ts +9 -0
  5. package/src/components/AvatarUpload.tsx +133 -0
  6. package/src/components/Button.tsx +14 -0
  7. package/src/components/Calendar.css +684 -0
  8. package/src/components/Calendar.tsx +32 -0
  9. package/src/components/CardsSelect.tsx +155 -0
  10. package/src/components/CollapsibleSidebarMenuItem.tsx +57 -0
  11. package/src/components/ColorPicker.tsx +56 -0
  12. package/src/components/CopyButton.tsx +45 -0
  13. package/src/components/CropDialog.tsx +154 -0
  14. package/src/components/DialogProvider.tsx +105 -0
  15. package/src/components/ErrorFallback.tsx +17 -0
  16. package/src/components/FileDropzone.tsx +120 -0
  17. package/src/components/MultiSelectDropdown.tsx +233 -0
  18. package/src/components/Orb.tsx +288 -0
  19. package/src/components/PageAlert.tsx +121 -0
  20. package/src/components/SelectChips.tsx +40 -0
  21. package/src/components/SidebarItem.tsx +26 -0
  22. package/src/components/Steps.tsx +340 -0
  23. package/src/components/TablerIconPicker.tsx +4260 -0
  24. package/src/components/app-header.tsx +40 -0
  25. package/src/components/blur-card.tsx +132 -0
  26. package/src/components/features-section-demo-1.tsx +127 -0
  27. package/src/components/features-section-demo-2.tsx +102 -0
  28. package/src/components/features-section-demo-3.tsx +272 -0
  29. package/src/components/mode-toggle.tsx +31 -0
  30. package/src/components/nav-main.tsx +69 -0
  31. package/src/components/pricing-cards.tsx +133 -0
  32. package/src/components/shared/ButtonCopy.tsx +50 -0
  33. package/src/components/team-switcher.tsx +83 -0
  34. package/src/components/theme-provider.tsx +74 -0
  35. package/src/components/typewriter.tsx +90 -0
  36. package/src/components/ui/alert-dialog.tsx +133 -0
  37. package/src/components/ui/alert.tsx +60 -0
  38. package/src/components/ui/avatar.tsx +47 -0
  39. package/src/components/ui/badge.tsx +33 -0
  40. package/src/components/ui/bento-grid.tsx +54 -0
  41. package/src/components/ui/bento-grid2.tsx +66 -0
  42. package/src/components/ui/breadcrumb.tsx +101 -0
  43. package/src/components/ui/button.tsx +50 -0
  44. package/src/components/ui/card.tsx +55 -0
  45. package/src/components/ui/checkbox.tsx +26 -0
  46. package/src/components/ui/collapsible.tsx +9 -0
  47. package/src/components/ui/dialog.tsx +119 -0
  48. package/src/components/ui/dropdown-menu.tsx +186 -0
  49. package/src/components/ui/floating-navbar.tsx +78 -0
  50. package/src/components/ui/form.tsx +167 -0
  51. package/src/components/ui/image.tsx +55 -0
  52. package/src/components/ui/input.tsx +22 -0
  53. package/src/components/ui/label.tsx +19 -0
  54. package/src/components/ui/pagination.tsx +105 -0
  55. package/src/components/ui/progress.tsx +23 -0
  56. package/src/components/ui/resizable-navbar.tsx +260 -0
  57. package/src/components/ui/segment-control.tsx +143 -0
  58. package/src/components/ui/select.tsx +153 -0
  59. package/src/components/ui/separator.tsx +24 -0
  60. package/src/components/ui/sheet.tsx +121 -0
  61. package/src/components/ui/sidebar.tsx +736 -0
  62. package/src/components/ui/skeleton.tsx +7 -0
  63. package/src/components/ui/slider.tsx +23 -0
  64. package/src/components/ui/sonner.tsx +27 -0
  65. package/src/components/ui/spinner.tsx +45 -0
  66. package/src/components/ui/switch.tsx +27 -0
  67. package/src/components/ui/table.tsx +90 -0
  68. package/src/components/ui/tabs.tsx +52 -0
  69. package/src/components/ui/textarea.tsx +18 -0
  70. package/src/components/ui/timeline.tsx +95 -0
  71. package/src/components/ui/toast.tsx +126 -0
  72. package/src/components/ui/tooltip.tsx +55 -0
  73. package/src/components/ui/typewriter-effect.tsx +181 -0
  74. package/src/hooks/use-mobile.ts +19 -0
  75. package/src/hooks/useDialog.ts +25 -0
  76. package/src/icons/GoogleIcon.tsx +32 -0
  77. package/src/icons/LinkedInIcon.tsx +30 -0
  78. package/src/icons/MicrosoftIcon.tsx +21 -0
  79. package/src/lib/chatwoot.ts +51 -0
  80. package/src/lib/utils.ts +6 -0
  81. package/src/modules/app/components/AppLoader.tsx +9 -0
  82. package/src/modules/app/components/AppShell.tsx +21 -0
  83. package/src/modules/app/components/AppSidebar.tsx +26 -0
  84. package/src/modules/app/components/AppSidebarContent.tsx +73 -0
  85. package/src/modules/app/components/AppSidebarHeader.tsx +57 -0
  86. package/src/modules/app/components/AppSidebarInvites.tsx +32 -0
  87. package/src/modules/app/components/AppSidebarUser.tsx +128 -0
  88. package/src/modules/auth/components/AdminUserManagement.tsx +1136 -0
  89. package/src/modules/auth/components/AdminWaitlist.tsx +358 -0
  90. package/src/modules/auth/components/AuthLayout.tsx +13 -0
  91. package/src/modules/auth/components/AuthProviders.tsx +105 -0
  92. package/src/modules/auth/components/AuthRouter.tsx +29 -0
  93. package/src/modules/auth/components/ClaimAccountRoute.tsx +242 -0
  94. package/src/modules/auth/components/ErrorAuthRoute.tsx +121 -0
  95. package/src/modules/auth/components/ForgotPasswordForm.tsx +58 -0
  96. package/src/modules/auth/components/ForgotPasswordRoute.tsx +27 -0
  97. package/src/modules/auth/components/InviteFriends.tsx +273 -0
  98. package/src/modules/auth/components/LastUsedBadge.tsx +22 -0
  99. package/src/modules/auth/components/LoginForm.tsx +104 -0
  100. package/src/modules/auth/components/LoginRoute.tsx +31 -0
  101. package/src/modules/auth/components/LogoutRoute.tsx +21 -0
  102. package/src/modules/auth/components/OrganizationAcceptInvitationRoute.tsx +161 -0
  103. package/src/modules/auth/components/OrganizationMembersRoute.tsx +730 -0
  104. package/src/modules/auth/components/OrganizationSettingsRoute.tsx +280 -0
  105. package/src/modules/auth/components/OrganizationSwitcher.tsx +148 -0
  106. package/src/modules/auth/components/ProfileRoute.tsx +104 -0
  107. package/src/modules/auth/components/RangeNuqsDatePicker.tsx +365 -0
  108. package/src/modules/auth/components/ResetPasswordForm.tsx +103 -0
  109. package/src/modules/auth/components/ResetPasswordRoute.tsx +27 -0
  110. package/src/modules/auth/components/SignupFormRoute.tsx +189 -0
  111. package/src/modules/auth/components/SignupRoute.tsx +53 -0
  112. package/src/modules/auth/components/UserPreferences.tsx +144 -0
  113. package/src/modules/auth/components/WaitlistCard.tsx +78 -0
  114. package/src/modules/auth/components/WaitlistCodeValidation.tsx +79 -0
  115. package/src/modules/billing/components/BillingBetaPage.tsx +124 -0
  116. package/src/modules/billing/components/BillingInvoicePage.tsx +180 -0
  117. package/src/modules/billing/components/BillingPlanSelect.tsx +14 -0
  118. package/src/modules/billing/components/BillingRouter.tsx +20 -0
  119. package/src/modules/billing/components/BillingSinglePlanSelect.tsx +172 -0
  120. package/src/modules/table/components/ColumnOrderAndVisibility.tsx +127 -0
  121. package/src/modules/table/components/NuqsTable.tsx +396 -0
  122. package/src/modules/table/components/TableFiltering.tsx +520 -0
  123. package/src/modules/table/components/TablePagination.tsx +59 -0
  124. package/src/modules/table/components/table.types.ts +11 -0
  125. package/src/modules/table/filterTransformers.ts +323 -0
  126. package/src/types.ts +4 -0
  127. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,53 @@
1
+ import { Card, CardBody, CardHeader } from "@heroui/react";
2
+ import { useQueryState } from "nuqs";
3
+ import { useTranslation } from "react-i18next";
4
+ import { Link } from "react-router";
5
+ import { SignupForm } from "#modules/auth/components/SignupFormRoute";
6
+ import type { UseBackendTRPC } from "#types";
7
+ import { AuthProviders } from "./AuthProviders";
8
+ import { WaitlistCard } from "./WaitlistCard";
9
+ import { WaitlistCodeValidation } from "./WaitlistCodeValidation";
10
+
11
+ interface SignupRouteProps {
12
+ providers?: string[];
13
+ useTRPC?: UseBackendTRPC;
14
+ }
15
+
16
+ export function SignupRoute({ providers, useTRPC }: SignupRouteProps) {
17
+ const { t } = useTranslation();
18
+
19
+ const [code] = useQueryState("code");
20
+ const [email] = useQueryState("email");
21
+
22
+ const hasWaitlist = !!useTRPC;
23
+
24
+ return (
25
+ <div className="flex flex-col gap-6">
26
+ {hasWaitlist && !code ? (
27
+ <WaitlistCard useTRPC={useTRPC} />
28
+ ) : (
29
+ <Card>
30
+ <CardHeader className="text-center flex flex-col gap-1">
31
+ <p className="text-xl font-semibold">{t("web-ui:auth.signup.createAccount")}</p>
32
+ <p className="text-sm text-default-600">{t("web-ui:auth.signup.description")}</p>
33
+ </CardHeader>
34
+ <CardBody>
35
+ <div className="grid gap-6">
36
+ {hasWaitlist && code && useTRPC && (
37
+ <WaitlistCodeValidation code={code} useTRPC={useTRPC} />
38
+ )}
39
+ <AuthProviders providers={providers} code={code} requestSignUp />
40
+ <SignupForm code={code} email={email} waitlist={hasWaitlist} />
41
+ </div>
42
+ </CardBody>
43
+ </Card>
44
+ )}
45
+ <div className="text-center text-xs text-muted-foreground">
46
+ {t("web-ui:auth.signup.alreadyHaveAccount")}{" "}
47
+ <Link to="/login" className="underline underline-offset-4 hover:text-primary">
48
+ {t("web-ui:auth.login.button")}
49
+ </Link>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,144 @@
1
+ import { Button, Form, Input, Select, SelectItem, Switch } from "@heroui/react";
2
+ import type { FormEvent, ReactElement } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { toast } from "sonner";
5
+ import type { z } from "zod";
6
+
7
+ type UpdatePreferencesOptions = {
8
+ noOptimisticUpdate?: boolean;
9
+ onSuccess?: () => void;
10
+ onError?: (error: unknown) => void;
11
+ };
12
+
13
+ interface ControlDefinition {
14
+ label: string;
15
+ element: "switch" | "select" | "number";
16
+ options?: { label: string; value: string }[];
17
+ min?: number;
18
+ max?: number;
19
+ step?: number;
20
+ }
21
+
22
+ type ControlsFor<Preferences> = {
23
+ [K in keyof Preferences]: ControlDefinition;
24
+ };
25
+
26
+ export function UserPreferences<S extends z.ZodObject<z.ZodRawShape>>({
27
+ schema,
28
+ controls,
29
+ preferences,
30
+ isLoading,
31
+ isPending,
32
+ updatePreferences,
33
+ }: {
34
+ schema: S;
35
+ controls: ControlsFor<z.infer<S>>;
36
+ preferences: z.infer<S>;
37
+ isLoading: boolean;
38
+ isPending: boolean;
39
+ updatePreferences: (
40
+ partialPreferences: Partial<z.infer<S>>,
41
+ options: UpdatePreferencesOptions
42
+ ) => void;
43
+ }): ReactElement {
44
+ const { t } = useTranslation("web-ui");
45
+
46
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
47
+ event.preventDefault();
48
+ const formData = new FormData(event.currentTarget);
49
+
50
+ const keys = Object.keys(controls) as Array<keyof typeof controls>;
51
+ const raw: Record<string, unknown> = {};
52
+ for (const key of keys) {
53
+ const control = controls[key];
54
+ if (control.element === "switch") {
55
+ raw[String(key)] = formData.get(String(key)) != null;
56
+ }
57
+ if (control.element === "select") {
58
+ raw[String(key)] = formData.get(String(key)) as string;
59
+ }
60
+ if (control.element === "number") {
61
+ const value = formData.get(String(key));
62
+ raw[String(key)] = value == null || value === "" ? undefined : Number(value);
63
+ }
64
+ }
65
+
66
+ const result = schema.safeParse(raw);
67
+ if (result.success) {
68
+ updatePreferences(result.data as Partial<z.infer<S>>, {
69
+ noOptimisticUpdate: true,
70
+ onSuccess: () => {
71
+ toast.success("Preferences updated");
72
+ },
73
+ onError: () => {
74
+ toast.error("Failed to update preferences");
75
+ },
76
+ });
77
+ } else {
78
+ // eslint-disable-next-line no-console
79
+ console.error(result.error);
80
+ }
81
+ }
82
+
83
+ const keys = Object.keys(controls) as Array<keyof typeof controls>;
84
+
85
+ if (isLoading) {
86
+ // FIXME: Add a loading state
87
+ return <div>Loading...</div>;
88
+ }
89
+
90
+ return (
91
+ <Form onSubmit={handleSubmit} className="p-6 flex flex-col gap-4">
92
+ <h1 className="text-2xl font-bold">{t("web-ui:preferences.title")}</h1>
93
+ {keys.map((key) => {
94
+ const control = controls[key];
95
+ switch (control.element) {
96
+ case "switch":
97
+ return (
98
+ <Switch
99
+ key={String(key)}
100
+ name={String(key)}
101
+ value="on"
102
+ defaultSelected={Boolean(preferences[key as keyof typeof preferences])}
103
+ >
104
+ {control.label}
105
+ </Switch>
106
+ );
107
+ case "select":
108
+ return (
109
+ <Select
110
+ key={String(key)}
111
+ name={String(key)}
112
+ label={control.label}
113
+ labelPlacement="outside-top"
114
+ defaultSelectedKeys={[String(preferences[key as keyof typeof preferences])]}
115
+ >
116
+ {(control.options ?? []).map((option) => (
117
+ <SelectItem key={option.value}>{option.label}</SelectItem>
118
+ ))}
119
+ </Select>
120
+ );
121
+ case "number":
122
+ return (
123
+ <Input
124
+ key={String(key)}
125
+ name={String(key)}
126
+ type="number"
127
+ label={control.label}
128
+ labelPlacement="outside-top"
129
+ defaultValue={String(preferences[key as keyof typeof preferences] ?? "")}
130
+ min={control.min}
131
+ max={control.max}
132
+ step={control.step}
133
+ />
134
+ );
135
+ default:
136
+ return <div key={String(key)}>Invalid control</div>;
137
+ }
138
+ })}
139
+ <Button type="submit" color="success" isLoading={isPending}>
140
+ {t("web-ui:preferences.submit")}
141
+ </Button>
142
+ </Form>
143
+ );
144
+ }
@@ -0,0 +1,78 @@
1
+ import { Alert, Button, Card, CardBody, CardHeader, Input } from "@heroui/react";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { useState } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+ import { toast } from "sonner";
6
+ import type { UseBackendTRPC } from "#types";
7
+
8
+ interface WaitlistCardProps {
9
+ useTRPC: UseBackendTRPC;
10
+ }
11
+
12
+ export function WaitlistCard({ useTRPC }: WaitlistCardProps) {
13
+ const { t } = useTranslation();
14
+ const trpc = useTRPC();
15
+ const [email, setEmail] = useState("");
16
+ const [joined, setJoined] = useState(false);
17
+
18
+ const { mutate } = useMutation(trpc.auth.joinWaitlist.mutationOptions());
19
+
20
+ const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
21
+ e.preventDefault();
22
+ mutate(
23
+ { email },
24
+ {
25
+ onSuccess: () => {
26
+ setJoined(true);
27
+ },
28
+ onError: (error) => {
29
+ toast.error(
30
+ error instanceof Error
31
+ ? error.message
32
+ : t("web-ui:auth.waitlist.error", {
33
+ defaultValue: "Failed to join waitlist. Please try again.",
34
+ })
35
+ );
36
+ },
37
+ }
38
+ );
39
+ };
40
+
41
+ if (joined) {
42
+ return (
43
+ <Card>
44
+ <CardBody className="pt-6">
45
+ <Alert color="success" variant="faded" title={t("web-ui:auth.waitlist.success")} />
46
+ </CardBody>
47
+ </Card>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <Card>
53
+ <CardHeader className="text-center flex flex-col gap-1">
54
+ <p className="text-xl font-semibold">{t("web-ui:auth.waitlist.title")}</p>
55
+ <p className="text-sm text-default-600">{t("web-ui:auth.waitlist.description")}</p>
56
+ </CardHeader>
57
+ <CardBody>
58
+ <form onSubmit={handleSubmit} className="grid gap-6">
59
+ <div className="grid gap-2">
60
+ <Input
61
+ type="email"
62
+ label={t("web-ui:auth.waitlist.email")}
63
+ labelPlacement="outside"
64
+ placeholder={t("web-ui:auth.waitlist.placeholder.email")}
65
+ isRequired
66
+ variant="bordered"
67
+ value={email}
68
+ onChange={(e) => setEmail(e.target.value)}
69
+ />
70
+ </div>
71
+ <Button type="submit" color="primary">
72
+ {t("web-ui:auth.waitlist.button")}
73
+ </Button>
74
+ </form>
75
+ </CardBody>
76
+ </Card>
77
+ );
78
+ }
@@ -0,0 +1,79 @@
1
+ import { Alert } from "@heroui/react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useTranslation } from "react-i18next";
4
+ import type { UseBackendTRPC } from "#types";
5
+
6
+ interface WaitlistCodeValidationProps {
7
+ useTRPC: UseBackendTRPC;
8
+ code: string;
9
+ }
10
+
11
+ export function WaitlistCodeValidation({ useTRPC, code }: WaitlistCodeValidationProps) {
12
+ const { t } = useTranslation();
13
+ const trpc = useTRPC();
14
+ const { data, isLoading, error } = useQuery(
15
+ trpc.auth.validateWaitlistCode.queryOptions({ code })
16
+ );
17
+ const status = data?.status;
18
+
19
+ const className = "p-1";
20
+
21
+ if (isLoading) {
22
+ return (
23
+ <Alert
24
+ className={className}
25
+ color="default"
26
+ variant="faded"
27
+ title={t("web-ui:auth.waitlist.validatingCode", {
28
+ defaultValue: "Validating the invitation code...",
29
+ })}
30
+ />
31
+ );
32
+ }
33
+
34
+ if (error) {
35
+ return (
36
+ <Alert
37
+ className={className}
38
+ color="danger"
39
+ variant="faded"
40
+ title={t("web-ui:auth.waitlist.codeError", {
41
+ defaultValue: "An error occurred while validating the invitation code.",
42
+ })}
43
+ />
44
+ );
45
+ }
46
+
47
+ if (status === "VALID") {
48
+ return (
49
+ <Alert
50
+ className={className}
51
+ color="success"
52
+ variant="faded"
53
+ title={t("web-ui:auth.waitlist.codeValid", {
54
+ defaultValue: "Invitation code is valid. You can proceed.",
55
+ })}
56
+ />
57
+ );
58
+ }
59
+
60
+ if (status) {
61
+ let message = t("web-ui:auth.waitlist.invalidCode", {
62
+ defaultValue: "Invalid invitation code.",
63
+ });
64
+
65
+ if (status === "EXPIRED") {
66
+ message = t("web-ui:auth.waitlist.expiredCode", {
67
+ defaultValue: "Invitation code has expired.",
68
+ });
69
+ } else if (status === "NOT_FOUND") {
70
+ message = t("web-ui:auth.waitlist.codeNotFound", {
71
+ defaultValue: "Invitation code not found.",
72
+ });
73
+ }
74
+
75
+ return <Alert className={className} color="danger" variant="faded" title={message} />;
76
+ }
77
+
78
+ return null;
79
+ }
@@ -0,0 +1,124 @@
1
+ import { Bell, CheckCircle2, Clock3, Sparkles } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { Badge } from "#components/ui/badge";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "#components/ui/card";
6
+ import { Separator } from "#components/ui/separator";
7
+ import { cn } from "#utils";
8
+
9
+ interface BillingBetaPageProps {
10
+ appName: string;
11
+ className?: string;
12
+ footer?: ReactNode;
13
+ }
14
+
15
+ export function BillingBetaPage({ appName, className, footer }: BillingBetaPageProps) {
16
+ const { t } = useTranslation("web-ui");
17
+ return (
18
+ <div className={cn("mx-auto max-w-5xl px-4 py-12 md:py-16", className)}>
19
+ {/* Heading */}
20
+ <div className="flex flex-col items-center text-center gap-4">
21
+ <Badge variant="secondary" className="uppercase tracking-wide">
22
+ {t("billing.beta.badge")}
23
+ </Badge>
24
+ <div className="space-y-3">
25
+ <h1 className="text-3xl md:text-4xl font-semibold tracking-tight">
26
+ {t("billing.title")}
27
+ </h1>
28
+ <p className="text-muted-foreground max-w-2xl">{t("billing.subtitle", { appName })}</p>
29
+ </div>
30
+ </div>
31
+
32
+ <Separator className="my-8" />
33
+
34
+ {/* Info Cards */}
35
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
36
+ <Card>
37
+ <CardHeader className="space-y-2">
38
+ <div className="flex items-center gap-2 text-green-600 dark:text-green-500">
39
+ <Sparkles className="h-5 w-5 shrink-0" />
40
+ <CardTitle>{t("billing.card.free.title")}</CardTitle>
41
+ </div>
42
+ <CardDescription>{t("billing.card.free.description")}</CardDescription>
43
+ </CardHeader>
44
+ <CardContent>
45
+ <div className="flex items-baseline gap-2">
46
+ <span className="text-4xl font-bold tracking-tight">
47
+ {t("billing.card.free.price")}
48
+ </span>
49
+ <span className="text-muted-foreground">{t("billing.card.free.priceSuffix")}</span>
50
+ </div>
51
+ <ul className="mt-4 space-y-2 text-sm">
52
+ <li className="flex items-center gap-2">
53
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />{" "}
54
+ {t("billing.card.free.feature.fullAccess")}
55
+ </li>
56
+ <li className="flex items-center gap-2">
57
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />{" "}
58
+ {t("billing.card.free.feature.noCard")}
59
+ </li>
60
+ <li className="flex items-center gap-2">
61
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />{" "}
62
+ {t("billing.card.free.feature.riskFree")}
63
+ </li>
64
+ </ul>
65
+ </CardContent>
66
+ </Card>
67
+
68
+ <Card>
69
+ <CardHeader className="space-y-2">
70
+ <div className="flex items-center gap-2 text-blue-600 dark:text-blue-500">
71
+ <Clock3 className="h-5 w-5 shrink-0" />
72
+ <CardTitle>{t("billing.card.progress.title")}</CardTitle>
73
+ </div>
74
+ <CardDescription>{t("billing.card.progress.description")}</CardDescription>
75
+ </CardHeader>
76
+ <CardContent>
77
+ <ul className="space-y-2 text-sm">
78
+ <li className="flex items-center gap-2">
79
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-blue-600" />{" "}
80
+ {t("billing.card.progress.feature.transparentPlans")}
81
+ </li>
82
+ <li className="flex items-center gap-2">
83
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-blue-600" />{" "}
84
+ {t("billing.card.progress.feature.fairValue")}
85
+ </li>
86
+ <li className="flex items-center gap-2">
87
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-blue-600" />{" "}
88
+ {t("billing.card.progress.feature.simpleBilling")}
89
+ </li>
90
+ </ul>
91
+ </CardContent>
92
+ </Card>
93
+
94
+ <Card>
95
+ <CardHeader className="space-y-2">
96
+ <div className="flex items-center gap-2 text-amber-600 dark:text-amber-500">
97
+ <Bell className="h-5 w-5 shrink-0" />
98
+ <CardTitle>{t("billing.card.notice.title")}</CardTitle>
99
+ </div>
100
+ <CardDescription>{t("billing.card.notice.description")}</CardDescription>
101
+ </CardHeader>
102
+ <CardContent>
103
+ <ul className="space-y-2 text-sm">
104
+ <li className="flex items-center gap-2">
105
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-amber-600" />{" "}
106
+ {t("billing.card.notice.feature.timeToDecide")}
107
+ </li>
108
+ <li className="flex items-center gap-2">
109
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-amber-600" />{" "}
110
+ {t("billing.card.notice.feature.safeData")}
111
+ </li>
112
+ <li className="flex items-center gap-2">
113
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-amber-600" />{" "}
114
+ {t("billing.card.notice.feature.autoFreeTier")}
115
+ </li>
116
+ </ul>
117
+ </CardContent>
118
+ </Card>
119
+ </div>
120
+
121
+ {footer}
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,180 @@
1
+ import {
2
+ Button,
3
+ Card,
4
+ CardBody,
5
+ CardHeader,
6
+ Chip,
7
+ Link,
8
+ Spinner,
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableColumn,
13
+ TableHeader,
14
+ TableRow,
15
+ } from "@heroui/react";
16
+ import { useSubscription } from "@m5kdev/frontend/modules/billing/hooks/useSubscription";
17
+ import { useQuery } from "@tanstack/react-query";
18
+ import { AlertCircle, CheckCircle2, ExternalLink } from "lucide-react";
19
+
20
+ import type { UseBackendTRPC } from "#types";
21
+
22
+ interface BillingInvoicePageProps {
23
+ useTRPC: UseBackendTRPC;
24
+ serverUrl: string;
25
+ }
26
+
27
+ export function BillingInvoicePage({ useTRPC, serverUrl }: BillingInvoicePageProps) {
28
+ const trpc = useTRPC();
29
+ const { data: invoices, isLoading } = useQuery(trpc.billing.listInvoices.queryOptions());
30
+
31
+ const { data: activeSubscription, isLoading: isLoadingSubscriptions } = useSubscription();
32
+ const formatCurrency = (amount: number, currency: string) => {
33
+ return new Intl.NumberFormat("en-US", {
34
+ style: "currency",
35
+ currency: currency.toUpperCase(),
36
+ }).format(amount / 100);
37
+ };
38
+
39
+ const formatDate = (timestamp: number | Date) => {
40
+ const date = timestamp instanceof Date ? timestamp : new Date(timestamp * 1000);
41
+ return date.toLocaleDateString(undefined, {
42
+ year: "numeric",
43
+ month: "long",
44
+ day: "numeric",
45
+ });
46
+ };
47
+
48
+ const cancelAt = activeSubscription?.cancelAt || activeSubscription?.cancelAtPeriodEnd;
49
+ return (
50
+ <div className="container mx-auto p-10 space-y-8">
51
+ <Card>
52
+ <CardHeader className="flex flex-col items-start gap-1 px-6 pt-6">
53
+ <h1 className="text-xl font-bold">Active Subscription</h1>
54
+ <p className="text-small text-default-500">Manage your active subscription.</p>
55
+ </CardHeader>
56
+ <CardBody className="px-6 pb-6">
57
+ {isLoadingSubscriptions ? (
58
+ <div className="flex justify-center py-8">
59
+ <Spinner />
60
+ </div>
61
+ ) : !activeSubscription ? (
62
+ <div className="flex flex-col items-center justify-center py-8 text-center gap-4">
63
+ <div className="p-4 rounded-full bg-default-100">
64
+ <AlertCircle className="w-8 h-8 text-default-500" />
65
+ </div>
66
+ <div>
67
+ <p className="text-lg font-medium">No active subscription</p>
68
+ <p className="text-small text-default-500">
69
+ You are currently on the free tier. Upgrade to access premium features.
70
+ </p>
71
+ </div>
72
+ <Button as={Link} href="/pricing" color="primary">
73
+ View Plans
74
+ </Button>
75
+ </div>
76
+ ) : (
77
+ <div className="space-y-6">
78
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-4 rounded-lg bg-default-50 border border-default-200">
79
+ <div className="space-y-1">
80
+ <div className="flex items-center gap-2">
81
+ <h3 className="text-lg font-semibold capitalize">
82
+ {activeSubscription.plan || "Premium Plan"}
83
+ </h3>
84
+
85
+ <Chip
86
+ color={
87
+ cancelAt
88
+ ? "danger"
89
+ : activeSubscription.status === "active"
90
+ ? "success"
91
+ : "warning"
92
+ }
93
+ variant="flat"
94
+ size="sm"
95
+ startContent={<CheckCircle2 className="w-3 h-3 ml-1" />}
96
+ >
97
+ {cancelAt ? "Cancelled" : activeSubscription.status}
98
+ </Chip>
99
+ </div>
100
+ <p className="text-small text-default-500 flex items-center gap-2">
101
+ {cancelAt ? "Your subscription will end on " : "Next billing date: "}
102
+ <span className="font-medium text-foreground">
103
+ {activeSubscription.periodEnd
104
+ ? formatDate(activeSubscription.periodEnd)
105
+ : "N/A"}
106
+ </span>
107
+ <span className="text-small text-default-500">
108
+ {`(${activeSubscription.interval === "month" ? "Monthly" : "Annually"})`}
109
+ </span>
110
+ </p>
111
+ </div>
112
+ <div className="flex gap-3">
113
+ <Button variant="bordered" as="a" href={`${serverUrl}/stripe/portal`}>
114
+ Manage Subscription
115
+ </Button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ )}
120
+ </CardBody>
121
+ </Card>
122
+ <Card>
123
+ <CardHeader className="flex flex-col items-start gap-1 px-6 pt-6">
124
+ <h1 className="text-xl font-bold">Invoices</h1>
125
+ <p className="text-small text-default-500">
126
+ View your invoice history and download past invoices.
127
+ </p>
128
+ </CardHeader>
129
+ <CardBody className="px-6 pb-6">
130
+ {isLoading ? (
131
+ <div className="flex justify-center py-8">
132
+ <Spinner />
133
+ </div>
134
+ ) : (
135
+ <Table aria-label="Invoices table" removeWrapper>
136
+ <TableHeader>
137
+ <TableColumn>Date</TableColumn>
138
+ <TableColumn>Amount</TableColumn>
139
+ <TableColumn>Status</TableColumn>
140
+ <TableColumn align="end">Action</TableColumn>
141
+ </TableHeader>
142
+ <TableBody emptyContent="No invoices found.">
143
+ {(invoices || []).map((invoice) => (
144
+ <TableRow key={invoice.id}>
145
+ <TableCell>{formatDate(invoice.created)}</TableCell>
146
+ <TableCell>{formatCurrency(invoice.total, invoice.currency)}</TableCell>
147
+ <TableCell>
148
+ <Chip
149
+ color={invoice.status === "paid" ? "success" : "default"}
150
+ variant="flat"
151
+ size="sm"
152
+ >
153
+ {invoice.status}
154
+ </Chip>
155
+ </TableCell>
156
+ <TableCell className="text-right">
157
+ {invoice.hosted_invoice_url && (
158
+ <Button
159
+ as={Link}
160
+ href={invoice.hosted_invoice_url}
161
+ target="_blank"
162
+ rel="noopener noreferrer"
163
+ variant="light"
164
+ size="sm"
165
+ endContent={<ExternalLink className="h-4 w-4" />}
166
+ >
167
+ View
168
+ </Button>
169
+ )}
170
+ </TableCell>
171
+ </TableRow>
172
+ ))}
173
+ </TableBody>
174
+ </Table>
175
+ )}
176
+ </CardBody>
177
+ </Card>
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,14 @@
1
+ import type { StripePlan } from "@m5kdev/commons/modules/billing/billing.types";
2
+ import { BillingSinglePlanSelect } from "#modules/billing/components/BillingSinglePlanSelect";
3
+
4
+ interface BillingPlanSelectProps {
5
+ plans: StripePlan[];
6
+ }
7
+
8
+ export function BillingPlanSelect({ plans }: BillingPlanSelectProps) {
9
+ if (plans.length === 1) {
10
+ return <BillingSinglePlanSelect plan={plans[0]} />;
11
+ }
12
+
13
+ return "Multiple plans selection not implemented yet";
14
+ }