diff --git a/.nvmrc b/.nvmrc index c369ba6..0a47c85 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.12.0 \ No newline at end of file +lts/iron \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 46bf756..fd23a5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", @@ -48,7 +49,7 @@ "devDependencies": { "@types/eslint": "^8.56.2", "@types/lodash": "^4.17.0", - "@types/node": "^20.11.20", + "@types/node": "^20.12.x", "@types/nodemailer": "^6.4.14", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", @@ -64,6 +65,7 @@ "prisma": "^5.10.2", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "tsx": "^4.7.2", "typescript": "^5.4.2" } }, @@ -178,6 +180,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -1756,6 +1782,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz", + "integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", @@ -2751,6 +2808,34 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "optional": true, + "peer": true + }, "node_modules/@types/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", @@ -2855,9 +2940,9 @@ "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, "node_modules/@types/node": { - "version": "20.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.2.tgz", - "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dependencies": { "undici-types": "~5.26.4" } @@ -3302,6 +3387,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4098,6 +4193,13 @@ "node": ">= 0.10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4310,6 +4412,16 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6918,6 +7030,13 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "optional": true, + "peer": true + }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -10512,6 +10631,57 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "optional": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "optional": true, + "peer": true + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10529,6 +10699,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tsx": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.2.tgz", + "integrity": "sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==", + "dev": true, + "dependencies": { + "esbuild": "~0.19.10", + "get-tsconfig": "^4.7.2" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10862,6 +11051,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "optional": true, + "peer": true + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -11220,6 +11416,16 @@ "node": ">= 14" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 646d7e1..71e6ca8 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "dev:email": "npx dotenv-run-script dev:email-noenv", "dev:email-noenv": "email dev --dir src/emails", "postinstall": "prisma generate", - "lint": "next lint", - "lint:fix": "next lint --fix", + "lint": "next lint && prisma validate", + "lint:fix": "next lint --fix && prisma format", "start": "next start", "ragequit": "rm -rf .next && npm run db:push && npm run dev" }, + "prisma": { + "seed": "tsx prisma/seeds/index.ts" + }, "dependencies": { "@auth/prisma-adapter": "^1.4.0", "@hookform/resolvers": "^3.3.4", @@ -30,6 +33,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", @@ -57,7 +61,7 @@ "devDependencies": { "@types/eslint": "^8.56.2", "@types/lodash": "^4.17.0", - "@types/node": "^20.11.20", + "@types/node": "^20.12.x", "@types/nodemailer": "^6.4.14", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", @@ -73,6 +77,7 @@ "prisma": "^5.10.2", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "tsx": "^4.7.2", "typescript": "^5.4.2" }, "ct3aMetadata": { diff --git a/prisma/migrations/20240411161725_add_membership_template/migration.sql b/prisma/migrations/20240411161725_add_membership_template/migration.sql index b58c046..1bf6fb7 100644 --- a/prisma/migrations/20240411161725_add_membership_template/migration.sql +++ b/prisma/migrations/20240411161725_add_membership_template/migration.sql @@ -15,7 +15,7 @@ CREATE TABLE "MembershipTemplate" ( "priceUnit" "PriceUnit" NOT NULL DEFAULT 'EUR', "stripePriceId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL; + "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "MembershipTemplate_pkey" PRIMARY KEY ("id") ); diff --git a/prisma/migrations/20240411161726_add_membership_keys/migration.sql b/prisma/migrations/20240411161726_add_membership_keys/migration.sql new file mode 100644 index 0000000..805e072 --- /dev/null +++ b/prisma/migrations/20240411161726_add_membership_keys/migration.sql @@ -0,0 +1,6 @@ +-- CreateIndex +CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId"); + +-- AddForeignKey +ALTER TABLE "Membership" ADD COLUMN "membershipTemplateId" TEXT NOT NULL; +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_membershipTemplateId_fkey" FOREIGN KEY ("membershipTemplateId") REFERENCES "MembershipTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2987f04..a145991 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,7 +67,7 @@ model User { email String? @unique emailVerified DateTime? image String? - stripeCustomerId String? + stripeCustomerId String? @unique accounts Account[] sessions Session[] memberships Membership[] @@ -96,29 +96,42 @@ enum MembershipStatus { model Membership { id String @id @default(cuid()) - userId String socialSecurityNumber String internalRef String? @unique // @db.Text status MembershipStatus @default(PENDING) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt lastPaymentAt DateTime? expiresAt DateTime? stripeSubscriptionId String? @unique // @db.Text - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Associations IDs + userId String + membershipTemplateId String + + // Associations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + membershipTemplate MembershipTemplate @relation(fields: [membershipTemplateId], references: [id], onDelete: Cascade) } model MembershipTemplate { - id String @id @default(cuid()) - title String - description String? - features String[] - priceAmount Int - pricePeriod PricePeriod @default(Yearly) - priceUnit PriceUnit @default(EUR) - stripePriceId String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String + description String? + features String[] + priceAmount Int + pricePeriod PricePeriod @default(Yearly) + priceUnit PriceUnit @default(EUR) + stripePriceId String @unique + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Associations + membership Membership[] } enum PricePeriod { diff --git a/prisma/seeds/index.ts b/prisma/seeds/index.ts new file mode 100644 index 0000000..1a84a6d --- /dev/null +++ b/prisma/seeds/index.ts @@ -0,0 +1,25 @@ +import {PrismaClient} from '@prisma/client' + +import {membershipTemplates} from "./models/membershipTemplates"; +import {users} from "./models/users"; + +const prisma = new PrismaClient() + +function seedModel(model: any, data: object[]) { + return model.createMany({data, skipDuplicates: true}) +} + +async function main() { + await seedModel(prisma.user, users) + await seedModel(prisma.membershipTemplate, membershipTemplates) +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) \ No newline at end of file diff --git a/prisma/seeds/models/membershipTemplates.ts b/prisma/seeds/models/membershipTemplates.ts new file mode 100644 index 0000000..f0de46a --- /dev/null +++ b/prisma/seeds/models/membershipTemplates.ts @@ -0,0 +1,47 @@ +import {PricePeriod, PriceUnit} from "@prisma/client"; + +export const membershipTemplates = [ + { + title: "Cinema Club 2024 Membership", + description: "Support the magic of cinema and join our mission to create a vibrant international community of movie lovers.", + features: [ + "Join an exclusive community of film enthusiasts", + "Early access to tickets for premieres and special screenings", + ], + priceAmount: 3000, + pricePeriod: PricePeriod.Yearly, + priceUnit: PriceUnit.EUR, + stripePriceId: 'price_123456789', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + title: "Cinema Club 2024 Premium", + description: "Contribute to the celebration of cinematic art and be part of our effort to build a global network of individuals passionate about film.", + features: [ + "Priority booking for new releases and exclusive content", + "Enjoy a 10€ discount on every purchase at our cinema shops", + ], + priceAmount: 6000, + pricePeriod: PricePeriod.Yearly, + priceUnit: PriceUnit.EUR, + stripePriceId: 'price_987654321', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + title: "Cinema Club 2024 Elite", + description: "Support leading-edge film presentations and help us foster a worldwide community that celebrates and enhances the cinematic experience.", + features: [ + "Advanced access to workshops with filmmakers and special movie content", + "Receive a 15€ discount on all merchandise and concession orders", + "Exclusive invitations to members-only previews and gala events", + ], + priceAmount: 9000, + pricePeriod: PricePeriod.Yearly, + priceUnit: PriceUnit.EUR, + stripePriceId: 'price_483123255', + createdAt: new Date(), + updatedAt: new Date(), + } +] \ No newline at end of file diff --git a/prisma/seeds/models/users.ts b/prisma/seeds/models/users.ts new file mode 100644 index 0000000..2db97c5 --- /dev/null +++ b/prisma/seeds/models/users.ts @@ -0,0 +1,16 @@ +import {UserRole} from "@prisma/client"; + +export const users = [ + { + name: "Alice", + role: UserRole.member, + email: "alice@example.com", + emailVerified: new Date(), + }, + { + name: "Bob", + role: UserRole.admin, + email: "bob@example.com", + emailVerified: new Date(), + } +] \ No newline at end of file diff --git a/src/app/actions/createMembership.ts b/src/app/actions/createMembership.ts index 8792a8e..2b8b7bf 100644 --- a/src/app/actions/createMembership.ts +++ b/src/app/actions/createMembership.ts @@ -13,6 +13,7 @@ export interface FormProps { firstName: string lastName: string socialSecurityNumber: string + membershipTemplateId: string } export async function createMembership( @@ -22,6 +23,13 @@ export async function createMembership( // Avoid double membership creation if (prevState.nextStep === "providePayment") return prevState + // Fetch membershipTemplate + const membershipTemplate = await db.membershipTemplate.findUnique({ + where: { + id: data.membershipTemplateId, + }, + }) + // Check for user let user = await db.user.findFirst({ where: { @@ -84,6 +92,7 @@ export async function createMembership( socialSecurityNumber: data.socialSecurityNumber, status: MembershipStatus.PENDING, userId: user.id, + membershipTemplateId: membershipTemplate.id, }, }) diff --git a/src/app/members/@authenticated/page.tsx b/src/app/members/@authenticated/page.tsx index 039aa76..db29166 100644 --- a/src/app/members/@authenticated/page.tsx +++ b/src/app/members/@authenticated/page.tsx @@ -1,13 +1,26 @@ -import { - MembershipCard, - PricePeriod, - PriceUnit, -} from "@/components/molecules/membershipCard" +import { MembershipTemplateCard } from "@/components/molecules/membershipTemplateCard" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { getServerAuthSession } from "@/server/auth" +import { MembershipStatus, PricePeriod, PriceUnit } from "@prisma/client" +import { db } from "@/services/db" + +function getData(userId) { + return db.membership.findFirst({ + where: { + status: { + not: MembershipStatus.PENDING, + }, + userId: userId, + }, + include: { + membershipTemplate: true, + }, + }) +} export default async function MembershipPortalHomePage() { const session = await getServerAuthSession() + const membership = await getData(session?.user.id!) return ( <> @@ -24,24 +37,19 @@ export default async function MembershipPortalHomePage() { partecipate in the association governance </p> - <MembershipCard - showPrice={false} - title={"SH 2024 Membership"} - features={[ - "Be part of the community", - "Early-access to event tickets and contents", - "Dedicated 5€ discount on all shop orders", - "Exclusive members meetups and dinners", - ]} - price={{ - period: PricePeriod.Yearly, - unit: PriceUnit.Eur, - value: 2400, - }} - description={ - "Support groundbreaking open source initiatives and join us in our mission to create an international community of open source lovers." - } - /> + {membership && ( + <MembershipTemplateCard + showPrice={false} + title={membership.membershipTemplate.title} + features={membership.membershipTemplate.features} + price={{ + period: membership.membershipTemplate.pricePeriod, + unit: membership.membershipTemplate.priceUnit, + value: membership.membershipTemplate.priceAmount, + }} + description={membership.membershipTemplate.description} + /> + )} <div className={"grid gap-4"}> <h3 className={"text-lg"}>Latest Announcements</h3> diff --git a/src/app/signup/form.tsx b/src/app/signup/form.tsx new file mode 100644 index 0000000..e8e3e53 --- /dev/null +++ b/src/app/signup/form.tsx @@ -0,0 +1,416 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from "@stripe/react-stripe-js" +import { loadStripe } from "@stripe/stripe-js" +import Image from "next/image" +import Link from "next/link" +import { type Dispatch, type SetStateAction, useState } from "react" +import { useFormState } from "react-dom" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { + createMembership, + type FormProps, +} from "@/app/actions/createMembership" +import type { ServerActionState } from "@/app/actions/types" +import { ServerActionStatus } from "@/app/actions/types" +import { MembershipTemplateCard } from "@/components/molecules/membershipTemplateCard" +import { StatefulButton } from "@/components/molecules/statefulButton" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import checkmark from "@/images/checkmark.svg" +import { type MembershipTemplate } from "@prisma/client" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" +import { Debug } from "@/components/devtool/debug" + +const formSchema = z.object({ + email: z.string().email(), + firstName: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), + lastName: z.string().min(2, { + message: "Surname must be at least 2 characters.", + }), + socialSecurityNumber: z + .string() + .regex( + new RegExp( + /^[A-Za-z]{6}[0-9]{2}[A-Za-z]{1}[0-9]{2}[A-Za-z]{1}[0-9]{3}[A-Za-z]{1}$/, + ), + 'Invalid format, only italians "codice fiscale" are accepted', + ), + statuteApproval: z.boolean().refine((val) => val, { + message: "Please read and accept the statute", + }), + membershipTemplateId: z.string(), +}) + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +) + +interface SignupFormProps { + membershipTemplates: MembershipTemplate[] +} + +export default function SignupForm({ membershipTemplates }: SignupFormProps) { + const [createMembershipState, createMembershipAction] = useFormState( + createMembership, + { + payload: {}, + status: ServerActionStatus.Pending, + }, + ) + + const form = useForm<z.infer<typeof formSchema>>({ + defaultValues: { + email: "lobetia@gmail.com", + firstName: "Mattia", + lastName: "Lobertini", + socialSecurityNumber: "LBRMTT92E14B157T", + statuteApproval: true, + }, + resolver: zodResolver(formSchema), + }) + + const handleForm = async (data: FormProps) => { + if (createMembershipState.nextStep === "confirmPayment") { + } else { + createMembershipAction(data) + } + } + const [step, setStep] = useState<number>(1) + + const validateStep1 = async (): Promise<void> => { + const stepIsValid = await form.trigger(["firstName", "lastName", "email"]) + if (stepIsValid) setStep(2) + } + const validateStep2 = async (): Promise<void> => { + const stepIsValid = await form.trigger([ + "socialSecurityNumber", + "statuteApproval", + ]) + if (stepIsValid) setStep(3) + } + + return ( + <div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> + <div className="flex flex-col space-y-2 text-center"> + <h1 className="text-2xl font-semibold tracking-tight"> + Activate a Membership + </h1> + <p className="text-sm text-muted-foreground"> + Fill the form below to request a membership number for the + association: <b>Schroedinger Hat</b> + </p> + </div> + + <form action={form.handleSubmit(handleForm) as any}> + <Form {...form}> + {/*Account data*/} + {createMembershipState.status === ServerActionStatus.Pending && + step === 1 && ( + <> + <div className="grid grid-cols-2 gap-2"> + <div> + <FormField + control={form.control} + name="firstName" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input placeholder="John" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + <div> + <FormField + control={form.control} + name="lastName" + render={({ field }) => ( + <FormItem> + <FormLabel>Surname</FormLabel> + <FormControl> + <Input placeholder="Doe" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className={"col-span-2 mt-4"}> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + placeholder="john@doe.com" + type={"email"} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className={"col-span-2 pt-3"}> + <div className={"flex flex-row-reverse"}> + <Button onClick={validateStep1}>Continue</Button> + </div> + </div> + </div> + + <p className={"mt-4 text-center text-xs text-gray-600"}> + By clicking Continue, you agree to our + <br /> + <Link + href="/legal/terms-of-service" + target={"_blank"} + className="underline underline-offset-4 hover:text-primary" + > + Terms of Service + </Link>{" "} + and{" "} + <Link + href="/legal/privacy-policy" + target={"_blank"} + className="underline underline-offset-4 hover:text-primary" + > + Privacy Policy + </Link> + . + </p> + </> + )} + + {/*Organisation required data*/} + {createMembershipState.status === ServerActionStatus.Pending && + step === 2 && ( + <div className="grid grid-cols-2 gap-2 duration-300 animate-in slide-in-from-right-12"> + <div className={"col-span-2"}> + <FormField + control={form.control} + name="socialSecurityNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>Social Security Number</FormLabel> + <FormControl> + <Input placeholder="LBRMTT..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className={"col-span-2 mt-4"}> + <div className={"rounded-2xl border-2 border-blue-200 p-3"}> + <FormField + control={form.control} + name="statuteApproval" + render={({ field }) => ( + <FormItem> + <div className={"flex"}> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className={"ml-2"}> + <p className={"text-sm font-semibold"}>Statute</p> + <p className={"text-sm"}> + I've read and approved{" "} + <Link + target="_blank" + className={"underline"} + href={"/documents/legal/statute"} + > + the statute + </Link> + </p> + <FormMessage /> + </div> + </div> + </FormItem> + )} + /> + </div> + </div> + + <div className={"col-span-2 pt-3"}> + <div className={"flex flex-row-reverse"}> + <Button onClick={validateStep2}>Continue</Button> + </div> + </div> + </div> + )} + + {/*Membership level*/} + {createMembershipState.status === ServerActionStatus.Pending && + step === 3 && ( + <div className="grid grid-cols-1 duration-300 animate-in slide-in-from-right-12"> + <FormField + control={form.control} + name="membershipTemplateId" + render={({ field }) => ( + <> + <Input type={"hidden"} {...field} /> + <ScrollArea className="-ml-[58px] w-[436px] p-2"> + <div className="mb-2 flex w-max space-x-4"> + {membershipTemplates.map((membershipTemplate) => ( + <div + onClick={() => { + form.setValue( + "membershipTemplateId", + membershipTemplate.id, + { + shouldTouch: true, + shouldDirty: true, + }, + ) + }} + > + <MembershipTemplateCard + className={ + form.getValues("membershipTemplateId") === + membershipTemplate.id + ? "cursor-pointer border-blue-500" + : "cursor-pointer" + } + key={membershipTemplate.id} + title={membershipTemplate.title} + features={membershipTemplate.features} + price={{ + period: membershipTemplate.pricePeriod, + unit: membershipTemplate.priceUnit, + value: membershipTemplate.priceAmount, + }} + description={membershipTemplate.description} + /> + </div> + ))} + </div> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + </> + )} + /> + + <div className={"col-span-2 mt-8"}> + <div className={"flex flex-row-reverse"}> + <StatefulButton>Subscribe</StatefulButton> + </div> + </div> + <p className={"mt-4 text-center text-xs text-gray-600"}> + By clicking Subscribe, you will proceed to payment. + <br /> + Payment is processed through our partner Stripe + </p> + </div> + )} + + {/*Payment*/} + {createMembershipState.nextStep === "providePayment" && + step !== 5 && ( + <Elements + stripe={stripePromise} + options={{ + clientSecret: (createMembershipState?.payload as any) + ?.clientSecret, + }} + > + <Step4 state={createMembershipState} setStep={setStep} /> + </Elements> + )} + + {/*Success*/} + {step === 5 && ( + <div className="grid grid-cols-1 justify-items-center"> + <Image + src={checkmark} + alt={"Success"} + width={128} + height={128} + className={"duration-500 animate-in zoom-in"} + /> + <p className={"my-4 text-2xl font-semibold"}>Welcome aboard!</p> + <p className={"text-md text-gray-800"}> + Admins will review your application and let know your membership + number in the following days. + </p> + </div> + )} + </Form> + </form> + </div> + ) +} + +interface Step4Props { + state: ServerActionState + setStep: Dispatch<SetStateAction<number>> +} + +function Step4({ state, setStep }: Step4Props) { + const stripe = useStripe() + const elements = useElements() + + const handlePaymentSubmit = async () => { + await elements?.submit() + const confirmPayment = await stripe?.confirmPayment({ + clientSecret: (state?.payload as any)?.clientSecret, + elements: elements!, + redirect: "if_required", + }) + console.log(confirmPayment) + if ((confirmPayment?.paymentIntent as any)?.status === "succeeded") { + setStep(5) + } + } + + return ( + <div className="flex flex-col duration-300 animate-in slide-in-from-right-12"> + <p className={"text-md mb-5 text-gray-800"}> + Provide your payment informations to complete your membership + subscription + </p> + + <div> + <PaymentElement /> + </div> + + <div className={"col-span-2 pt-3"}> + <div className={"flex flex-row-reverse"}> + <Button onClick={() => handlePaymentSubmit()}>Next</Button> + </div> + </div> + </div> + ) +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index e45a2ba..9cb6a19 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,385 +1,22 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from "@stripe/react-stripe-js" -import { loadStripe } from "@stripe/stripe-js" -import Image from "next/image" -import Link from "next/link" -import { type Dispatch, type SetStateAction, useState } from "react" -import { useFormState } from "react-dom" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { - createMembership, - type FormProps, -} from "@/app/actions/createMembership" -import type { ServerActionState } from "@/app/actions/types" -import { ServerActionStatus } from "@/app/actions/types" -import { - MembershipCard, - PricePeriod, - PriceUnit, -} from "@/components/molecules/membershipCard" -import { StatefulButton } from "@/components/molecules/statefulButton" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import checkmark from "@/images/checkmark.svg" - -const formSchema = z.object({ - email: z.string().email(), - firstName: z.string().min(2, { - message: "Name must be at least 2 characters.", - }), - lastName: z.string().min(2, { - message: "Surname must be at least 2 characters.", - }), - socialSecurityNumber: z - .string() - .regex( - new RegExp( - /^[A-Za-z]{6}[0-9]{2}[A-Za-z]{1}[0-9]{2}[A-Za-z]{1}[0-9]{3}[A-Za-z]{1}$/, - ), - 'Invalid format, only italians "codice fiscale" are accepted', - ), - statuteApproval: z.boolean().refine((val) => val, { - message: "Please read and accept the statute", - }), -}) - -const stripePromise = loadStripe( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, -) - -export default function SignupPage() { - const [createMembershipState, createMembershipAction] = useFormState( - createMembership, - { - payload: {}, - status: ServerActionStatus.Pending, - }, - ) - - const form = useForm<z.infer<typeof formSchema>>({ - defaultValues: { - email: "", - firstName: "", - lastName: "", - socialSecurityNumber: "", - statuteApproval: false, - }, - resolver: zodResolver(formSchema), +import SignupForm from "@/app/signup/form" +import { db } from "@/services/db" + +function getData() { + return db.membershipTemplate.findMany({ + orderBy: [ + { + priceAmount: "asc", + }, + ], }) - - const handleForm = async (data: FormProps) => { - if (createMembershipState.nextStep === "confirmPayment") { - } else { - createMembershipAction(data) - } - } - const [step, setStep] = useState<number>(1) - - const validateStep1 = async (): Promise<void> => { - const stepIsValid = await form.trigger(["firstName", "lastName", "email"]) - if (stepIsValid) setStep(2) - } - const validateStep2 = async (): Promise<void> => { - const stepIsValid = await form.trigger([ - "socialSecurityNumber", - "statuteApproval", - ]) - if (stepIsValid) setStep(3) - } - - return ( - <div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> - <div className="flex flex-col space-y-2 text-center"> - <h1 className="text-2xl font-semibold tracking-tight"> - Activate a Membership - </h1> - <p className="text-sm text-muted-foreground"> - Fill the form below to request a membership number for the - association: <b>Schroedinger Hat</b> - </p> - </div> - - <form action={form.handleSubmit(handleForm) as any}> - <Form {...form}> - {/*Account data*/} - {createMembershipState.status === ServerActionStatus.Pending && - step === 1 && ( - <> - <div className="grid grid-cols-2 gap-2"> - <div> - <FormField - control={form.control} - name="firstName" - render={({ field }) => ( - <FormItem> - <FormLabel>Name</FormLabel> - <FormControl> - <Input placeholder="John" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - <div> - <FormField - control={form.control} - name="lastName" - render={({ field }) => ( - <FormItem> - <FormLabel>Surname</FormLabel> - <FormControl> - <Input placeholder="Doe" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className={"col-span-2 mt-4"}> - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input - placeholder="john@doe.com" - type={"email"} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className={"col-span-2 pt-3"}> - <div className={"flex flex-row-reverse"}> - <Button onClick={validateStep1}>Continue</Button> - </div> - </div> - </div> - - <p className={"mt-4 text-center text-xs text-gray-600"}> - By clicking Continue, you agree to our - <br /> - <Link - href="/legal/terms-of-service" - target={"_blank"} - className="underline underline-offset-4 hover:text-primary" - > - Terms of Service - </Link>{" "} - and{" "} - <Link - href="/legal/privacy-policy" - target={"_blank"} - className="underline underline-offset-4 hover:text-primary" - > - Privacy Policy - </Link> - . - </p> - </> - )} - - {/*Organisation required data*/} - {createMembershipState.status === ServerActionStatus.Pending && - step === 2 && ( - <div className="grid grid-cols-2 gap-2 duration-300 animate-in slide-in-from-right-12"> - <div className={"col-span-2"}> - <FormField - control={form.control} - name="socialSecurityNumber" - render={({ field }) => ( - <FormItem> - <FormLabel>Social Security Number</FormLabel> - <FormControl> - <Input placeholder="LBRMTT..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className={"col-span-2 mt-4"}> - <div className={"rounded-2xl border-2 border-blue-200 p-3"}> - <FormField - control={form.control} - name="statuteApproval" - render={({ field }) => ( - <FormItem> - <div className={"flex"}> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className={"ml-2"}> - <p className={"text-sm font-semibold"}>Statute</p> - <p className={"text-sm"}> - I've read and approved{" "} - <Link - target="_blank" - className={"underline"} - href={"/documents/legal/statute"} - > - the statute - </Link> - </p> - <FormMessage /> - </div> - </div> - </FormItem> - )} - /> - </div> - </div> - - <div className={"col-span-2 pt-3"}> - <div className={"flex flex-row-reverse"}> - <Button onClick={validateStep2}>Continue</Button> - </div> - </div> - </div> - )} - - {/*Membership level*/} - {createMembershipState.status === ServerActionStatus.Pending && - step === 3 && ( - <div className="grid grid-cols-1 duration-300 animate-in slide-in-from-right-12"> - <div className="flex flex-col"> - <MembershipCard - title={"SH 2024 Membership"} - features={[ - "Be part of the community", - "Early-access to event tickets and contents", - "Dedicated 5€ discount on all shop orders", - "Exclusive members meetups and dinners", - ]} - price={{ - period: PricePeriod.Yearly, - unit: PriceUnit.Eur, - value: 2400, - }} - description={ - "Support groundbreaking open source initiatives and join us in our mission to create an international community of open source lovers." - } - /> - </div> - - <div className={"col-span-2 mt-8"}> - <div className={"flex flex-row-reverse"}> - <StatefulButton>Subscribe</StatefulButton> - </div> - </div> - <p className={"mt-4 text-center text-xs text-gray-600"}> - By clicking Subscribe, you will proceed to payment. - <br /> - Payment is processed through our partner Stripe - </p> - </div> - )} - - {/*Payment*/} - {createMembershipState.nextStep === "providePayment" && - step !== 5 && ( - <Elements - stripe={stripePromise} - options={{ - clientSecret: (createMembershipState?.payload as any) - ?.clientSecret, - }} - > - <Step4 state={createMembershipState} setStep={setStep} /> - </Elements> - )} - - {/*Success*/} - {step === 5 && ( - <div className="grid grid-cols-1 justify-items-center"> - <Image - src={checkmark} - alt={"Success"} - width={128} - height={128} - className={"duration-500 animate-in zoom-in"} - /> - <p className={"my-4 text-2xl font-semibold"}>Welcome aboard!</p> - <p className={"text-md text-gray-800"}> - Admins will review your application and let know your membership - number in the following days. - </p> - </div> - )} - </Form> - </form> - </div> - ) -} - -interface Step4Props { - state: ServerActionState - setStep: Dispatch<SetStateAction<number>> } -function Step4({ state, setStep }: Step4Props) { - const stripe = useStripe() - const elements = useElements() - - const handlePaymentSubmit = async () => { - await elements?.submit() - const confirmPayment = await stripe?.confirmPayment({ - clientSecret: (state?.payload as any)?.clientSecret, - elements: elements!, - redirect: "if_required", - }) - console.log(confirmPayment) - if ((confirmPayment?.paymentIntent as any)?.status === "succeeded") { - setStep(5) - } - } +export default async function SignupPage() { + const membershipTemplates = await getData() return ( - <div className="flex flex-col duration-300 animate-in slide-in-from-right-12"> - <p className={"text-md mb-5 text-gray-800"}> - Provide your payment informations to complete your membership - subscription - </p> - - <div> - <PaymentElement /> - </div> - - <div className={"col-span-2 pt-3"}> - <div className={"flex flex-row-reverse"}> - <Button onClick={() => handlePaymentSubmit()}>Next</Button> - </div> - </div> - </div> + <> + <SignupForm membershipTemplates={membershipTemplates} /> + </> ) } diff --git a/src/components/molecules/membershipCard.tsx b/src/components/molecules/membershipTemplateCard.tsx similarity index 79% rename from src/components/molecules/membershipCard.tsx rename to src/components/molecules/membershipTemplateCard.tsx index 3dd1009..5c3867e 100644 --- a/src/components/molecules/membershipCard.tsx +++ b/src/components/molecules/membershipTemplateCard.tsx @@ -1,4 +1,6 @@ import { Card, CardContent } from "@/components/ui/card" +import { type PricePeriod, type PriceUnit } from "@prisma/client" +import { cn } from "@/lib/utils" interface Price { value: number @@ -6,31 +8,23 @@ interface Price { unit: PriceUnit } -export enum PriceUnit { - Eur = "EUR", - Usd = "USD", -} - -export enum PricePeriod { - Monthly = "month", - Yearly = "year", -} - -interface Membership { +interface MembershipTemplateCardProps { + className?: string showPrice?: boolean title: string features: string[] - description?: string + description: string | null price: Price } -export function MembershipCard({ +export function MembershipTemplateCard({ + className = "", title, features, description, price, showPrice = true, -}: Membership) { +}: MembershipTemplateCardProps) { const moneyFormatter = new Intl.NumberFormat("en-US", { currency: price.unit, minimumFractionDigits: 0, @@ -38,7 +32,7 @@ export function MembershipCard({ }) return ( - <Card> + <Card className={cn("w-[320px]", className)}> <CardContent className={"p-6"}> <div className="mb-4 flex items-start justify-between"> <h4 className={"text-lg font-semibold"}>{title}</h4> diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..b9ffc17 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> +>(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className, + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar }