diff --git a/package-lock.json b/package-lock.json index 0cac1ed..46bf756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,16 +21,19 @@ "@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-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "@react-email/components": "0.0.16", "@stripe/react-stripe-js": "^2.6.2", "@stripe/stripe-js": "^3.2.0", "@t3-oss/env-nextjs": "^0.9.2", + "@tanstack/react-table": "^8.15.3", "@types/mdx": "^2.0.12", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lodash": "^4.17.21", - "next": "^14.1.3", + "next": "^14.2.0", "next-auth": "^4.24.6", "nodemailer": "^6.9.13", "react": "18.2.0", @@ -875,9 +878,9 @@ } }, "node_modules/@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.0.tgz", + "integrity": "sha512-4+70ELtSbRtYUuyRpAJmKC8NHBW2x1HMje9KO2Xd7IkoyucmV9SjgO+qeWMC0JWkRQXgydv1O7yKOK8nu/rITQ==" }, "node_modules/@next/eslint-plugin-next": { "version": "14.1.4", @@ -917,9 +920,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.0.tgz", + "integrity": "sha512-kHktLlw0AceuDnkVljJ/4lTJagLzDiO3klR1Fzl2APDFZ8r+aTxNaNcPmpp0xLMkgRwwk6sggYeqq0Rz9K4zzA==", "cpu": [ "arm64" ], @@ -932,9 +935,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.0.tgz", + "integrity": "sha512-HFSDu7lb1U3RDxXNeKH3NGRR5KyTPBSUTuIOr9jXoAso7i76gNYvnTjbuzGVWt2X5izpH908gmOYWtI7un+JrA==", "cpu": [ "x64" ], @@ -947,9 +950,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.0.tgz", + "integrity": "sha512-iQsoWziO5ZMxDWZ4ZTCAc7hbJ1C9UDj/gATSqTaMjW2bJFwAsvf9UM79AKnljBl73uPZ+V0kH4rvnHTco4Ps2w==", "cpu": [ "arm64" ], @@ -962,9 +965,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.0.tgz", + "integrity": "sha512-0JOk2uzLUt8fJK5LpsKKZa74zAch7bJjjgJzR9aOMs231AlE4gPYzsSm430ckZitjPGKeH5bgDZjqwqJQKIS2w==", "cpu": [ "arm64" ], @@ -977,9 +980,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.0.tgz", + "integrity": "sha512-uYHkuTzX0NM6biKNp7hdKTf+BF0iMV254SxO0B8PgrQkxUBKGmk5ysHKB+FYBfdf9xei/t8OIKlXJs9ckD943A==", "cpu": [ "x64" ], @@ -992,9 +995,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.0.tgz", + "integrity": "sha512-paN89nLs2dTBDtfXWty1/NVPit+q6ldwdktixYSVwiiAz647QDCd+EIYqoiS+/rPG3oXs/A7rWcJK9HVqfnMVg==", "cpu": [ "x64" ], @@ -1007,9 +1010,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.0.tgz", + "integrity": "sha512-j1oiidZisnymYjawFqEfeGNcE22ZQ7lGUaa4pGOCVWrWeIDkPSj8zYgS9TzMNlg17Q3wSWCQC/F5uJAhSh7qcA==", "cpu": [ "arm64" ], @@ -1022,9 +1025,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.0.tgz", + "integrity": "sha512-6ff6F4xb+QGD1jhx/dOT9Ot7PQ/GAYekV9ykwEh2EFS/cLTyU4Y3cXkX5cNtNIhpctS5NvyjW9gIksRNErYE0A==", "cpu": [ "ia32" ], @@ -1037,9 +1040,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.0.tgz", + "integrity": "sha512-09DbG5vXAxz0eTFSf1uebWD36GF3D5toynRkgo2AlSrxwGZkWtJ1RhmrczRYQ17eD5bdo4FZ0ibiffdq5kc4vg==", "cpu": [ "x64" ], @@ -1184,6 +1187,14 @@ "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" }, + "node_modules/@radix-ui/number": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", + "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", @@ -1745,6 +1756,49 @@ } } }, + "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", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@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-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "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-slot": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", @@ -1763,6 +1817,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", + "integrity": "sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-portal": "1.0.4", + "@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-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "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-toggle": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz", @@ -2632,6 +2720,37 @@ } } }, + "node_modules/@tanstack/react-table": { + "version": "8.15.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz", + "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==", + "dependencies": { + "@tanstack/table-core": "8.15.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.15.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz", + "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", @@ -7695,12 +7814,12 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.0.tgz", + "integrity": "sha512-2T41HqJdKPqheR27ll7MFZ3gtTYvGew7cUc0PwPSyK9Ao5vvwpf9bYfP4V5YBGLckHF2kEGvrLte5BqLSv0s8g==", "dependencies": { - "@next/env": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.0", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -7714,18 +7833,19 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4" + "@next/swc-darwin-arm64": "14.2.0", + "@next/swc-darwin-x64": "14.2.0", + "@next/swc-linux-arm64-gnu": "14.2.0", + "@next/swc-linux-arm64-musl": "14.2.0", + "@next/swc-linux-x64-gnu": "14.2.0", + "@next/swc-linux-x64-musl": "14.2.0", + "@next/swc-win32-arm64-msvc": "14.2.0", + "@next/swc-win32-ia32-msvc": "14.2.0", + "@next/swc-win32-x64-msvc": "14.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -7734,6 +7854,9 @@ "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, "sass": { "optional": true } @@ -7782,6 +7905,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 0127c33..646d7e1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "postinstall": "prisma generate", "lint": "next lint", "lint:fix": "next lint --fix", - "start": "next start" + "start": "next start", + "ragequit": "rm -rf .next && npm run db:push && npm run dev" }, "dependencies": { "@auth/prisma-adapter": "^1.4.0", @@ -29,16 +30,19 @@ "@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-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "@react-email/components": "0.0.16", "@stripe/react-stripe-js": "^2.6.2", "@stripe/stripe-js": "^3.2.0", "@t3-oss/env-nextjs": "^0.9.2", + "@tanstack/react-table": "^8.15.3", "@types/mdx": "^2.0.12", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lodash": "^4.17.21", - "next": "^14.1.3", + "next": "^14.2.0", "next-auth": "^4.24.6", "nodemailer": "^6.9.13", "react": "18.2.0", diff --git a/prisma/migrations/20240411161725_add_membership_template/migration.sql b/prisma/migrations/20240411161725_add_membership_template/migration.sql new file mode 100644 index 0000000..b58c046 --- /dev/null +++ b/prisma/migrations/20240411161725_add_membership_template/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "PricePeriod" AS ENUM ('Yearly', 'Monthly'); + +-- CreateEnum +CREATE TYPE "PriceUnit" AS ENUM ('EUR', 'USD'); + +-- CreateTable +CREATE TABLE "MembershipTemplate" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "features" TEXT[], + "priceAmount" INTEGER NOT NULL, + "pricePeriod" "PricePeriod" NOT NULL DEFAULT 'Yearly', + "priceUnit" "PriceUnit" NOT NULL DEFAULT 'EUR', + "stripePriceId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL; + + CONSTRAINT "MembershipTemplate_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "MembershipTemplate_stripePriceId_key" ON "MembershipTemplate"("stripePriceId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43cd773..2987f04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,19 +61,19 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - role UserRole @default(member) - email String? @unique - emailVerified DateTime? - image String? - stripeCustomerId String? - accounts Account[] - sessions Session[] - memberships Membership[] + id String @id @default(cuid()) + name String? + role UserRole @default(member) + email String? @unique + emailVerified DateTime? + image String? + stripeCustomerId String? + accounts Account[] + sessions Session[] + memberships Membership[] } -enum UserRole{ +enum UserRole { member admin } @@ -107,3 +107,26 @@ model Membership { stripeSubscriptionId String? @unique // @db.Text user User @relation(fields: [userId], 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 +} + +enum PricePeriod { + Yearly + Monthly +} + +enum PriceUnit { + EUR + USD +} diff --git a/src/app/actions/createMembership.ts b/src/app/actions/createMembership.ts index 0acc5cf..8792a8e 100644 --- a/src/app/actions/createMembership.ts +++ b/src/app/actions/createMembership.ts @@ -1,21 +1,18 @@ -"use server"; +"use server" -import { MembershipStatus } from "@prisma/client"; +import { MembershipStatus } from "@prisma/client" -import { - type ServerActionState, - ServerActionStatus, -} from "@/app/actions/types"; -import { db } from "@/services/db"; -import { stripe } from "@/services/stripe"; +import { type ServerActionState, ServerActionStatus } from "@/app/actions/types" +import { db } from "@/services/db" +import { stripe } from "@/services/stripe" -const MEMBERSHIP_PRICE_ID = "price_1P3HNlCXdJySzBrwlcoAQqS2"; +const MEMBERSHIP_PRICE_ID = "price_1P3HNlCXdJySzBrwlcoAQqS2" export interface FormProps { - email: string; - firstName: string; - lastName: string; - socialSecurityNumber: string; + email: string + firstName: string + lastName: string + socialSecurityNumber: string } export async function createMembership( @@ -23,14 +20,14 @@ export async function createMembership( data: FormProps, ): Promise { // Avoid double membership creation - if (prevState.nextStep === "providePayment") return prevState; + if (prevState.nextStep === "providePayment") return prevState // Check for user let user = await db.user.findFirst({ where: { email: data.email, }, - }); + }) // No User found, creates it if (!user) { @@ -41,7 +38,7 @@ export async function createMembership( emailVerified: new Date(), name: `${data.firstName} ${data.lastName}`.trim(), }, - }); + }) } // User is not linked to Stripe, creates it @@ -50,13 +47,13 @@ export async function createMembership( const stripeCustomer = await stripe.customers.create({ email: data.email, name: `${data.firstName} ${data.lastName}`.trim(), - }); + }) - user.stripeCustomerId = stripeCustomer.id; + user.stripeCustomerId = stripeCustomer.id await db.user.update({ data: { stripeCustomerId: stripeCustomer.id }, where: { id: user.id }, - }); + }) } // Lookup for membership @@ -67,7 +64,7 @@ export async function createMembership( }, userId: user.id, }, - }); + }) // Membership already exists, block creation if (membership) { @@ -78,7 +75,7 @@ export async function createMembership( }, ], status: ServerActionStatus.Error, - }; + } } // Creates membership @@ -88,7 +85,7 @@ export async function createMembership( status: MembershipStatus.PENDING, userId: user.id, }, - }); + }) // Create Stripe subscription const stripeSubscription = await stripe.subscriptions.create({ @@ -103,14 +100,14 @@ export async function createMembership( payment_settings: { payment_method_types: ["card", "link", "paypal", "sepa_debit"], }, - }); + }) // Update membership - membership.stripeSubscriptionId = stripeSubscription.id; + membership.stripeSubscriptionId = stripeSubscription.id await db.membership.update({ data: { stripeSubscriptionId: stripeSubscription.id }, where: { id: membership.id }, - }); + }) // Return secret to client to finalize payment return { @@ -121,5 +118,5 @@ export async function createMembership( membershipId: membership.id, }, status: ServerActionStatus.Success, - }; + } } diff --git a/src/app/actions/createMembershipTemplate.ts b/src/app/actions/createMembershipTemplate.ts new file mode 100644 index 0000000..a9aef89 --- /dev/null +++ b/src/app/actions/createMembershipTemplate.ts @@ -0,0 +1,44 @@ +"use server"; + +import { + type ServerActionState, + ServerActionStatus, +} from "@/app/actions/types"; +import { db } from "@/services/db"; +import { PricePeriod, type PriceUnit } from "@prisma/client"; + +export interface FormProps { + title: string; + description?: string; + features?: string; + priceAmount: number; + priceUnit: PriceUnit; + stripePriceId: string; +} + +export async function createMembershipTemplate( + prevState: ServerActionState, + data: FormProps, +): Promise { + // Query to create user + const membershipTemplate = await db.membershipTemplate.create({ + data: { + title: data.title, + description: data.description, + features: data.features?.length + ? data.features.split(",").filter(Boolean) + : [], + priceAmount: data.priceAmount * 100, + priceUnit: data.priceUnit, + pricePeriod: PricePeriod.Yearly, + stripePriceId: data.stripePriceId, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + return { + status: ServerActionStatus.Success, + payload: membershipTemplate, + }; +} diff --git a/src/app/actions/editMembershipTemplate.ts b/src/app/actions/editMembershipTemplate.ts new file mode 100644 index 0000000..82cc522 --- /dev/null +++ b/src/app/actions/editMembershipTemplate.ts @@ -0,0 +1,47 @@ +"use server"; + +import { + type ServerActionState, + ServerActionStatus, +} from "@/app/actions/types"; +import { db } from "@/services/db"; +import { PricePeriod, type PriceUnit } from "@prisma/client"; + +export interface FormProps { + id: string; + title: string; + description?: string; + features?: string; + priceAmount: number; + priceUnit: PriceUnit; + stripePriceId: string; +} + +export async function editMembershipTemplate( + prevState: ServerActionState, + data: FormProps, +): Promise { + // Query to create user + const membershipTemplate = await db.membershipTemplate.update({ + where: { + id: data.id, + }, + data: { + title: data.title, + description: data.description, + features: data.features?.length + ? data.features.split(",").filter(Boolean) + : [], + priceAmount: data.priceAmount * 100, + priceUnit: data.priceUnit, + pricePeriod: PricePeriod.Yearly, + stripePriceId: data.stripePriceId, + updatedAt: new Date(), + }, + }); + + return { + status: ServerActionStatus.Success, + payload: membershipTemplate, + }; +} diff --git a/src/app/actions/types.ts b/src/app/actions/types.ts index ff60ff9..8d38e53 100644 --- a/src/app/actions/types.ts +++ b/src/app/actions/types.ts @@ -15,3 +15,8 @@ export enum ServerActionStatus { Error = "error", Pending = "pending", } + +export const InitialServerActionState = { + payload: {}, + status: ServerActionStatus.Pending, +} diff --git a/src/app/admin/@authenticated/membership/layout.tsx b/src/app/admin/@authenticated/membership/layout.tsx new file mode 100644 index 0000000..c9d37fc --- /dev/null +++ b/src/app/admin/@authenticated/membership/layout.tsx @@ -0,0 +1,32 @@ +import { find } from "lodash"; + +import { adminMenuTreeConfig } from "@/app/admin/const"; +import { LinkWithActive } from "@/components/molecules/linkWithActive"; + +interface AdminAuthenticatedLayoutInterface { + children: React.ReactNode; +} + +export default async function AdminAuthenticatedLayout({ + children, +}: AdminAuthenticatedLayoutInterface) { + const settingsChildren = find(adminMenuTreeConfig, { id: "membership" })!; + + return ( + <> +
+

Membership

+
+
+ +
{children}
+
+ + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/(.)create/client.tsx b/src/app/admin/@authenticated/membership/manage/@modal/(.)create/client.tsx new file mode 100644 index 0000000..3ccd78e --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/(.)create/client.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { AdminMembershipCRUDForm } from "@/app/admin/@authenticated/membership/manage/form"; +import { CRUDFormIntent } from "@/modules/crudForm/types"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +export function AdminMembershipCreateModalClient() { + const { toast } = useToast(); + const router = useRouter(); + + const onSubmit = () => { + toast({ + title: "Success", + description: "Membership Template created", + variant: "success", + }); + router.back(); + router.refresh(); + }; + + return ( + + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/(.)create/page.tsx b/src/app/admin/@authenticated/membership/manage/@modal/(.)create/page.tsx new file mode 100644 index 0000000..78b7392 --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/(.)create/page.tsx @@ -0,0 +1,11 @@ +import { Modal } from "@/components/molecules/modal"; +import { AdminMembershipCRUDForm } from "@/app/admin/@authenticated/membership/manage/form"; +import { CRUDFormIntent } from "@/modules/crudForm/types"; + +export default function AdminMembershipModalCreatePage() { + return ( + + + + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/client.tsx b/src/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/client.tsx new file mode 100644 index 0000000..2a9e594 --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/client.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { AdminMembershipCRUDForm } from "@/app/admin/@authenticated/membership/manage/form"; +import { CRUDFormIntent } from "@/modules/crudForm/types"; +import { type MembershipTemplate } from "@prisma/client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +interface AdminMembershipEditPageClient { + previousValues: MembershipTemplate; +} + +export function AdminMembershipEditModalClient({ + previousValues, +}: AdminMembershipEditPageClient) { + const { toast } = useToast(); + const router = useRouter(); + + const onSubmit = () => { + toast({ + title: "Success", + description: "Membership Template updated", + variant: "success", + }); + router.back(); + router.refresh(); + }; + + return ( + + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/page.tsx b/src/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/page.tsx new file mode 100644 index 0000000..c172d00 --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/page.tsx @@ -0,0 +1,29 @@ +import { Modal } from "@/components/molecules/modal"; +import { db } from "@/services/db"; +import { AdminMembershipEditModalClient } from "@/app/admin/@authenticated/membership/manage/@modal/(.)edit/[id]/client"; + +const getData = (id: string) => { + return db.membershipTemplate.findUnique({ + where: { + id, + }, + }); +}; + +interface AdminMembershipEditPageProps { + params: { + id: string; + }; +} + +export default async function AdminMembershipModalEditPage({ + params, +}: AdminMembershipEditPageProps) { + const membershipTemplate = await getData(params.id); + + return ( + + + + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/create/client.tsx b/src/app/admin/@authenticated/membership/manage/@modal/create/client.tsx new file mode 100644 index 0000000..5ca9722 --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/create/client.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { AdminMembershipCRUDForm } from "@/app/admin/@authenticated/membership/manage/form"; +import { CRUDFormIntent } from "@/modules/crudForm/types"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +export function AdminMembershipCreatePageClient() { + const { toast } = useToast(); + const router = useRouter(); + + const onSubmit = () => { + toast({ + title: "Success", + description: "Membership Template created", + variant: "success", + }); + router.replace("/admin/membership/manage"); + router.refresh(); + }; + + return ( + + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/create/page.tsx b/src/app/admin/@authenticated/membership/manage/@modal/create/page.tsx new file mode 100644 index 0000000..5b03c8a --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/create/page.tsx @@ -0,0 +1,9 @@ +import { AdminMembershipCreatePageClient } from "@/app/admin/@authenticated/membership/manage/@modal/create/client"; + +export default function AdminMembershipCreatePage() { + return ( +
+ +
+ ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/default.tsx b/src/app/admin/@authenticated/membership/manage/@modal/default.tsx new file mode 100644 index 0000000..8ee23ba --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function AdminMembershipModalDefault() { + return null; +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/edit/[id]/client.tsx b/src/app/admin/@authenticated/membership/manage/@modal/edit/[id]/client.tsx new file mode 100644 index 0000000..c0e5a3f --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/edit/[id]/client.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { AdminMembershipCRUDForm } from "@/app/admin/@authenticated/membership/manage/form"; +import { CRUDFormIntent } from "@/modules/crudForm/types"; +import { type MembershipTemplate } from "@prisma/client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +interface AdminMembershipEditPageClient { + previousValues: MembershipTemplate; +} + +export function AdminMembershipEditPageClient({ + previousValues, +}: AdminMembershipEditPageClient) { + const { toast } = useToast(); + const router = useRouter(); + + const onSubmit = () => { + toast({ + title: "Success", + description: "Membership Template updated", + variant: "success", + }); + router.replace("/admin/membership/manage"); + router.refresh(); + }; + + return ( + + ); +} diff --git a/src/app/admin/@authenticated/membership/manage/@modal/edit/[id]/page.tsx b/src/app/admin/@authenticated/membership/manage/@modal/edit/[id]/page.tsx new file mode 100644 index 0000000..720953e --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/@modal/edit/[id]/page.tsx @@ -0,0 +1,28 @@ +import { db } from "@/services/db"; +import { AdminMembershipEditPageClient } from "@/app/admin/@authenticated/membership/manage/@modal/edit/[id]/client"; + +const getData = (id: string) => { + return db.membershipTemplate.findUnique({ + where: { + id, + }, + }); +}; + +interface AdminMembershipEditPageProps { + params: { + id: string; + }; +} + +export default async function AdminMembershipEditPage({ + params, +}: AdminMembershipEditPageProps) { + const membershipTemplate = await getData(params.id); + + return ( +
+ +
+ ); +} diff --git a/src/app/admin/@authenticated/membership/manage/default.tsx b/src/app/admin/@authenticated/membership/manage/default.tsx new file mode 100644 index 0000000..8ee23ba --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/default.tsx @@ -0,0 +1,3 @@ +export default function AdminMembershipModalDefault() { + return null; +} diff --git a/src/app/admin/@authenticated/membership/manage/form.tsx b/src/app/admin/@authenticated/membership/manage/form.tsx new file mode 100644 index 0000000..973fcb7 --- /dev/null +++ b/src/app/admin/@authenticated/membership/manage/form.tsx @@ -0,0 +1,211 @@ +"use client" + +import { useForm } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { StatefulButton } from "@/components/molecules/statefulButton" +import { useFormState } from "react-dom" +import { + InitialServerActionState, + ServerActionStatus, +} from "@/app/actions/types" +import { createMembershipTemplate } from "@/app/actions/createMembershipTemplate" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { CRUDFormIntent } from "@/modules/crudForm/types" +import { editMembershipTemplate } from "@/app/actions/editMembershipTemplate" +import { useEffect, useState } from "react" + +const formSchema = z.object({ + id: z.string().optional(), + title: z.string().min(4, "Title should be at least 4 characters long"), + description: z.string().optional(), + features: z.string().optional(), + priceAmount: z.coerce.number().positive(), + priceUnit: z.string(), + stripePriceId: z + .string() + .regex( + new RegExp("^price_[0-9A-Za-z]+$"), + "You need to provide a Stripe Product Price ID, format: price_xxxxx", + ), +}) + +interface FormProps { + intent: CRUDFormIntent + previousValues?: any + onSuccess?: any +} + +export function AdminMembershipCRUDForm({ + intent, + previousValues, + onSuccess, +}: FormProps) { + const [membershipTemplateState, membershipTemplateAction] = useFormState( + intent === CRUDFormIntent.Create + ? createMembershipTemplate + : editMembershipTemplate, + InitialServerActionState, + ) + const [onSuccessFired, setOnSuccessFired] = useState(false) + + const form = useForm>({ + defaultValues: + intent === CRUDFormIntent.Create + ? {} + : { + ...previousValues, + features: previousValues?.features.join(","), + priceAmount: previousValues.priceAmount / 100, + }, + resolver: zodResolver(formSchema), + }) + + useEffect(() => { + if ( + membershipTemplateState.status === ServerActionStatus.Success && + onSuccess && + !onSuccessFired + ) { + onSuccess(membershipTemplateState) + setOnSuccessFired(true) + } + }, [membershipTemplateState, onSuccess, onSuccessFired]) + + return ( +
+ + {CRUDFormIntent.Edit && ( + } + /> + )} +
+ ( + + Title + + + + + + )} + /> + + ( + + Description + +