├── .env.example
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── drizzle.config.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── src
├── app
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── sign-in
│ │ │ └── [[...sign-in]]
│ │ │ │ └── page.tsx
│ │ └── sign-up
│ │ │ └── [[...sign-up]]
│ │ │ └── page.tsx
│ ├── (marketing)
│ │ ├── _components
│ │ │ └── NavBar.tsx
│ │ ├── _icons
│ │ │ ├── Clerk.tsx
│ │ │ └── Neon.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api
│ │ ├── products
│ │ │ └── [productId]
│ │ │ │ └── banner
│ │ │ │ └── route.ts
│ │ └── webhooks
│ │ │ ├── clerk
│ │ │ └── route.ts
│ │ │ └── stripe
│ │ │ └── route.ts
│ ├── dashboard
│ │ ├── _components
│ │ │ ├── AddToSiteProductModalContent.tsx
│ │ │ ├── DeleteProductAlertDialogContent.tsx
│ │ │ ├── NavBar.tsx
│ │ │ ├── NoProducts.tsx
│ │ │ ├── PageWithBackButton.tsx
│ │ │ ├── ProductGrid.tsx
│ │ │ ├── TimezoneDropdownMenuItem.tsx
│ │ │ ├── charts
│ │ │ │ ├── ViewsByCountryChart.tsx
│ │ │ │ ├── ViewsByDayChart.tsx
│ │ │ │ └── ViewsByPPPChart.tsx
│ │ │ └── forms
│ │ │ │ ├── CountryDiscountsForm.tsx
│ │ │ │ ├── ProductCustomizationForm.tsx
│ │ │ │ └── ProductDeailsForm.tsx
│ │ ├── analytics
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── products
│ │ │ ├── [productId]
│ │ │ │ └── edit
│ │ │ │ │ └── page.tsx
│ │ │ ├── new
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── subscription
│ │ │ └── page.tsx
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ └── GeistVF.woff
│ ├── globals.css
│ └── layout.tsx
├── components
│ ├── Banner.tsx
│ ├── BrandLogo.tsx
│ ├── HasPermission.tsx
│ ├── NoPermissionCard.tsx
│ ├── RequiredLabelIcon.tsx
│ └── ui
│ │ ├── alert-dialog.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── chart.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── progress.tsx
│ │ ├── switch.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ └── toaster.tsx
├── data
│ ├── countriesByDiscount.json
│ ├── env
│ │ ├── client.ts
│ │ └── server.ts
│ └── subscriptionTiers.ts
├── drizzle
│ ├── db.ts
│ ├── migrations
│ │ ├── 0000_left_agent_zero.sql
│ │ └── meta
│ │ │ ├── 0000_snapshot.json
│ │ │ └── _journal.json
│ └── schema.ts
├── hooks
│ └── use-toast.ts
├── lib
│ ├── cache.ts
│ ├── formatters.ts
│ └── utils.ts
├── middleware.ts
├── schemas
│ └── products.ts
├── server
│ ├── actions
│ │ ├── products.ts
│ │ └── stripe.ts
│ ├── db
│ │ ├── productViews.ts
│ │ ├── products.ts
│ │ ├── subscription.ts
│ │ └── users.ts
│ └── permissions.ts
└── tasks
│ └── updateCountryGroups.ts
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | # Database
2 | DATABASE_URL=
3 |
4 | # Clerk
5 | CLERK_SECRET_KEY=
6 | CLERK_WEBHOOK_SECRET=
7 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
8 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
9 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
10 | NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/dashboard
11 | NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/dashboard
12 |
13 | # Stripe
14 | STRIPE_BASIC_PLAN_STRIPE_PRICE_ID=
15 | STRIPE_STANDARD_PLAN_STRIPE_PRICE_ID=
16 | STRIPE_PREMIUM_PLAN_STRIPE_PRICE_ID=
17 | STRIPE_SECRET_KEY=
18 | STRIPE_WEBHOOK_SECRET=
19 |
20 | # Other
21 | NEXT_PUBLIC_SERVER_URL=http://localhost:3000
22 |
23 | # Development
24 | TEST_COUNTRY_CODE=IN
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 WebDevSimplified
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/data/env/server"
2 | import { defineConfig } from "drizzle-kit"
3 |
4 | export default defineConfig({
5 | schema: "./src/drizzle/schema.ts",
6 | out: "./src/drizzle/migrations",
7 | dialect: "postgresql",
8 | strict: true,
9 | verbose: true,
10 | dbCredentials: {
11 | url: env.DATABASE_URL,
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | staleTimes: {
5 | dynamic: 0,
6 | },
7 | },
8 | }
9 |
10 | export default nextConfig
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "parity-deals-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "next dev",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint",
11 | "db:generate": "drizzle-kit generate",
12 | "db:migrate": "drizzle-kit migrate",
13 | "db:studio": "drizzle-kit studio",
14 | "db:updateCountryGroups": "tsx --env-file=.env ./src/tasks/updateCountryGroups.ts",
15 | "stripe:webhooks": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
16 | },
17 | "dependencies": {
18 | "@clerk/nextjs": "^5.5.3",
19 | "@date-fns/tz": "^1.0.1",
20 | "@hookform/resolvers": "^3.9.0",
21 | "@neondatabase/serverless": "^0.9.5",
22 | "@radix-ui/react-alert-dialog": "^1.1.1",
23 | "@radix-ui/react-dialog": "^1.1.1",
24 | "@radix-ui/react-dropdown-menu": "^2.1.1",
25 | "@radix-ui/react-icons": "^1.3.0",
26 | "@radix-ui/react-label": "^2.1.0",
27 | "@radix-ui/react-progress": "^1.1.0",
28 | "@radix-ui/react-slot": "^1.1.0",
29 | "@radix-ui/react-switch": "^1.1.0",
30 | "@radix-ui/react-tabs": "^1.1.0",
31 | "@radix-ui/react-toast": "^1.2.1",
32 | "@t3-oss/env-nextjs": "^0.11.1",
33 | "class-variance-authority": "^0.7.0",
34 | "clsx": "^2.1.1",
35 | "date-fns": "^4.0.0",
36 | "drizzle-orm": "^0.33.0",
37 | "lucide-react": "^0.441.0",
38 | "next": "14.2.11",
39 | "react": "^18",
40 | "react-country-flag": "^3.1.0",
41 | "react-dom": "^18",
42 | "react-hook-form": "^7.53.0",
43 | "recharts": "^2.12.7",
44 | "stripe": "^16.11.0",
45 | "svix": "^1.34.0",
46 | "tailwind-merge": "^2.5.2",
47 | "tailwindcss-animate": "^1.0.7",
48 | "zod": "^3.23.8"
49 | },
50 | "devDependencies": {
51 | "@types/node": "^20",
52 | "@types/react": "^18",
53 | "@types/react-dom": "^18",
54 | "drizzle-kit": "^0.24.2",
55 | "eslint": "^8",
56 | "eslint-config-next": "14.2.11",
57 | "postcss": "^8",
58 | "tailwindcss": "^3.4.1",
59 | "tsx": "^4.19.1",
60 | "typescript": "^5"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | export default async function AuthLayout({
2 | children,
3 | }: {
4 | children: React.ReactNode
5 | }) {
6 | return (
7 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs"
2 |
3 | export default function SignInPage() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs"
2 |
3 | export default function SignUpPage() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(marketing)/_components/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import { BrandLogo } from "@/components/BrandLogo"
2 | import { SignedIn, SignedOut, SignInButton } from "@clerk/nextjs"
3 | import Link from "next/link"
4 |
5 | export function NavBar() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 | Features
14 |
15 |
16 | Pricing
17 |
18 |
19 | About
20 |
21 |
22 |
23 | Dashboard
24 |
25 |
26 | Login
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/(marketing)/_icons/Clerk.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react"
2 |
3 | export function ClerkIcon(svgProps: ComponentProps<"svg">) {
4 | return (
5 |
11 |
12 |
13 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/(marketing)/_icons/Neon.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react"
2 |
3 | export function NeonIcon(svgProps: ComponentProps<"svg">) {
4 | return (
5 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/(marketing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 | import { NavBar } from "./_components/NavBar"
3 |
4 | export default function MarketingLayout({ children }: { children: ReactNode }) {
5 | return (
6 |
7 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import { SignUpButton } from "@clerk/nextjs"
3 | import { ArrowRightIcon, CheckIcon } from "lucide-react"
4 | import Link from "next/link"
5 | import { NeonIcon } from "./_icons/Neon"
6 | import { ClerkIcon } from "./_icons/Clerk"
7 | import { subscriptionTiersInOrder } from "@/data/subscriptionTiers"
8 | import {
9 | Card,
10 | CardContent,
11 | CardDescription,
12 | CardFooter,
13 | CardHeader,
14 | CardTitle,
15 | } from "@/components/ui/card"
16 | import { formatCompactNumber } from "@/lib/formatters"
17 | import { ReactNode } from "react"
18 | import { cn } from "@/lib/utils"
19 | import { BrandLogo } from "@/components/BrandLogo"
20 |
21 | export default function HomePage() {
22 | return (
23 | <>
24 |
25 |
26 | Price Smarter, Sell bigger!
27 |
28 |
29 | Optimize your product pricing across countries to maximize sales.
30 | Capture 85% of the untapped market with location-based dynamic pricing
31 |
32 |
33 |
34 | Get started for free
35 |
36 |
37 |
38 |
39 |
40 |
41 | Trusted by the top modern companies
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | Pricing software which pays for itself 20x over
80 |
81 |
82 | {subscriptionTiersInOrder.map(tier => (
83 |
84 | ))}
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
100 |
108 |
109 |
110 |
114 |
122 |
130 |
131 |
132 |
142 |
153 |
154 |
155 |
156 | >
157 | )
158 | }
159 |
160 | function PricingCard({
161 | name,
162 | priceInCents,
163 | maxNumberOfVisits,
164 | maxNumberOfProducts,
165 | canRemoveBranding,
166 | canAccessAnalytics,
167 | canCustomizeBanner,
168 | }: (typeof subscriptionTiersInOrder)[number]) {
169 | const isMostPopular = name === "Standard"
170 |
171 | return (
172 |
178 | {isMostPopular && (
179 |
180 | Most popular
181 |
182 | )}
183 |
184 | {name}
185 |
186 | ${priceInCents / 100} /mo
187 |
188 |
189 | {formatCompactNumber(maxNumberOfVisits)} pricing page visits/mo
190 |
191 |
192 |
193 |
194 |
198 | Get Started
199 |
200 |
201 |
202 |
203 |
204 | {maxNumberOfProducts}{" "}
205 | {maxNumberOfProducts === 1 ? "product" : "products"}
206 |
207 | PPP discounts
208 | {canAccessAnalytics && Advanced analytics }
209 | {canRemoveBranding && Remove Easy PPP branding }
210 | {canCustomizeBanner && Banner customization }
211 |
212 |
213 | )
214 | }
215 |
216 | function Feature({
217 | children,
218 | className,
219 | }: {
220 | children: ReactNode
221 | className?: string
222 | }) {
223 | return (
224 |
225 |
226 | {children}
227 |
228 | )
229 | }
230 |
231 | function FooterLinkGroup({
232 | title,
233 | links,
234 | }: {
235 | title: string
236 | links: { label: string; href: string }[]
237 | }) {
238 | return (
239 |
240 |
{title}
241 |
242 | {links.map(link => (
243 |
244 | {link.label}
245 |
246 | ))}
247 |
248 |
249 | )
250 | }
251 |
--------------------------------------------------------------------------------
/src/app/api/products/[productId]/banner/route.ts:
--------------------------------------------------------------------------------
1 | import { Banner } from "@/components/Banner"
2 | import { env } from "@/data/env/server"
3 | import { getProductForBanner } from "@/server/db/products"
4 | import { createProductView } from "@/server/db/productViews"
5 | import { canRemoveBranding, canShowDiscountBanner } from "@/server/permissions"
6 | import { headers } from "next/headers"
7 | import { notFound } from "next/navigation"
8 | import { NextRequest } from "next/server"
9 | import { createElement } from "react"
10 |
11 | export const runtime = "edge"
12 |
13 | export async function GET(
14 | request: NextRequest,
15 | { params: { productId } }: { params: { productId: string } }
16 | ) {
17 | const headersMap = headers()
18 | const requestingUrl = headersMap.get("referer") || headersMap.get("origin")
19 | if (requestingUrl == null) return notFound()
20 | const countryCode = getCountryCode(request)
21 | if (countryCode == null) return notFound()
22 |
23 | const { product, discount, country } = await getProductForBanner({
24 | id: productId,
25 | countryCode,
26 | url: requestingUrl,
27 | })
28 |
29 | if (product == null) return notFound()
30 |
31 | const canShowBanner = await canShowDiscountBanner(product.clerkUserId)
32 |
33 | await createProductView({
34 | productId: product.id,
35 | countryId: country?.id,
36 | userId: product.clerkUserId,
37 | })
38 |
39 | if (!canShowBanner) return notFound()
40 | if (country == null || discount == null) return notFound()
41 |
42 | return new Response(
43 | await getJavaScript(
44 | product,
45 | country,
46 | discount,
47 | await canRemoveBranding(product.clerkUserId)
48 | ),
49 | { headers: { "content-type": "text/javascript" } }
50 | )
51 | }
52 |
53 | function getCountryCode(request: NextRequest) {
54 | if (request.geo?.country != null) return request.geo.country
55 | if (process.env.NODE_ENV === "development") {
56 | return env.TEST_COUNTRY_CODE
57 | }
58 | }
59 |
60 | async function getJavaScript(
61 | product: {
62 | customization: {
63 | locationMessage: string
64 | bannerContainer: string
65 | backgroundColor: string
66 | textColor: string
67 | fontSize: string
68 | isSticky: boolean
69 | classPrefix?: string | null
70 | }
71 | },
72 | country: { name: string },
73 | discount: { coupon: string; percentage: number },
74 | canRemoveBranding: boolean
75 | ) {
76 | const { renderToStaticMarkup } = await import("react-dom/server")
77 | return `
78 | const banner = document.createElement("div");
79 | banner.innerHTML = '${renderToStaticMarkup(
80 | createElement(Banner, {
81 | message: product.customization.locationMessage,
82 | mappings: {
83 | country: country.name,
84 | coupon: discount.coupon,
85 | discount: (discount.percentage * 100).toString(),
86 | },
87 | customization: product.customization,
88 | canRemoveBranding,
89 | })
90 | )}';
91 | document.querySelector("${
92 | product.customization.bannerContainer
93 | }").prepend(...banner.children);
94 | `.replace(/(\r\n|\n|\r)/g, "")
95 | }
96 |
--------------------------------------------------------------------------------
/src/app/api/webhooks/clerk/route.ts:
--------------------------------------------------------------------------------
1 | import { Webhook } from "svix"
2 | import { headers } from "next/headers"
3 | import { WebhookEvent } from "@clerk/nextjs/server"
4 | import { env } from "@/data/env/server"
5 | import {
6 | createUserSubscription,
7 | getUserSubscription,
8 | } from "@/server/db/subscription"
9 | import { deleteUser } from "@/server/db/users"
10 | import { Stripe } from "stripe"
11 |
12 | const stripe = new Stripe(env.STRIPE_SECRET_KEY)
13 |
14 | export async function POST(req: Request) {
15 | const headerPayload = headers()
16 | const svixId = headerPayload.get("svix-id")
17 | const svixTimestamp = headerPayload.get("svix-timestamp")
18 | const svixSignature = headerPayload.get("svix-signature")
19 |
20 | if (!svixId || !svixTimestamp || !svixSignature) {
21 | return new Response("Error occurred -- no svix headers", {
22 | status: 400,
23 | })
24 | }
25 |
26 | const payload = await req.json()
27 | const body = JSON.stringify(payload)
28 |
29 | const wh = new Webhook(env.CLERK_WEBHOOK_SECRET)
30 | let event: WebhookEvent
31 |
32 | try {
33 | event = wh.verify(body, {
34 | "svix-id": svixId,
35 | "svix-timestamp": svixTimestamp,
36 | "svix-signature": svixSignature,
37 | }) as WebhookEvent
38 | } catch (err) {
39 | console.error("Error verifying webhook:", err)
40 | return new Response("Error occurred", {
41 | status: 400,
42 | })
43 | }
44 |
45 | switch (event.type) {
46 | case "user.created": {
47 | await createUserSubscription({
48 | clerkUserId: event.data.id,
49 | tier: "Free",
50 | })
51 | break
52 | }
53 | case "user.deleted": {
54 | if (event.data.id != null) {
55 | const userSubscription = await getUserSubscription(event.data.id)
56 | if (userSubscription?.stripeSubscriptionId != null) {
57 | await stripe.subscriptions.cancel(
58 | userSubscription?.stripeSubscriptionId
59 | )
60 | }
61 | await deleteUser(event.data.id)
62 | }
63 | }
64 | }
65 |
66 | return new Response("", { status: 200 })
67 | }
68 |
--------------------------------------------------------------------------------
/src/app/api/webhooks/stripe/route.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/data/env/server"
2 | import { getTierByPriceId, subscriptionTiers } from "@/data/subscriptionTiers"
3 | import { UserSubscriptionTable } from "@/drizzle/schema"
4 | import { updateUserSubscription } from "@/server/db/subscription"
5 | import { eq } from "drizzle-orm"
6 | import { NextRequest } from "next/server"
7 | import Stripe from "stripe"
8 |
9 | const stripe = new Stripe(env.STRIPE_SECRET_KEY)
10 |
11 | export async function POST(request: NextRequest) {
12 | const event = await stripe.webhooks.constructEvent(
13 | await request.text(),
14 | request.headers.get("stripe-signature") as string,
15 | env.STRIPE_WEBHOOK_SECRET
16 | )
17 |
18 | switch (event.type) {
19 | case "customer.subscription.deleted": {
20 | await handleDelete(event.data.object)
21 | break
22 | }
23 | case "customer.subscription.updated": {
24 | await handleUpdate(event.data.object)
25 | break
26 | }
27 | case "customer.subscription.created": {
28 | await handleCreate(event.data.object)
29 | break
30 | }
31 | }
32 |
33 | return new Response(null, { status: 200 })
34 | }
35 |
36 | async function handleCreate(subscription: Stripe.Subscription) {
37 | const tier = getTierByPriceId(subscription.items.data[0].price.id)
38 | const clerkUserId = subscription.metadata.clerkUserId
39 | if (clerkUserId == null || tier == null) {
40 | return new Response(null, { status: 500 })
41 | }
42 | const customer = subscription.customer
43 | const customerId = typeof customer === "string" ? customer : customer.id
44 |
45 | return await updateUserSubscription(
46 | eq(UserSubscriptionTable.clerkUserId, clerkUserId),
47 | {
48 | stripeCustomerId: customerId,
49 | tier: tier.name,
50 | stripeSubscriptionId: subscription.id,
51 | stripeSubscriptionItemId: subscription.items.data[0].id,
52 | }
53 | )
54 | }
55 |
56 | async function handleUpdate(subscription: Stripe.Subscription) {
57 | const tier = getTierByPriceId(subscription.items.data[0].price.id)
58 | const customer = subscription.customer
59 | const customerId = typeof customer === "string" ? customer : customer.id
60 | if (tier == null) {
61 | return new Response(null, { status: 500 })
62 | }
63 |
64 | return await updateUserSubscription(
65 | eq(UserSubscriptionTable.stripeCustomerId, customerId),
66 | { tier: tier.name }
67 | )
68 | }
69 |
70 | async function handleDelete(subscription: Stripe.Subscription) {
71 | const customer = subscription.customer
72 | const customerId = typeof customer === "string" ? customer : customer.id
73 |
74 | return await updateUserSubscription(
75 | eq(UserSubscriptionTable.stripeCustomerId, customerId),
76 | {
77 | tier: subscriptionTiers.Free.name,
78 | stripeSubscriptionId: null,
79 | stripeSubscriptionItemId: null,
80 | }
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/AddToSiteProductModalContent.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import {
5 | DialogClose,
6 | DialogContent,
7 | DialogDescription,
8 | DialogHeader,
9 | DialogTitle,
10 | } from "@/components/ui/dialog"
11 | import { env } from "@/data/env/client"
12 | import { CopyCheckIcon, CopyIcon, CopyXIcon } from "lucide-react"
13 | import { useState } from "react"
14 |
15 | type CopyState = "idle" | "copied" | "error"
16 |
17 | export function AddToSiteProductModalContent({ id }: { id: string }) {
18 | const [copyState, setCopyState] = useState("idle")
19 | const code = ``
20 | const Icon = getCopyIcon(copyState)
21 |
22 | return (
23 |
24 |
25 | Start Earning PPP Sales!
26 |
27 | All you need to do is copy the below script into your site and your
28 | customers will start seeing PPP discounts!
29 |
30 |
31 |
32 | {code}
33 |
34 |
35 | {
37 | navigator.clipboard
38 | .writeText(code)
39 | .then(() => {
40 | setCopyState("copied")
41 | setTimeout(() => setCopyState("idle"), 2000)
42 | })
43 | .catch(() => {
44 | setCopyState("error")
45 | setTimeout(() => setCopyState("idle"), 2000)
46 | })
47 | }}
48 | >
49 | { }
50 | {getChildren(copyState)}
51 |
52 |
53 | Close
54 |
55 |
56 |
57 | )
58 | }
59 |
60 | function getCopyIcon(copyState: CopyState) {
61 | switch (copyState) {
62 | case "idle":
63 | return CopyIcon
64 | case "copied":
65 | return CopyCheckIcon
66 | case "error":
67 | return CopyXIcon
68 | }
69 | }
70 |
71 | function getChildren(copyState: CopyState) {
72 | switch (copyState) {
73 | case "idle":
74 | return "Copy Code"
75 | case "copied":
76 | return "Copied!"
77 | case "error":
78 | return "Error"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/DeleteProductAlertDialogContent.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | AlertDialogAction,
5 | AlertDialogCancel,
6 | AlertDialogContent,
7 | AlertDialogDescription,
8 | AlertDialogFooter,
9 | AlertDialogHeader,
10 | AlertDialogTitle,
11 | } from "@/components/ui/alert-dialog"
12 | import { useToast } from "@/hooks/use-toast"
13 | import { deleteProduct } from "@/server/actions/products"
14 | import { useTransition } from "react"
15 |
16 | export function DeleteProductAlertDialogContent({ id }: { id: string }) {
17 | const [isDeletePending, startDeleteTransition] = useTransition()
18 | const { toast } = useToast()
19 |
20 | return (
21 |
22 |
23 | Are you sure?
24 |
25 | This action cannot be undone. This will permanently delete this
26 | product.
27 |
28 |
29 |
30 | Cancel
31 | {
33 | startDeleteTransition(async () => {
34 | const data = await deleteProduct(id)
35 | if (data.message) {
36 | toast({
37 | title: data.error ? "Error" : "Success",
38 | description: data.message,
39 | variant: data.error ? "destructive" : "default",
40 | })
41 | }
42 | })
43 | }}
44 | disabled={isDeletePending}
45 | >
46 | Delete
47 |
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import { BrandLogo } from "@/components/BrandLogo"
2 | import { UserButton } from "@clerk/nextjs"
3 | import Link from "next/link"
4 |
5 | export function NavBar() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | Products
13 | Analytics
14 | Subscription
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/NoProducts.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import Link from "next/link"
3 |
4 | export function NoProducts() {
5 | return (
6 |
7 |
You have no products
8 |
9 | Get started with PPP discounts by creating a product
10 |
11 |
12 | Add Product
13 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/PageWithBackButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import { CaretLeftIcon } from "@radix-ui/react-icons"
3 | import Link from "next/link"
4 | import { ReactNode } from "react"
5 |
6 | export function PageWithBackButton({
7 | backButtonHref,
8 | pageTitle,
9 | children,
10 | }: {
11 | backButtonHref: string
12 | pageTitle: string
13 | children: ReactNode
14 | }) {
15 | return (
16 |
17 |
18 |
19 | Back
20 |
21 |
22 |
23 |
{pageTitle}
24 |
{children}
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/ProductGrid.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card"
9 | import { DotsHorizontalIcon } from "@radix-ui/react-icons"
10 | import Link from "next/link"
11 | import {
12 | DropdownMenu,
13 | DropdownMenuContent,
14 | DropdownMenuItem,
15 | DropdownMenuSeparator,
16 | DropdownMenuTrigger,
17 | } from "@/components/ui/dropdown-menu"
18 | import { Dialog, DialogTrigger } from "@/components/ui/dialog"
19 | import { AddToSiteProductModalContent } from "./AddToSiteProductModalContent"
20 | import { AlertDialog, AlertDialogTrigger } from "@/components/ui/alert-dialog"
21 | import { DeleteProductAlertDialogContent } from "./DeleteProductAlertDialogContent"
22 |
23 | export function ProductGrid({
24 | products,
25 | }: {
26 | products: {
27 | id: string
28 | name: string
29 | url: string
30 | description?: string | null
31 | }[]
32 | }) {
33 | return (
34 |
35 | {products.map(product => (
36 |
37 | ))}
38 |
39 | )
40 | }
41 |
42 | export function ProductCard({
43 | id,
44 | name,
45 | url,
46 | description,
47 | }: {
48 | id: string
49 | name: string
50 | url: string
51 | description?: string | null
52 | }) {
53 | return (
54 |
55 |
56 |
57 |
58 | {name}
59 |
60 |
61 |
62 |
63 |
64 |
65 | Action Menu
66 |
67 |
68 |
69 |
70 |
71 | Edit
72 |
73 |
74 | Add To Site
75 |
76 |
77 |
78 | Delete
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | {url}
88 |
89 | {description && {description} }
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/TimezoneDropdownMenuItem.tsx:
--------------------------------------------------------------------------------
1 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
2 | import { createURL } from "@/lib/utils"
3 | import Link from "next/link"
4 |
5 | export function TimezoneDropdownMenuItem({
6 | searchParams,
7 | }: {
8 | searchParams: Record
9 | }) {
10 | const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
11 | return (
12 |
13 |
18 | {userTimezone}
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/charts/ViewsByCountryChart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | ChartContainer,
5 | ChartTooltip,
6 | ChartTooltipContent,
7 | } from "@/components/ui/chart"
8 | import { formatCompactNumber } from "@/lib/formatters"
9 | import { Bar, BarChart, XAxis, YAxis } from "recharts"
10 |
11 | export function ViewsByCountryChart({
12 | chartData,
13 | }: {
14 | chartData: { countryCode: string; countryName: string; views: number }[]
15 | }) {
16 | const chartConfig = {
17 | views: {
18 | label: "Visitors",
19 | color: "hsl(var(--accent))",
20 | },
21 | }
22 |
23 | if (chartData.length === 0) {
24 | return (
25 |
26 | No data available
27 |
28 | )
29 | }
30 |
31 | return (
32 |
36 |
37 |
38 |
44 | } />
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/charts/ViewsByDayChart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | ChartContainer,
5 | ChartTooltip,
6 | ChartTooltipContent,
7 | } from "@/components/ui/chart"
8 | import { formatCompactNumber } from "@/lib/formatters"
9 | import { Bar, BarChart, XAxis, YAxis } from "recharts"
10 |
11 | export function ViewsByDayChart({
12 | chartData,
13 | }: {
14 | chartData: { date: string; views: number }[]
15 | }) {
16 | const chartConfig = {
17 | views: {
18 | label: "Visitors",
19 | color: "hsl(var(--accent))",
20 | },
21 | }
22 |
23 | if (chartData.length === 0) {
24 | return (
25 |
26 | No data available
27 |
28 | )
29 | }
30 |
31 | return (
32 |
36 |
37 |
38 |
44 | } />
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/charts/ViewsByPPPChart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | ChartContainer,
5 | ChartTooltip,
6 | ChartTooltipContent,
7 | } from "@/components/ui/chart"
8 | import { formatCompactNumber } from "@/lib/formatters"
9 | import { Bar, BarChart, XAxis, YAxis } from "recharts"
10 |
11 | export function ViewsByPPPChart({
12 | chartData,
13 | }: {
14 | chartData: { pppName: string; views: number }[]
15 | }) {
16 | const chartConfig = {
17 | views: {
18 | label: "Visitors",
19 | color: "hsl(var(--accent))",
20 | },
21 | }
22 |
23 | if (chartData.length === 0) {
24 | return (
25 |
26 | No data available
27 |
28 | )
29 | }
30 |
31 | const data = chartData.map(d => ({
32 | ...d,
33 | pppName: d.pppName.replace("Parity Group: ", ""),
34 | }))
35 |
36 | return (
37 |
41 |
42 |
43 |
49 | } />
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/forms/CountryDiscountsForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Card, CardContent } from "@/components/ui/card"
4 | import {
5 | Form,
6 | FormControl,
7 | FormField,
8 | FormItem,
9 | FormLabel,
10 | FormMessage,
11 | } from "@/components/ui/form"
12 | import { productCountryDiscountsSchema } from "@/schemas/products"
13 | import { zodResolver } from "@hookform/resolvers/zod"
14 | import { useForm } from "react-hook-form"
15 | import { z } from "zod"
16 | import Image from "next/image"
17 | import { Input } from "@/components/ui/input"
18 | import { Button } from "@/components/ui/button"
19 | import { useToast } from "@/hooks/use-toast"
20 | import { updateCountryDiscounts } from "@/server/actions/products"
21 |
22 | export function CountryDiscountsForm({
23 | productId,
24 | countryGroups,
25 | }: {
26 | productId: string
27 | countryGroups: {
28 | id: string
29 | name: string
30 | recommendedDiscountPercentage: number | null
31 | countries: {
32 | name: string
33 | code: string
34 | }[]
35 | discount?: {
36 | coupon: string
37 | discountPercentage: number
38 | }
39 | }[]
40 | }) {
41 | const { toast } = useToast()
42 | const form = useForm>({
43 | resolver: zodResolver(productCountryDiscountsSchema),
44 | defaultValues: {
45 | groups: countryGroups.map(group => {
46 | const discount =
47 | group.discount?.discountPercentage ??
48 | group.recommendedDiscountPercentage
49 |
50 | return {
51 | countryGroupId: group.id,
52 | coupon: group.discount?.coupon ?? "",
53 | discountPercentage: discount != null ? discount * 100 : undefined,
54 | }
55 | }),
56 | },
57 | })
58 |
59 | async function onSubmit(
60 | values: z.infer
61 | ) {
62 | const data = await updateCountryDiscounts(productId, values)
63 |
64 | if (data.message) {
65 | toast({
66 | title: data.error ? "Error" : "Success",
67 | description: data.message,
68 | variant: data.error ? "destructive" : "default",
69 | })
70 | }
71 | }
72 |
73 | return (
74 |
157 |
158 | )
159 | }
160 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/forms/ProductCustomizationForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Banner } from "@/components/Banner"
4 | import { NoPermissionCard } from "@/components/NoPermissionCard"
5 | import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
6 | import { Button } from "@/components/ui/button"
7 | import {
8 | Form,
9 | FormControl,
10 | FormDescription,
11 | FormField,
12 | FormItem,
13 | FormLabel,
14 | FormMessage,
15 | } from "@/components/ui/form"
16 | import { Input } from "@/components/ui/input"
17 | import { Switch } from "@/components/ui/switch"
18 | import { Textarea } from "@/components/ui/textarea"
19 | import { useToast } from "@/hooks/use-toast"
20 | import { productCustomizationSchema } from "@/schemas/products"
21 | import { updateProductCustomization } from "@/server/actions/products"
22 | import { zodResolver } from "@hookform/resolvers/zod"
23 | import { useForm } from "react-hook-form"
24 | import { custom, z } from "zod"
25 |
26 | export function ProductCustomizationForm({
27 | customization,
28 | canCustomizeBanner,
29 | canRemoveBranding,
30 | }: {
31 | customization: {
32 | productId: string
33 | locationMessage: string
34 | backgroundColor: string
35 | textColor: string
36 | fontSize: string
37 | bannerContainer: string
38 | isSticky: boolean
39 | classPrefix: string | null
40 | }
41 | canRemoveBranding: boolean
42 | canCustomizeBanner: boolean
43 | }) {
44 | const { toast } = useToast()
45 | const form = useForm>({
46 | resolver: zodResolver(productCustomizationSchema),
47 | defaultValues: {
48 | ...customization,
49 | classPrefix: customization.classPrefix ?? "",
50 | },
51 | })
52 |
53 | async function onSubmit(values: z.infer) {
54 | const data = await updateProductCustomization(
55 | customization.productId,
56 | values
57 | )
58 |
59 | if (data?.message) {
60 | toast({
61 | title: data.error ? "Error" : "Success",
62 | description: data.message,
63 | variant: data.error ? "destructive" : "default",
64 | })
65 | }
66 | }
67 |
68 | const formValues = form.watch()
69 |
70 | return (
71 | <>
72 |
73 |
83 |
84 | {!canCustomizeBanner && (
85 |
86 |
87 |
88 | )}
89 |
232 |
233 | >
234 | )
235 | }
236 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/forms/ProductDeailsForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { z } from "zod"
5 | import { zodResolver } from "@hookform/resolvers/zod"
6 | import {
7 | Form,
8 | FormControl,
9 | FormDescription,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form"
15 | import { Input } from "@/components/ui/input"
16 | import { Textarea } from "@/components/ui/textarea"
17 | import { Button } from "@/components/ui/button"
18 | import { productDetailsSchema } from "@/schemas/products"
19 | import { createProduct, updateProduct } from "@/server/actions/products"
20 | import { useToast } from "@/hooks/use-toast"
21 | import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
22 |
23 | export function ProductDetailsForm({
24 | product,
25 | }: {
26 | product?: {
27 | id: string
28 | name: string
29 | description: string | null
30 | url: string
31 | }
32 | }) {
33 | const { toast } = useToast()
34 | const form = useForm>({
35 | resolver: zodResolver(productDetailsSchema),
36 | defaultValues: product
37 | ? { ...product, description: product.description ?? "" }
38 | : {
39 | name: "",
40 | url: "",
41 | description: "",
42 | },
43 | })
44 |
45 | async function onSubmit(values: z.infer) {
46 | const action =
47 | product == null ? createProduct : updateProduct.bind(null, product.id)
48 | const data = await action(values)
49 |
50 | if (data?.message) {
51 | toast({
52 | title: data.error ? "Error" : "Success",
53 | description: data.message,
54 | variant: data.error ? "destructive" : "default",
55 | })
56 | }
57 | }
58 |
59 | return (
60 |
61 |
65 |
66 | (
70 |
71 |
72 | Product Name
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | )}
81 | />
82 | (
86 |
87 |
88 | Enter your website URL
89 |
90 |
91 |
92 |
93 |
94 |
95 | Include the protocol (http/https) and the full path to the
96 | sales page
97 |
98 |
99 |
100 | )}
101 | />
102 |
103 | (
107 |
108 | Product Description
109 |
110 |
111 |
112 |
113 | An optional description to help distinguish your product from
114 | other products
115 |
116 |
117 |
118 | )}
119 | />
120 |
121 |
122 | Save
123 |
124 |
125 |
126 |
127 | )
128 | }
129 |
--------------------------------------------------------------------------------
/src/app/dashboard/analytics/page.tsx:
--------------------------------------------------------------------------------
1 | import { HasPermission } from "@/components/HasPermission"
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
3 | import {
4 | CHART_INTERVALS,
5 | getViewsByCountryChartData,
6 | getViewsByDayChartData,
7 | getViewsByPPPChartData,
8 | } from "@/server/db/productViews"
9 | import { canAccessAnalytics } from "@/server/permissions"
10 | import { auth } from "@clerk/nextjs/server"
11 | import { ChevronDownIcon, SearchCheck } from "lucide-react"
12 | import { ViewsByCountryChart } from "../_components/charts/ViewsByCountryChart"
13 | import { ViewsByPPPChart } from "../_components/charts/ViewsByPPPChart"
14 | import { ViewsByDayChart } from "../_components/charts/ViewsByDayChart"
15 | import {
16 | DropdownMenu,
17 | DropdownMenuContent,
18 | DropdownMenuTrigger,
19 | DropdownMenuItem,
20 | } from "@/components/ui/dropdown-menu"
21 | import { Button } from "@/components/ui/button"
22 | import Link from "next/link"
23 | import { createURL } from "@/lib/utils"
24 | import { getProduct, getProducts } from "@/server/db/products"
25 | import { TimezoneDropdownMenuItem } from "../_components/TimezoneDropdownMenuItem"
26 |
27 | export default async function AnalyticsPage({
28 | searchParams,
29 | }: {
30 | searchParams: {
31 | interval?: string
32 | timezone?: string
33 | productId?: string
34 | }
35 | }) {
36 | const { userId, redirectToSignIn } = auth()
37 | if (userId == null) return redirectToSignIn()
38 |
39 | const interval =
40 | CHART_INTERVALS[searchParams.interval as keyof typeof CHART_INTERVALS] ??
41 | CHART_INTERVALS.last7Days
42 | const timezone = searchParams.timezone || "UTC"
43 | const productId = searchParams.productId
44 |
45 | return (
46 | <>
47 |
48 |
Analytics
49 |
50 |
51 |
52 |
53 |
54 | {interval.label}
55 |
56 |
57 |
58 |
59 | {Object.entries(CHART_INTERVALS).map(([key, value]) => (
60 |
61 |
66 | {value.label}
67 |
68 |
69 | ))}
70 |
71 |
72 |
77 |
78 |
79 |
80 | {timezone}
81 |
82 |
83 |
84 |
85 |
86 |
91 | UTC
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
108 |
114 |
120 |
121 |
122 | >
123 | )
124 | }
125 |
126 | async function ProductDropdown({
127 | userId,
128 | selectedProductId,
129 | searchParams,
130 | }: {
131 | userId: string
132 | selectedProductId?: string
133 | searchParams: Record
134 | }) {
135 | const products = await getProducts(userId)
136 |
137 | return (
138 |
139 |
140 |
141 | {products.find(p => p.id === selectedProductId)?.name ??
142 | "All Products"}
143 |
144 |
145 |
146 |
147 |
148 |
153 | All Products
154 |
155 |
156 | {products.map(product => (
157 |
158 |
163 | {product.name}
164 |
165 |
166 | ))}
167 |
168 |
169 | )
170 | }
171 |
172 | async function ViewsByDayCard(
173 | props: Parameters[0]
174 | ) {
175 | const chartData = await getViewsByDayChartData(props)
176 |
177 | return (
178 |
179 |
180 | Visitors Per Day
181 |
182 |
183 |
184 |
185 |
186 | )
187 | }
188 |
189 | async function ViewsByPPPCard(
190 | props: Parameters[0]
191 | ) {
192 | const chartData = await getViewsByPPPChartData(props)
193 |
194 | return (
195 |
196 |
197 | Visitors Per PPP Group
198 |
199 |
200 |
201 |
202 |
203 | )
204 | }
205 |
206 | async function ViewsByCountryCard(
207 | props: Parameters[0]
208 | ) {
209 | const chartData = await getViewsByCountryChartData(props)
210 |
211 | return (
212 |
213 |
214 | Visitors Per Country
215 |
216 |
217 |
218 |
219 |
220 | )
221 | }
222 |
--------------------------------------------------------------------------------
/src/app/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 | import { NavBar } from "./_components/NavBar"
3 |
4 | export default function DashboardLayout({ children }: { children: ReactNode }) {
5 | return (
6 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { getProducts } from "@/server/db/products"
2 | import { auth } from "@clerk/nextjs/server"
3 | import { NoProducts } from "./_components/NoProducts"
4 | import Link from "next/link"
5 | import { ArrowRightIcon, PlusIcon } from "lucide-react"
6 | import { Button } from "@/components/ui/button"
7 | import { ProductGrid } from "./_components/ProductGrid"
8 | import { HasPermission } from "@/components/HasPermission"
9 | import { canAccessAnalytics } from "@/server/permissions"
10 | import {
11 | CHART_INTERVALS,
12 | getViewsByDayChartData,
13 | } from "@/server/db/productViews"
14 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
15 | import { ViewsByDayChart } from "./_components/charts/ViewsByDayChart"
16 |
17 | export default async function DashboardPage() {
18 | const { userId, redirectToSignIn } = auth()
19 | if (userId == null) return redirectToSignIn()
20 |
21 | const products = await getProducts(userId, { limit: 6 })
22 |
23 | if (products.length === 0) return
24 |
25 | return (
26 | <>
27 |
28 |
32 | Products
33 |
34 |
35 |
36 |
37 |
38 | New Product
39 |
40 |
41 |
42 |
43 |
44 |
48 | Analytics
49 |
50 |
51 |
52 |
53 |
54 |
55 | >
56 | )
57 | }
58 |
59 | async function AnalyticsChart({ userId }: { userId: string }) {
60 | const chartData = await getViewsByDayChartData({
61 | userId,
62 | interval: CHART_INTERVALS.last30Days,
63 | timezone: "UTC",
64 | })
65 |
66 | return (
67 |
68 |
69 | Views by Day
70 |
71 |
72 |
73 |
74 |
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/src/app/dashboard/products/[productId]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | import { CountryDiscountsForm } from "@/app/dashboard/_components/forms/CountryDiscountsForm"
2 | import { ProductCustomizationForm } from "@/app/dashboard/_components/forms/ProductCustomizationForm"
3 | import { ProductDetailsForm } from "@/app/dashboard/_components/forms/ProductDeailsForm"
4 | import { PageWithBackButton } from "@/app/dashboard/_components/PageWithBackButton"
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card"
12 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
13 | import { clearFullCache } from "@/lib/cache"
14 | import {
15 | getProduct,
16 | getProductCountryGroups,
17 | getProductCustomization,
18 | } from "@/server/db/products"
19 | import { canCustomizeBanner, canRemoveBranding } from "@/server/permissions"
20 | import { auth } from "@clerk/nextjs/server"
21 | import { notFound } from "next/navigation"
22 |
23 | export default async function EditProductPage({
24 | params: { productId },
25 | searchParams: { tab = "details" },
26 | }: {
27 | params: { productId: string }
28 | searchParams: { tab?: string }
29 | }) {
30 | const { userId, redirectToSignIn } = auth()
31 | if (userId == null) return redirectToSignIn()
32 |
33 | const product = await getProduct({ id: productId, userId })
34 | if (product == null) return notFound()
35 |
36 | return (
37 |
41 |
42 |
43 | Details
44 | Country
45 | Customization
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | function DetailsTab({
62 | product,
63 | }: {
64 | product: {
65 | id: string
66 | name: string
67 | description: string | null
68 | url: string
69 | }
70 | }) {
71 | return (
72 |
73 |
74 | Product Details
75 |
76 |
77 |
78 |
79 |
80 | )
81 | }
82 |
83 | async function CountryTab({
84 | productId,
85 | userId,
86 | }: {
87 | productId: string
88 | userId: string
89 | }) {
90 | const countryGroups = await getProductCountryGroups({
91 | productId,
92 | userId,
93 | })
94 |
95 | return (
96 |
97 |
98 | Country Discounts
99 |
100 | Leave the discount field blank if you do not want to display deals for
101 | any specific parity group.
102 |
103 |
104 |
105 |
109 |
110 |
111 | )
112 | }
113 |
114 | async function CustomizationsTab({
115 | productId,
116 | userId,
117 | }: {
118 | productId: string
119 | userId: string
120 | }) {
121 | const customization = await getProductCustomization({ productId, userId })
122 |
123 | if (customization == null) return notFound()
124 |
125 | return (
126 |
127 |
128 | Banner Customization
129 |
130 |
131 |
136 |
137 |
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/src/app/dashboard/products/new/page.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
2 | import { PageWithBackButton } from "../../_components/PageWithBackButton"
3 | import { ProductDetailsForm } from "../../_components/forms/ProductDeailsForm"
4 | import { HasPermission } from "@/components/HasPermission"
5 | import { canCreateProduct } from "@/server/permissions"
6 |
7 | export default function NewProductPage() {
8 | return (
9 |
13 |
18 |
19 |
20 | Product Details
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/dashboard/products/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs/server"
2 | import { NoProducts } from "../_components/NoProducts"
3 | import { ProductGrid } from "../_components/ProductGrid"
4 | import { Button } from "@/components/ui/button"
5 | import { PlusIcon } from "lucide-react"
6 | import Link from "next/link"
7 | import { getProducts } from "@/server/db/products"
8 |
9 | export default async function Products() {
10 | const { userId, redirectToSignIn } = await auth()
11 | if (userId == null) return redirectToSignIn()
12 |
13 | const products = await getProducts(userId)
14 |
15 | if (products.length === 0) return
16 |
17 | return (
18 | <>
19 |
20 | Products
21 |
22 |
23 | New Product
24 |
25 |
26 |
27 |
28 | >
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/dashboard/subscription/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "@/components/ui/card"
10 | import { Progress } from "@/components/ui/progress"
11 | import {
12 | subscriptionTiers,
13 | subscriptionTiersInOrder,
14 | TierNames,
15 | } from "@/data/subscriptionTiers"
16 | import { formatCompactNumber } from "@/lib/formatters"
17 | import { cn } from "@/lib/utils"
18 | import {
19 | createCancelSession,
20 | createCheckoutSession,
21 | createCustomerPortalSession,
22 | } from "@/server/actions/stripe"
23 | import { getProductCount } from "@/server/db/products"
24 | import { getProductViewCount } from "@/server/db/productViews"
25 | import { getUserSubscriptionTier } from "@/server/db/subscription"
26 | import { auth } from "@clerk/nextjs/server"
27 | import { startOfMonth } from "date-fns"
28 | import { CheckIcon } from "lucide-react"
29 | import { ReactNode } from "react"
30 |
31 | export default async function SubscriptionPage() {
32 | const { userId, redirectToSignIn } = auth()
33 | if (userId == null) return redirectToSignIn()
34 | const tier = await getUserSubscriptionTier(userId)
35 | const productCount = await getProductCount(userId)
36 | const pricingViewCount = await getProductViewCount(
37 | userId,
38 | startOfMonth(new Date())
39 | )
40 | return (
41 | <>
42 | Your Subscription
43 |
44 |
45 |
46 |
47 | Monthly Usage
48 |
49 | {formatCompactNumber(pricingViewCount)} /{" "}
50 | {formatCompactNumber(tier.maxNumberOfVisits)} pricing page
51 | visits this month
52 |
53 |
54 |
55 |
58 |
59 |
60 |
61 |
62 | Number of Products
63 |
64 | {productCount} / {tier.maxNumberOfProducts} products created
65 |
66 |
67 |
68 |
71 |
72 |
73 |
74 | {tier != subscriptionTiers.Free && (
75 |
76 |
77 | You are currently on the {tier.name} plan
78 |
79 | If you would like to upgrade, cancel, or change your payment
80 | method use the button below.
81 |
82 |
83 |
84 |
85 |
90 | Manage Subscription
91 |
92 |
93 |
94 |
95 | )}
96 |
97 |
98 | {subscriptionTiersInOrder.map(t => (
99 |
100 | ))}
101 |
102 | >
103 | )
104 | }
105 |
106 | function PricingCard({
107 | name,
108 | priceInCents,
109 | maxNumberOfVisits,
110 | maxNumberOfProducts,
111 | canRemoveBranding,
112 | canAccessAnalytics,
113 | canCustomizeBanner,
114 | currentTierName,
115 | }: (typeof subscriptionTiersInOrder)[number] & { currentTierName: TierNames }) {
116 | const isCurrent = currentTierName === name
117 |
118 | return (
119 |
120 |
121 | {name}
122 |
123 | ${priceInCents / 100} /mo
124 |
125 |
126 | {formatCompactNumber(maxNumberOfVisits)} pricing page visits/mo
127 |
128 |
129 |
130 |
137 |
142 | {isCurrent ? "Current" : "Swap"}
143 |
144 |
145 |
146 |
147 |
148 | {maxNumberOfProducts}{" "}
149 | {maxNumberOfProducts === 1 ? "product" : "products"}
150 |
151 | PPP discounts
152 | {canCustomizeBanner && Banner customization }
153 | {canAccessAnalytics && Advanced analytics }
154 | {canRemoveBranding && Remove Easy PPP branding }
155 |
156 |
157 | )
158 | }
159 |
160 | function Feature({
161 | children,
162 | className,
163 | }: {
164 | children: ReactNode
165 | className?: string
166 | }) {
167 | return (
168 |
169 |
170 | {children}
171 |
172 | )
173 | }
174 |
--------------------------------------------------------------------------------
/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WebDevSimplified/parity-deals-clone/3661f28d75205d3db84d4b86c82ae535acd11272/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WebDevSimplified/parity-deals-clone/3661f28d75205d3db84d4b86c82ae535acd11272/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @layer base {
5 | :root {
6 | --background: 0 0% 100%;
7 | --foreground: 240 10% 3.9%;
8 | --card: 0 0% 100%;
9 | --card-foreground: 240 10% 3.9%;
10 | --popover: 0 0% 100%;
11 | --popover-foreground: 240 10% 3.9%;
12 | --primary: 240 5.9% 10%;
13 | --primary-foreground: 0 0% 98%;
14 | --secondary: 240 4.8% 95.9%;
15 | --secondary-foreground: 240 5.9% 10%;
16 | --muted: 240 4.8% 95.9%;
17 | --muted-foreground: 240 3.8% 46.1%;
18 | --accent: 913 82% 31%;
19 | --accent-foreground: 0 0% 100%;
20 | --destructive: 0 84.2% 60.2%;
21 | --destructive-foreground: 0 0% 98%;
22 | --border: 240 5.9% 90%;
23 | --input: 240 5.9% 90%;
24 | --ring: 240 10% 3.9%;
25 | --chart-1: 12 76% 61%;
26 | --chart-2: 173 58% 39%;
27 | --chart-3: 197 37% 24%;
28 | --chart-4: 43 74% 66%;
29 | --chart-5: 27 87% 67%;
30 | --radius: 0.5rem;
31 | }
32 | .dark {
33 | --background: 240 10% 3.9%;
34 | --foreground: 0 0% 98%;
35 | --card: 240 10% 3.9%;
36 | --card-foreground: 0 0% 98%;
37 | --popover: 240 10% 3.9%;
38 | --popover-foreground: 0 0% 98%;
39 | --primary: 0 0% 98%;
40 | --primary-foreground: 240 5.9% 10%;
41 | --secondary: 240 3.7% 15.9%;
42 | --secondary-foreground: 0 0% 98%;
43 | --muted: 240 3.7% 15.9%;
44 | --muted-foreground: 240 5% 64.9%;
45 | --accent: 240 3.7% 15.9%;
46 | --accent-foreground: 0 0% 98%;
47 | --destructive: 0 62.8% 30.6%;
48 | --destructive-foreground: 0 0% 98%;
49 | --border: 240 3.7% 15.9%;
50 | --input: 240 3.7% 15.9%;
51 | --ring: 240 4.9% 83.9%;
52 | --chart-1: 220 70% 50%;
53 | --chart-2: 160 60% 45%;
54 | --chart-3: 30 80% 55%;
55 | --chart-4: 280 65% 60%;
56 | --chart-5: 340 75% 55%;
57 | }
58 | }
59 | @layer base {
60 | * {
61 | @apply border-border;
62 | }
63 | body {
64 | @apply bg-background text-foreground;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import localFont from "next/font/local"
3 | import "./globals.css"
4 | import { ClerkProvider } from "@clerk/nextjs"
5 | import { Toaster } from "@/components/ui/toaster"
6 |
7 | const geistSans = localFont({
8 | src: "./fonts/GeistVF.woff",
9 | variable: "--font-geist-sans",
10 | weight: "100 900",
11 | })
12 | const geistMono = localFont({
13 | src: "./fonts/GeistMonoVF.woff",
14 | variable: "--font-geist-mono",
15 | weight: "100 900",
16 | })
17 |
18 | export const metadata: Metadata = {
19 | title: "Create Next App",
20 | description: "Generated by create next app",
21 | }
22 |
23 | export default function RootLayout({
24 | children,
25 | }: Readonly<{
26 | children: React.ReactNode
27 | }>) {
28 | return (
29 |
30 |
31 |
34 | {children}
35 |
36 |
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Banner.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/data/env/client"
2 |
3 | export function Banner({
4 | message,
5 | mappings,
6 | customization,
7 | canRemoveBranding,
8 | }: {
9 | canRemoveBranding: boolean
10 | message: string
11 | mappings: {
12 | coupon: string
13 | discount: string
14 | country: string
15 | }
16 | customization: {
17 | backgroundColor: string
18 | textColor: string
19 | fontSize: string
20 | isSticky: boolean
21 | classPrefix?: string | null
22 | }
23 | }) {
24 | const prefix = customization.classPrefix ?? ""
25 | const mappedMessage = Object.entries(mappings).reduce(
26 | (mappedMessage, [key, value]) => {
27 | return mappedMessage.replace(new RegExp(`{${key}}`, "g"), value)
28 | },
29 | message.replace(/'/g, "'")
30 | )
31 |
32 | return (
33 | <>
34 |
62 |
63 |
79 | >
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/BrandLogo.tsx:
--------------------------------------------------------------------------------
1 | import { Globe2Icon } from "lucide-react"
2 |
3 | export function BrandLogo() {
4 | return (
5 |
6 |
7 | Easy PPP
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/HasPermission.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs/server"
2 | import { AwaitedReactNode } from "react"
3 | import { NoPermissionCard } from "./NoPermissionCard"
4 |
5 | export async function HasPermission({
6 | permission,
7 | renderFallback = false,
8 | fallbackText,
9 | children,
10 | }: {
11 | permission: (userId: string | null) => Promise
12 | renderFallback?: boolean
13 | fallbackText?: string
14 | children: AwaitedReactNode
15 | }) {
16 | const { userId } = auth()
17 | const hasPermission = await permission(userId)
18 | if (hasPermission) return children
19 | if (renderFallback) return {fallbackText}
20 | return null
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/NoPermissionCard.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 | import { Button } from "./ui/button"
3 | import {
4 | Card,
5 | CardContent,
6 | CardDescription,
7 | CardFooter,
8 | CardHeader,
9 | CardTitle,
10 | } from "./ui/card"
11 | import { ReactNode } from "react"
12 |
13 | export function NoPermissionCard({
14 | children = "You do not have permission to perform this action. Try upgrading your account to access this feature.",
15 | }: {
16 | children?: ReactNode
17 | }) {
18 | return (
19 |
20 |
21 | Permission Denied
22 |
23 |
24 | {children}
25 |
26 |
27 |
28 | Upgrade Account
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/RequiredLabelIcon.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import { AsteriskIcon } from "lucide-react"
3 | import { ComponentPropsWithoutRef } from "react"
4 |
5 | export function RequiredLabelIcon({
6 | className,
7 | ...props
8 | }: ComponentPropsWithoutRef) {
9 | return (
10 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | accent: "bg-accent text-accent-foreground shadow hover:bg-accent/90",
15 | destructive:
16 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
17 | outline:
18 | "border border-input bg-background shadow-sm hover:bg-muted hover:text-muted-foreground",
19 | secondary:
20 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
21 | ghost: "hover:bg-accent hover:text-accent-foreground",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2",
26 | sm: "h-8 rounded-md px-3 text-xs",
27 | lg: "h-10 rounded-md px-8",
28 | icon: "h-9 w-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button"
47 | return (
48 |
53 | )
54 | }
55 | )
56 | Button.displayName = "Button"
57 |
58 | export { Button, buttonVariants }
59 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/src/components/ui/chart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RechartsPrimitive from "recharts"
5 | import {
6 | NameType,
7 | Payload,
8 | ValueType,
9 | } from "recharts/types/component/DefaultTooltipContent"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | // Format: { THEME_NAME: CSS_SELECTOR }
14 | const THEMES = { light: "", dark: ".dark" } as const
15 |
16 | export type ChartConfig = {
17 | [k in string]: {
18 | label?: React.ReactNode
19 | icon?: React.ComponentType
20 | } & (
21 | | { color?: string; theme?: never }
22 | | { color?: never; theme: Record }
23 | )
24 | }
25 |
26 | type ChartContextProps = {
27 | config: ChartConfig
28 | }
29 |
30 | const ChartContext = React.createContext(null)
31 |
32 | function useChart() {
33 | const context = React.useContext(ChartContext)
34 |
35 | if (!context) {
36 | throw new Error("useChart must be used within a ")
37 | }
38 |
39 | return context
40 | }
41 |
42 | const ChartContainer = React.forwardRef<
43 | HTMLDivElement,
44 | React.ComponentProps<"div"> & {
45 | config: ChartConfig
46 | children: React.ComponentProps<
47 | typeof RechartsPrimitive.ResponsiveContainer
48 | >["children"]
49 | }
50 | >(({ id, className, children, config, ...props }, ref) => {
51 | const uniqueId = React.useId()
52 | const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
53 |
54 | return (
55 |
56 |
65 |
66 |
67 | {children}
68 |
69 |
70 |
71 | )
72 | })
73 | ChartContainer.displayName = "Chart"
74 |
75 | const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
76 | const colorConfig = Object.entries(config).filter(
77 | ([_, config]) => config.theme || config.color
78 | )
79 |
80 | if (!colorConfig.length) {
81 | return null
82 | }
83 |
84 | return (
85 |