From 82955be4cbd120abeb54161fa38f91f870e5a11b Mon Sep 17 00:00:00 2001 From: Artur <33733651+Keeqler@users.noreply.github.com> Date: Thu, 17 Oct 2024 12:29:40 -0300 Subject: [PATCH] Campaign Site V2 (#81) * Apply OpenSats UI enhancements * Add register modal and some UI fixes * Add login modal * Add reset password button * Use @t3-oss/env-nextjs for env variables * Email verification without keycloak UI * Password reset without keycloak UI * Add "My donations" page with one-time stripe donations list * Display crypto donations in "My donations" page * Donation form fixes and improvements * Display recurring annual fiat donations * Include keycloak realm export file and remove hardcoded client values * fix: correctly handle btcpay webhooks and fix donationList query * feat: add membership modal and implement membership payment using btcpay * feat: add procedure for membership purchases with stripe and use db as single source of truth for donations * feat: use webhooks to update stripe donation/membership status * feat: memberships list page and fixes * feat: db schema changes and webhook fixes * feat: re-add "donate" and add "get annual membership" buttons to project page * feat: open register modal when clicking membership button while logged out * feat: replace "Get Membership" button with "My Memberships" button when user already has a membership for that project * feat: multiple funds support * deps: bump axios * feat: add different color schemes for each fund and some fixes * feat: add home page * feat: add missing titles and responsiveness improvements * chore: add prod workflow file and compose file * chore(deploy workflow): set environment name * chore(Dockerfile): add necessary lines for prisma * chore: make it skip env validation on build * fix: prevent donation amounts from being fetched from db during build * chore(nginx.conf): remove copy-paste junk * chore(docker compose): correctly set APP_URL env * feat: replace Sendgrid with SES * deps: audit fix * chore(deploy.yml): remove unecessary env * fix: correctly manage client and server env * fix(trpc.submitApplication): get recipient emails from server side env * fix(Dockerfile): define NEXT_PUBLIC_ env on build * chore(trpc): make it log any errors * chore(trpc): improve displaying of errors * chore(trpc): improve displaying of errors * fix: buggy link buttons * feat: use single btcpay store * feat: show form 8283 info in donation form and handle tax deductible donations * feat: have only one privacy and terms page for the entire site * feat: support many social links * feat: add funding required endpoint (wip) * feat: get rates using btcpay api and small refactor * deps: audit fix * fix: correctly handle payment methods on InvoiceSettled event * fix: make index on Donation.btcPayInvoiceId * feat(funding-required): improve asset parameter response * feat(funding-required): add project_status param * feat(funding-required): add fund param * feat(funding-required): implement caching * fix(funding-required): minor fixes * feat(funding-required): add remaining_amount_ fields and fixes * chore: include all services in docker-compose.dev.yml and update .env.example * feat: move terms and privacy links to footer * fix: address font not always loading bug * feat: use fund logos as header image * feat: donation confirmation email * fix: use correct stripe client for each fund on webhooks * feat: add account settings page with change password form * feat: add email change form to settings page * fix: address wrong btcpay invoice url redirect * chore: email change request debug * fix(api): better handle user attributes * feat: ui improvements * feat: add btcpay invoice item description * chore(nginx): api rate limit * feat: remove typing component from fund landing pages * feat: implement refresh token rotation using keycloak * refactor: have gross and net amounts for donations * feat: invalidate user sessions on password/email change * fix: make "Create an account" button work on donate/membership modals * refactor: project props * fix(utils.md): correctly load md project attributes * chore(prisma): make composite unique constraint for fundSlug and projectSlug on ProjectAddresses * chore: mark example project as not funded * fix(utils.md): serialization error * chore(funding-required): btcpay invoice payment methods debug * fix(funding-required): get bitcoin address from correct payment method * fix(funding-required): correctly handle project_status ANY filter * fix(btcpay webhook handler): correctly handle payment methods on InvoicePaymentSettled * chore(docker-compose.yml): expose nginx port 80 * fix(funding-required): correctly concat project url * feat: ui improvements for smaller screens * fix(btcpay webhook handler): correctly get payment method amount on InvoiceSettled * fix(btcpay webhook handler): respond with 200 immediately if there is no metadata * chore(funding-required): debugging * chore(funding-required): debugging * chore(funding-required): debugging * fix(Dockerfile): define BUILD_MODE as arg instead of env to make it blank at runtime * fix: correctly pass current and goal values to project card progress * fix(funding-required): set high monitoring time for static address invoice * fix: correctly handle refresh token expiration on the ui * feat: ui improvements * chore: update README * chore: update README * Initial site text * fix: colors * chore: mention funds accordingly in texts * chore: update realm-export.json * chore: rename docker compose files * Update emails * Form updates * Remove unused pages and page improvements * feat: allow editing navbar links for each fund * Cleanup and Firo projects * chore(deploy.yml): change deploy branch to master * fix(auth): use fetch instead of axios when fetching refresh token due to edge runtime compatibility * fix: keep empty project folders * Fix code scanning alert no. 20: DOM text reinterpreted as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * chore: sanitize md file paths * Text and link updates --------- Co-authored-by: Artur N Co-authored-by: Justin Ehrenhofer <12520755+SamsungGalaxyPlayer@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .env.example | 38 + .eslintrc.json | 5 +- .github/workflows/deploy.yml | 50 + .prettierrc.js | 2 +- Dockerfile | 24 +- README.md | 128 +- auth.d.ts | 17 + components.json | 17 + components/CustomLink.tsx | 42 + components/DonationForm.tsx | 244 - components/DonationFormModal.tsx | 319 + components/FiroLogo.tsx | 35 + components/Footer.tsx | 33 + components/Header.tsx | 180 +- components/Layout.tsx | 78 +- components/LoginFormModal.tsx | 149 + components/LogoCrystal.tsx | 725 + components/MagicLogo.tsx | 118 + components/MembershipFormModal.tsx | 295 + components/MobileNav.tsx | 90 + components/MoneroLogo.tsx | 58 + components/PageHeading.tsx | 77 + components/PasswordResetFormModal.tsx | 71 + components/PaymentModal.tsx | 67 - components/PrivacyGuidesLogo.tsx | 36 + components/Progress.tsx | 28 +- components/ProjectCard.tsx | 128 +- components/ProjectList.tsx | 32 +- components/RegisterFormModal.tsx | 166 + components/SectionContainer.tsx | 13 + components/ShareButtons.tsx | 42 - components/Spinner.tsx | 6 +- components/ThemeSwitch.tsx | 43 + components/WebIcon.tsx | 21 + components/ui/alert.tsx | 49 + components/ui/avatar.tsx | 45 + components/ui/button.tsx | 50 + components/ui/dialog.tsx | 99 + components/ui/dropdown-menu.tsx | 184 + components/ui/form.tsx | 179 + components/ui/input.tsx | 47 + components/ui/label.tsx | 24 + components/ui/radio-group.tsx | 42 + components/ui/table.tsx | 93 + components/ui/toast.tsx | 127 + components/ui/toaster.tsx | 33 + components/ui/use-toast.ts | 189 + config/index.ts | 11 +- data/headerNavLinks.ts | 18 + docker-compose.prod.yml | 89 + docker-compose.yml | 139 +- docs/firo/about_us.md | 9 + .../projects/aram-jivanyan-curve-trees.md | 49 + .../lelantus-spark-flutter-library.md | 31 + docs/general/about_us.md | 19 + docs/general/faq.md | 26 + docs/general/projects/.gitkeep | 0 docs/{ => monero}/about_us.md | 3 +- docs/{ => monero}/apply_research.md | 4 +- docs/{ => monero}/faq.md | 4 +- .../projects/Q1Q2_2024_dev_vtnerd.md | 67 +- .../projects/eae_attack_and_churning.md | 33 +- .../projects/eth_xmr_atomic_swaps.md | 37 +- .../projects/ring_signature_ai.md | 35 +- docs/privacy.md | 115 +- docs/privacyguides/about_us.md | 7 + docs/privacyguides/projects/.gitkeep | 0 docs/terms.md | 122 +- env.mjs | 113 + middleware.ts | 27 + next-env.d.ts | 2 +- next.config.js | 8 + nginx.conf | 32 + package-lock.json | 13319 ++++++++-------- package.json | 59 +- pages/[fund]/about.tsx | 36 + pages/[fund]/account/my-donations.tsx | 67 + pages/[fund]/account/my-memberships.tsx | 75 + pages/[fund]/account/settings.tsx | 220 + pages/[fund]/apply_research.tsx | 31 + pages/[fund]/faq.tsx | 36 + pages/[fund]/projects/[slug].tsx | 293 + pages/{ => [fund]}/projects/index.tsx | 46 +- pages/[fund]/reset-password/[token].tsx | 155 + pages/[fund]/verify-email/[token].tsx | 35 + pages/_app.tsx | 41 +- pages/about.tsx | 19 - pages/api/auth/[...nextauth].ts | 85 + pages/api/btcpay.ts | 56 - pages/api/btcpay/webhook.ts | 157 + pages/api/funding-required.ts | 266 + pages/api/sendgrid.ts | 43 - pages/api/stripe/firo-webhook.ts | 10 + pages/api/stripe/general-webhook.ts | 10 + pages/api/stripe/monero-webhook.ts | 10 + pages/api/stripe/privacy-guides-webhook.ts | 10 + pages/api/stripe_checkout.ts | 72 - pages/api/trpc/[trpc].ts | 10 + pages/apply.tsx | 211 - pages/apply_research.tsx | 19 - pages/checkout.tsx | 29 - pages/faq.tsx | 19 - pages/firo/index.tsx | 176 + pages/firo/thankyou.tsx | 25 + pages/general/index.tsx | 177 + pages/general/thankyou.tsx | 25 + pages/index.tsx | 206 +- pages/monero/apply.tsx | 295 + pages/monero/index.tsx | 186 + pages/{ => monero}/submitted.tsx | 4 +- pages/monero/thankyou.tsx | 25 + pages/privacy.tsx | 8 +- pages/privacyguides/index.tsx | 177 + pages/privacyguides/thankyou.tsx | 25 + pages/projects/[slug].tsx | 207 - pages/terms.tsx | 5 +- pages/thankyou.tsx | 17 - .../20241009181209_init/migration.sql | 58 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 62 + public/firo_logo.svg | 22 + public/img/project/bitcoiner_guide.png | Bin 134987 -> 0 bytes public/img/project/bpi.png | Bin 1871 -> 0 bytes public/img/project/btcpayserver.png | Bin 633812 -> 0 bytes public/img/project/firo-curve-trees.png | Bin 0 -> 224653 bytes public/img/project/jon_atack.png | Bin 678244 -> 0 bytes public/img/project/michael_ford.png | Bin 863841 -> 0 bytes public/img/project/opensats_legal.jpeg | Bin 58858 -> 0 bytes public/img/project/opensats_logo.png | Bin 59448 -> 0 bytes public/img/project/ronin_dojo.png | Bin 131192 -> 0 bytes public/img/project/sapio.jpg | Bin 443488 -> 0 bytes public/img/project/sparrow.png | Bin 554805 -> 0 bytes public/img/project/specter.png | Bin 151194 -> 0 bytes public/img/project/thunderhub.png | Bin 142365 -> 0 bytes public/img/project/zeus.png | Bin 558725 -> 0 bytes realm-export.json | 2293 +++ server/routers/_app.ts | 14 + server/routers/account.ts | 113 + server/routers/application.ts | 33 + server/routers/auth.ts | 232 + server/routers/donation.ts | 393 + server/services.ts | 45 + server/trpc.ts | 65 + server/types.ts | 69 + server/utils/auth.ts | 43 + server/utils/funds.ts | 16 + server/utils/keycloak.ts | 12 + server/utils/mailing.ts | 115 + server/utils/webhooks.ts | 108 + styles/globals.css | 277 +- tailwind.config.js | 157 +- tsconfig.json | 12 +- types/next-auth.d.ts | 10 + utils/api-helpers.ts | 165 - utils/cn.ts | 6 + utils/funds.ts | 120 + utils/markdownToHtml.ts | 17 +- utils/md.ts | 178 +- utils/trpc.ts | 61 + utils/types.ts | 47 +- utils/use-fund-slug.ts | 9 + 161 files changed, 18657 insertions(+), 8633 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/deploy.yml create mode 100644 auth.d.ts create mode 100644 components.json create mode 100644 components/CustomLink.tsx delete mode 100644 components/DonationForm.tsx create mode 100644 components/DonationFormModal.tsx create mode 100644 components/FiroLogo.tsx create mode 100644 components/Footer.tsx create mode 100644 components/LoginFormModal.tsx create mode 100644 components/LogoCrystal.tsx create mode 100644 components/MagicLogo.tsx create mode 100644 components/MembershipFormModal.tsx create mode 100644 components/MobileNav.tsx create mode 100644 components/MoneroLogo.tsx create mode 100644 components/PageHeading.tsx create mode 100644 components/PasswordResetFormModal.tsx delete mode 100644 components/PaymentModal.tsx create mode 100644 components/PrivacyGuidesLogo.tsx create mode 100644 components/RegisterFormModal.tsx create mode 100644 components/SectionContainer.tsx delete mode 100644 components/ShareButtons.tsx create mode 100644 components/ThemeSwitch.tsx create mode 100644 components/WebIcon.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 data/headerNavLinks.ts create mode 100644 docker-compose.prod.yml create mode 100644 docs/firo/about_us.md create mode 100644 docs/firo/projects/aram-jivanyan-curve-trees.md create mode 100644 docs/firo/projects/lelantus-spark-flutter-library.md create mode 100644 docs/general/about_us.md create mode 100644 docs/general/faq.md create mode 100644 docs/general/projects/.gitkeep rename docs/{ => monero}/about_us.md (73%) rename docs/{ => monero}/apply_research.md (93%) rename docs/{ => monero}/faq.md (96%) rename docs/{ => monero}/projects/Q1Q2_2024_dev_vtnerd.md (56%) rename docs/{ => monero}/projects/eae_attack_and_churning.md (90%) rename docs/{ => monero}/projects/eth_xmr_atomic_swaps.md (86%) rename docs/{ => monero}/projects/ring_signature_ai.md (87%) create mode 100644 docs/privacyguides/about_us.md create mode 100644 docs/privacyguides/projects/.gitkeep create mode 100644 env.mjs create mode 100644 middleware.ts create mode 100644 nginx.conf create mode 100644 pages/[fund]/about.tsx create mode 100644 pages/[fund]/account/my-donations.tsx create mode 100644 pages/[fund]/account/my-memberships.tsx create mode 100644 pages/[fund]/account/settings.tsx create mode 100644 pages/[fund]/apply_research.tsx create mode 100644 pages/[fund]/faq.tsx create mode 100644 pages/[fund]/projects/[slug].tsx rename pages/{ => [fund]}/projects/index.tsx (52%) create mode 100644 pages/[fund]/reset-password/[token].tsx create mode 100644 pages/[fund]/verify-email/[token].tsx delete mode 100644 pages/about.tsx create mode 100644 pages/api/auth/[...nextauth].ts delete mode 100644 pages/api/btcpay.ts create mode 100644 pages/api/btcpay/webhook.ts create mode 100644 pages/api/funding-required.ts delete mode 100644 pages/api/sendgrid.ts create mode 100644 pages/api/stripe/firo-webhook.ts create mode 100644 pages/api/stripe/general-webhook.ts create mode 100644 pages/api/stripe/monero-webhook.ts create mode 100644 pages/api/stripe/privacy-guides-webhook.ts delete mode 100644 pages/api/stripe_checkout.ts create mode 100644 pages/api/trpc/[trpc].ts delete mode 100644 pages/apply.tsx delete mode 100644 pages/apply_research.tsx delete mode 100644 pages/checkout.tsx delete mode 100644 pages/faq.tsx create mode 100644 pages/firo/index.tsx create mode 100644 pages/firo/thankyou.tsx create mode 100644 pages/general/index.tsx create mode 100644 pages/general/thankyou.tsx create mode 100644 pages/monero/apply.tsx create mode 100644 pages/monero/index.tsx rename pages/{ => monero}/submitted.tsx (66%) create mode 100644 pages/monero/thankyou.tsx create mode 100644 pages/privacyguides/index.tsx create mode 100644 pages/privacyguides/thankyou.tsx delete mode 100644 pages/projects/[slug].tsx delete mode 100644 pages/thankyou.tsx create mode 100644 prisma/migrations/20241009181209_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 public/firo_logo.svg delete mode 100644 public/img/project/bitcoiner_guide.png delete mode 100644 public/img/project/bpi.png delete mode 100644 public/img/project/btcpayserver.png create mode 100644 public/img/project/firo-curve-trees.png delete mode 100644 public/img/project/jon_atack.png delete mode 100644 public/img/project/michael_ford.png delete mode 100644 public/img/project/opensats_legal.jpeg delete mode 100644 public/img/project/opensats_logo.png delete mode 100644 public/img/project/ronin_dojo.png delete mode 100644 public/img/project/sapio.jpg delete mode 100644 public/img/project/sparrow.png delete mode 100644 public/img/project/specter.png delete mode 100644 public/img/project/thunderhub.png delete mode 100644 public/img/project/zeus.png create mode 100644 realm-export.json create mode 100644 server/routers/_app.ts create mode 100644 server/routers/account.ts create mode 100644 server/routers/application.ts create mode 100644 server/routers/auth.ts create mode 100644 server/routers/donation.ts create mode 100644 server/services.ts create mode 100644 server/trpc.ts create mode 100644 server/types.ts create mode 100644 server/utils/auth.ts create mode 100644 server/utils/funds.ts create mode 100644 server/utils/keycloak.ts create mode 100644 server/utils/mailing.ts create mode 100644 server/utils/webhooks.ts create mode 100644 types/next-auth.d.ts delete mode 100644 utils/api-helpers.ts create mode 100644 utils/cn.ts create mode 100644 utils/funds.ts create mode 100644 utils/trpc.ts create mode 100644 utils/use-fund-slug.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..54d9397 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +APP_URL="http://localhost:3000" +DATABASE_URL="postgresql://magic:magic@magic-postgres:5432/magic?schema=public" + +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL_INTERNAL="http://localhost:3000" +NEXTAUTH_SECRET="" +USER_SETTINGS_JWT_SECRET="" + +SMTP_HOST="sandbox.smtp.mailtrap.io" +SMTP_PORT="2525" +SMTP_USER="" +SMTP_PASS="" +SES_VERIFIED_SENDER="" + +STRIPE_MONERO_SECRET_KEY="" +STRIPE_MONERO_WEBHOOK_SECRET="" +STRIPE_FIRO_SECRET_KEY="" +STRIPE_FIRO_WEBHOOK_SECRET="" +STRIPE_PRIVACY_GUIDES_SECRET_KEY="" +STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET="" +STRIPE_GENERAL_SECRET_KEY="" +STRIPE_GENERAL_WEBHOOK_SECRET="" + +BTCPAY_URL="http://btcpayserver:49392" +BTCPAY_EXTERNAL_URL="http://localhost:49392" +BTCPAY_API_KEY="" +BTCPAY_STORE_ID="" +BTCPAY_WEBHOOK_SECRET="" + +KEYCLOAK_URL="http://keycloak:8080" +KEYCLOAK_CLIENT_ID="app" +KEYCLOAK_CLIENT_SECRET="" +KEYCLOAK_REALM_NAME="magic" + +MONERO_APPLICATION_RECIPIENT="" +FIRO_APPLICATION_RECIPIENT="" +PRIVACY_GUIDES_APPLICATION_RECIPIENT="" +GENERAL_APPLICATION_RECIPIENT="" diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..09937b6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,6 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "rules": { + "react-hooks/exhaustive-deps": "off" + } } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6210f8e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy app to donate.magicgrants.org + +on: + push: + branches: + - master + +jobs: + deploy: + runs-on: ubuntu-latest + environment: master + + steps: + - uses: actions/checkout@v4 + - uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + - name: Deploy + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }} << 'EOF' + export HISTFILE=/dev/null + cd campaign-site + git checkout v2 + echo "Pulling changes..." + git pull + echo "Building and starting..." + + CLOUDFLARE_TUNNEL_TOKEN=${{ secrets.CLOUDFLARE_TUNNEL_TOKEN }} \ + POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ + DATABASE_URL=${{ secrets.DATABASE_URL }} \ + NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }} \ + USER_SETTINGS_JWT_SECRET=${{ secrets.USER_SETTINGS_JWT_SECRET }} \ + SMTP_USER=${{ secrets.SMTP_USER }} \ + SMTP_PASS=${{ secrets.SMTP_PASS }} \ + STRIPE_MONERO_SECRET_KEY=${{ secrets.STRIPE_MONERO_SECRET_KEY }} \ + STRIPE_MONERO_WEBHOOK_SECRET=${{ secrets.STRIPE_MONERO_WEBHOOK_SECRET }} \ + STRIPE_FIRO_SECRET_KEY=${{ secrets.STRIPE_FIRO_SECRET_KEY }} \ + STRIPE_FIRO_WEBHOOK_SECRET=${{ secrets.STRIPE_FIRO_WEBHOOK_SECRET }} \ + STRIPE_PRIVACY_GUIDES_SECRET_KEY=${{ secrets.STRIPE_PRIVACY_GUIDES_SECRET_KEY }} \ + STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET=${{ secrets.STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET }} \ + STRIPE_GENERAL_SECRET_KEY=${{ secrets.STRIPE_GENERAL_SECRET_KEY }} \ + STRIPE_GENERAL_WEBHOOK_SECRET=${{ secrets.STRIPE_GENERAL_WEBHOOK_SECRET }} \ + KEYCLOAK_URL=${{ secrets.KEYCLOAK_URL }} \ + KEYCLOAK_CLIENT_SECRET=${{ secrets.KEYCLOAK_CLIENT_SECRET }} \ + BTCPAY_URL=${{ secrets.BTCPAY_URL }} \ + BTCPAY_API_KEY=${{ secrets.BTCPAY_API_KEY }} \ + BTCPAY_STORE_ID=${{ secrets.BTCPAY_STORE_ID }} \ + BTCPAY_WEBHOOK_SECRET=${{ secrets.BTCPAY_WEBHOOK_SECRET }} \ + docker compose -f docker-compose.prod.yml up -d --build + EOF diff --git a/.prettierrc.js b/.prettierrc.js index 5e226d2..6a39aa5 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -2,7 +2,7 @@ module.exports = { semi: false, trailingComma: 'es5', singleQuote: true, - printWidth: 80, + printWidth: 100, tabWidth: 2, useTabs: false, jsxBracketSameLine: false, diff --git a/Dockerfile b/Dockerfile index 4566244..b3066c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app +COPY prisma prisma +ENV PRISMA_BINARY_TARGETS='["native", "rhel-openssl-1.0.x"]' + # Install dependencies based on the preferred package manager COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ RUN \ @@ -26,6 +29,13 @@ COPY . . # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. ENV NEXT_TELEMETRY_DISABLED 1 +ENV BUILD_MODE 1 +ENV PRISMA_BINARY_TARGETS='["native", "rhel-openssl-1.0.x"]' +ENV NEXT_PUBLIC_MONERO_APPLICATION_RECIPIENT='monerofund@magicgrants.org' +ENV NEXT_PUBLIC_FIRO_APPLICATION_RECIPIENT='firofund@magicgrants.org' +ENV NEXT_PUBLIC_PRIVACY_GUIDES_APPLICATION_RECIPIENT='privacyguidesfund@magicgrants.org' +ENV NEXT_PUBLIC_GENERAL_APPLICATION_RECIPIENT='info@magicgrants.org' +RUN npx prisma generate RUN \ if [ -f yarn.lock ]; then yarn run build; \ @@ -38,6 +48,8 @@ RUN \ FROM base AS runner WORKDIR /app +ARG BUILD_MODE=1 + ENV NODE_ENV production # Uncomment the following line in case you want to disable telemetry during runtime. ENV NEXT_TELEMETRY_DISABLED 1 @@ -45,6 +57,7 @@ ENV NEXT_TELEMETRY_DISABLED 1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/public ./public # Set the correct permission for prerender cache @@ -63,7 +76,16 @@ WORKDIR /app EXPOSE 3000 ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +# Install Prisma CLI +RUN mkdir /home/nextjs/.npm-global +ENV PATH=/home/nextjs/.npm-global/bin:$PATH +ENV NPM_CONFIG_PREFIX=/home/nextjs/.npm-global +ENV PRISMA_BINARY_TARGETS='["native", "rhel-openssl-1.0.x"]' +RUN npm install --quiet --no-progress -g prisma +RUN npm cache clean --force # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD HOSTNAME="0.0.0.0" node server.js \ No newline at end of file +CMD ["/bin/sh", "-c", "prisma migrate deploy && node server.js"] \ No newline at end of file diff --git a/README.md b/README.md index c1112a2..87ad157 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,127 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# MAGIC Grants Campaign Site -PRs welcome! +## Development -Thanks for supporting MAGIC Monero Fund. +### Requirements ---- +- Docker +- Docker Compose +- NodeJS >=20 -# Upgrading Server +### Running containers -ssh into the machine, and cd into the correct folder, under Production. Then: +First, install the application's dependencies and then run the containers: -`sudo git pull` +```bash +$ npm i +``` -`sudo docker-compose up --build` +```bash +$ docker-compse up -d +``` -After building coompletes, quit out with Ctrl + C +The app will be available at `http://localhost:3000`. -`sudo docker start monerofund-frontend-page` +### Configuration + +Create a `.env `file as a copy of `.env.example` and set the values for the empty variables. + +### Setting up Keycloak + +1. Open up http://localhost:8080 in your browser, then login using `admin` for both username and password; + +2. Open the dropdown menu on the top left corner of the screen (where it says Keycloak) and click **Create realm**; + +3. Upload the `realm-export.json` file from this repo, name the realm `magic` and click **Create**; + +4. Once the realm is created, go to **Clients** -> **Credentials**, and under **Client Secret**, click **Regenerate**. Copy the secret and add it to the `KEYCLOAK_CLIENT_SECRET` environment variable in your `.env` file. + +### Setting up BTCPayServer + +1. Open up http://localhost:49392 in your browser and create an account; + +2. Once logged in, open the **XMR Wallet** page and upload a view-only wallet file, you can get one from [Feather Wallet](https://featherwallet.org/); + +3. In the **Webhooks** tab, create a new webhook by setting the **Payload URL** to `http://campaign-site:3000/api/btcpay/webhook`, copy the secret and add it to the `BTCPAY_WEBHOOK_SECRET` environment variable in your `.env` file, and finally click **Add webhook**. + +4. Create a new API key at **Account** -> **Manage Account** -> **API Keys**, you'll need the following permissions: **View invoices**, **Create an invoice** and **View your stores**. Then copy the API key and add it to the `BTCPAY_API_KEY` environment variable in your `.env` file. + +### Setting up Stripe + +1. Open up the [Stripe Dashboard](https://dashboard.stripe.com) in your browser; + +2. Create a new account in test mode; + +3. Go to **Developers** -> **API keys**, and get a secret key, add it to the `STRIPE_MONERO_SECRET_KEY` environment variable in your `.env` file; + +4. Go to **Developers** -> **Webhooks**, and add a new webhook endpoint. Add `/api/stripe/monero-webhook` to the URL field replacing `` with your app's public address, then add the secret to the `STRIPE_MONERO_WEBHOOK_SECRET` environment variable in your `.env` file. To expose the app to the internet so Stripe can reach the webhook endpoint, you can use tunneling services like Visual Studio Code's built-in [port-forwarding feature](https://code.visualstudio.com/docs/editor/port-forwarding) or [Ngrok](https://ngrok.io). + +This makes you able to test Stripe donations in the Monero fund, which should be enough for development. You can add random values to the other Stripe enviroment variables to bypass validation. + +You are now all set up to start developing! + +## Funding required endpoint + +Endpoint: `GET /api/funding-required` + +Request query parameters +| Parameter | Required | Default | Accepted values | Description | +| - | - | - | - | - | +| `fund` | No | - | `monero` `firo` `privacyguides` `general` | Filters projects by fund. | +| `asset` | No | - | `BTC` `XMR` `USD` | Only return project amounts and address for a specific asset. Specifying this parameter changes the response JSON schema as shown below. | +| `project_status` | No | `NOT_FUNDED` | `NOT_FUNDED` `FUNDED` `ANY` | Filters projects by status. | + +Response body (`asset` parameter **not** specified) + +```ts +[ + { + title: string + fund: 'monero' | 'firo' | 'privacyguides' | 'general' + date: string // YYYY-MM-DD + author: string + url: string + is_funded: boolean + raised_amount_percent: number + contributions: number + target_amount_btc: number + target_amount_xmr: number + target_amount_usd: number + remaining_amount_btc: number + remaining_amount_xmr: number + remaining_amount_usd: number + address_btc: string | null + address_xmr: string | null + } +] +``` + +Response body (`asset` parameter specified) + +```ts +[ + { + title: string + fund: 'monero' | 'firo' | 'privacyguides' | 'general' + date: string // YYYY-MM-DD + author: string + url: string + is_funded: boolean + raised_amount_percent: number + contributions: number + asset: 'BTC' | 'XMR' | 'USD' + target_amount: number + remaining_amount: number + address: string | null + } +] +``` + +# Contributing + +Pull requests welcome! +Thanks for supporting MAGIC Grants. + +# License + +[MIT](LICENSE) \ No newline at end of file diff --git a/auth.d.ts b/auth.d.ts new file mode 100644 index 0000000..e045c0b --- /dev/null +++ b/auth.d.ts @@ -0,0 +1,17 @@ +import { Session } from 'next-auth' +import { DefaultJWT, JWT } from 'next-auth/jwt' + +declare module 'next-auth' { + interface Session extends Session { + error?: 'RefreshAccessTokenError' + } +} + +declare module 'next-auth/jwt' { + interface JWT extends DefaultJWT { + accessToken: string + accessTokenExpiresAt: number + refreshToken: string + error?: 'RefreshAccessTokenError' + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..60c8c9b --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "styles/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "components", + "utils": "utils" + } +} \ No newline at end of file diff --git a/components/CustomLink.tsx b/components/CustomLink.tsx new file mode 100644 index 0000000..2046d6c --- /dev/null +++ b/components/CustomLink.tsx @@ -0,0 +1,42 @@ +/* eslint-disable jsx-a11y/anchor-has-content */ +import Link from 'next/link' +import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react' +import { cn } from '../utils/cn' + +const CustomLink = ({ + href, + className, + ...rest +}: DetailedHTMLProps, HTMLAnchorElement>) => { + const isInternalLink = href && href.startsWith('/') + const isAnchorLink = href && href.startsWith('#') + + if (isInternalLink) { + // @ts-ignore + return ( + + ) + } + + if (isAnchorLink) { + return ( + + ) + } + + return ( + + ) +} + +export default CustomLink diff --git a/components/DonationForm.tsx b/components/DonationForm.tsx deleted file mode 100644 index d498bb5..0000000 --- a/components/DonationForm.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { faMonero } from '@fortawesome/free-brands-svg-icons' -import { faCreditCard } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useEffect, useRef, useState } from 'react' -import { MAX_AMOUNT } from '../config' -import { fetchPostJSON } from '../utils/api-helpers' -import Spinner from './Spinner' - -type DonationStepsProps = { - projectNamePretty: string - projectSlug: string -} -const DonationSteps: React.FC = ({ - projectNamePretty, - projectSlug, -}) => { - const [name, setName] = useState('') - const [email, setEmail] = useState('') - - const [deductible, setDeductible] = useState('no') - const [amount, setAmount] = useState('') - - const [readyToPay, setReadyToPay] = useState(false) - - const [btcPayLoading, setBtcpayLoading] = useState(false) - const [fiatLoading, setFiatLoading] = useState(false) - - const formRef = useRef(null) - - const radioHandler = (event: React.ChangeEvent) => { - setDeductible(event.target.value) - } - - function handleFiatAmountClick(e: React.MouseEvent, value: string) { - e.preventDefault() - setAmount(value) - } - - useEffect(() => { - if (amount && typeof parseInt(amount) === 'number') { - if (deductible === 'no' || (name && email)) { - setReadyToPay(true) - } else { - setReadyToPay(false) - } - } else { - setReadyToPay(false) - } - }, [deductible, amount, email, name]) - - async function handleBtcPay() { - const validity = formRef.current?.checkValidity() - if (!validity) { - return - } - setBtcpayLoading(true) - try { - const payload = { - amount, - project_slug: projectSlug, - project_name: projectNamePretty - } - - if (email) { - Object.assign(payload, { email }) - } - - if (name) { - Object.assign(payload, { name }) - } - - console.log(payload) - const data = await fetchPostJSON('/api/btcpay', payload) - if (data.checkoutLink) { - window.location.assign(data.checkoutLink) - } else if (data.message) { - throw new Error(data.message) - } else { - console.log({ data }) - throw new Error('Something went wrong with BtcPay Server checkout.') - } - } catch (e) { - console.error(e) - } - setBtcpayLoading(false) - } - - async function handleFiat() { - const validity = formRef.current?.checkValidity() - if (!validity) { - return - } - setFiatLoading(true) - try { - const data = await fetchPostJSON('/api/stripe_checkout', { - amount, - project_slug: projectSlug, - project_name: projectNamePretty, - email, - name, - }) - console.log({ data }) - if (data.url) { - window.location.assign(data.url) - } else { - throw new Error('Something went wrong with Stripe checkout.') - } - } catch (e) { - console.error(e) - } - setFiatLoading(false) - } - - return ( -
e.preventDefault()} - > -
-

Do you want this donation to be tax deductible (USA only)?

-
- - -
- -

- Name{' '} - - {deductible === 'yes' ? '(required)' : '(optional)'} - -

- setName(e.target.value)} - className="mb-4" - > -

- Email{' '} - - {deductible === 'yes' ? '(required)' : '(optional)'} - -

- setEmail(e.target.value)} - > -
- -
-
-

How much would you like to donate?

-
-
- {[50, 100, 250, 500].map((value, index) => ( - - ))} -
-
- {/* */} - {'$'} -
- { - setAmount(e.target.value) - }} - className="!pl-10 w-full" - placeholder="Or enter custom amount" - /> -
-
-
-
- - -
-
- ) -} - -export default DonationSteps diff --git a/components/DonationFormModal.tsx b/components/DonationFormModal.tsx new file mode 100644 index 0000000..c6c6bdc --- /dev/null +++ b/components/DonationFormModal.tsx @@ -0,0 +1,319 @@ +import { useEffect, useRef, useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { faMonero } from '@fortawesome/free-brands-svg-icons' +import { faCreditCard } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { DollarSign, Info } from 'lucide-react' +import { z } from 'zod' +import Image from 'next/image' + +import { MAX_AMOUNT } from '../config' +import Spinner from './Spinner' +import { trpc } from '../utils/trpc' +import { useToast } from './ui/use-toast' +import { useSession } from 'next-auth/react' +import { Button } from './ui/button' +import { RadioGroup, RadioGroupItem } from './ui/radio-group' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Input } from './ui/input' +import { ProjectItem } from '../utils/types' +import { useFundSlug } from '../utils/use-fund-slug' +import { Alert, AlertDescription, AlertTitle } from './ui/alert' +import CustomLink from './CustomLink' + +type Props = { + project: ProjectItem | undefined + close: () => void + openRegisterModal: () => void +} + +const DonationFormModal: React.FC = ({ project, openRegisterModal, close }) => { + const fundSlug = useFundSlug() + const session = useSession() + const isAuthed = session.status === 'authenticated' + + const schema = z + .object({ + name: z.string().optional(), + email: z.string().email().optional(), + amount: z.coerce.number().min(1).max(MAX_AMOUNT), + taxDeductible: z.enum(['yes', 'no']), + }) + .refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), { + message: 'Name is required when the donation is tax deductible.', + path: ['name'], + }) + .refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), { + message: 'Email is required when the donation is tax deductible.', + path: ['email'], + }) + + type FormInputs = z.infer + + const { toast } = useToast() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + amount: '' as unknown as number, // a trick to get trigger to work when amount is empty + taxDeductible: 'no', + }, + mode: 'all', + }) + + const amount = form.watch('amount') + const taxDeductible = form.watch('taxDeductible') + + const donateWithFiatMutation = trpc.donation.donateWithFiat.useMutation() + const donateWithCryptoMutation = trpc.donation.donateWithCrypto.useMutation() + + async function handleBtcPay(data: FormInputs) { + if (!project) return + if (!fundSlug) return + + try { + const result = await donateWithCryptoMutation.mutateAsync({ + email: data.email || null, + name: data.name || null, + amount: data.amount, + projectSlug: project.slug, + projectName: project.title, + fundSlug, + taxDeductible: data.taxDeductible === 'yes', + }) + + window.location.assign(result.url) + } catch (e) { + toast({ + title: 'Sorry, something went wrong.', + variant: 'destructive', + }) + } + } + + async function handleFiat(data: FormInputs) { + if (!project) return + if (!fundSlug) return + + try { + const result = await donateWithFiatMutation.mutateAsync({ + email: data.email || null, + name: data.name || null, + amount: data.amount, + projectSlug: project.slug, + projectName: project.title, + fundSlug, + taxDeductible: data.taxDeductible === 'yes', + }) + + if (!result.url) throw Error() + + window.location.assign(result.url) + } catch (e) { + toast({ + title: 'Sorry, something went wrong.', + variant: 'destructive', + }) + } + } + + useEffect(() => { + form.trigger('email', { shouldFocus: true }) + form.trigger('name', { shouldFocus: true }) + }, [taxDeductible]) + + if (!project) return <> + + return ( +
+
+
+ {project.title} +
+

{project.title}

+

Pledge your support

+
+
+
+ +
+ + {!isAuthed && ( + <> + ( + + Name {taxDeductible === 'no' && '(optional)'} + + + + + + )} + /> + + ( + + Email {taxDeductible === 'no' && '(optional)'} + + + + + + )} + /> + + )} + + ( + + Amount + +
+ + + {[50, 100, 250, 500].map((value, index) => ( + + ))} +
+
+ +
+ )} + /> + + ( + + Do you want this donation to be tax deductible? (US only) + + + + + + + No + + + + + + Yes + + + + + + )} + /> + + {amount > 500 && taxDeductible === 'yes' && ( + + + Heads up! + + When donating over $500 with crypto, you MUST complete{' '} + + Form 8283 + {' '} + and send the completed form to{' '} + info@magicgrants.org{' '} + to deduct your donation. + + + )} + +
+ + + +
+ + + + {!isAuthed &&
} + + {!isAuthed && ( +
+

Want to support more projects from now on?

+ + +
+ )} +
+ ) +} + +export default DonationFormModal diff --git a/components/FiroLogo.tsx b/components/FiroLogo.tsx new file mode 100644 index 0000000..ce16ed3 --- /dev/null +++ b/components/FiroLogo.tsx @@ -0,0 +1,35 @@ +import { SVGProps } from 'react' + +function FiroLogo(props: SVGProps) { + return ( + + + + + + + + + + + + + ) +} + +export default FiroLogo diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..bf989f5 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,33 @@ +import CustomLink from './CustomLink' + +// import SocialIcon from '@/components/social-icons' + +function Footer() { + return ( +
+
+ + Terms of Use + + + | + + + Privacy Policy + +
+ +
+ MAGIC Grants is a 501(c)(3) non-profit organization. All gifts and donations are + tax-deductible to the full extent of the law. +
+ +
+ © {new Date().getFullYear()} MAGIC Grants. This website builds upon technology by Open + Sats. +
+
+ ) +} + +export default Footer diff --git a/components/Header.tsx b/components/Header.tsx index 5b2ef13..aa4ce17 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,29 +1,163 @@ -import Nav from 'react-bootstrap/Nav' -import Navbar from 'react-bootstrap/Navbar' -import Container from 'react-bootstrap/Container' -// import NavDropdown from 'react-bootstrap/NavDropdown' -// import Row from 'react-bootstrap/Row' -// import Col from 'react-bootstrap/Col' -// import Image from 'next/image' -// import Link from 'next/link' -// import samplelogo from '/public/favicon.png' +import { useEffect, useState } from 'react' +import { signOut, useSession } from 'next-auth/react' +import { useRouter } from 'next/router' + +import Link from './CustomLink' +import MobileNav from './MobileNav' +import { fundHeaderNavLinks } from '../data/headerNavLinks' +import MagicLogo from './MagicLogo' +import { Dialog, DialogContent, DialogTrigger } from './ui/dialog' +import { Button } from './ui/button' +import RegisterFormModal from './RegisterFormModal' +import LoginFormModal from './LoginFormModal' +import PasswordResetFormModal from './PasswordResetFormModal' +import { Avatar, AvatarFallback } from './ui/avatar' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu' +import { useFundSlug } from '../utils/use-fund-slug' +import CustomLink from './CustomLink' +import { funds } from '../utils/funds' +import MoneroLogo from './MoneroLogo' +import FiroLogo from './FiroLogo' +import PrivacyGuidesLogo from './PrivacyGuidesLogo' const Header = () => { + const [registerIsOpen, setRegisterIsOpen] = useState(false) + const [loginIsOpen, setLoginIsOpen] = useState(false) + const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false) + const router = useRouter() + const session = useSession() + const fundSlug = useFundSlug() + + useEffect(() => { + if (router.query.loginEmail) { + setLoginIsOpen(true) + } + }, [router.query.loginEmail]) + + const fund = fundSlug ? funds[fundSlug] : null + return ( -
- - - MAGIC Monero Fund - - - - - - +
+
+ + {!fundSlug && } + {fundSlug === 'monero' && } + {fundSlug === 'firo' && } + {fundSlug === 'privacyguides' && } + {fundSlug === 'general' && } + + + {fund ? fund.title : 'MAGIC Grants'} + + +
+ +
+ {!!fund && + fundHeaderNavLinks[fund.slug].map((link) => ( + + {link.title} + + ))} + + {!!fund && session.status !== 'authenticated' && ( + <> + + + + + + + + + setLoginIsOpen(false)} + openRegisterModal={() => setRegisterIsOpen(true)} + openPasswordResetModal={() => setPasswordResetIsOpen(true)} + /> + + + + + + + + + + + + setRegisterIsOpen(false)} + openLoginModal={() => setLoginIsOpen(true)} + /> + + + + )} + + {/* */} + + {!!fund && session.status === 'authenticated' && ( + + + + + {session.data.user?.email?.slice(0, 2).toUpperCase()} + + + + + My Account + + + My Donations + + + My Memberships + + + Settings + + signOut({ callbackUrl: `/${fundSlug}` })}> + Logout + + + + )} + + {!!fundSlug && } +
+ + + + setPasswordResetIsOpen(false)} /> + +
) } diff --git a/components/Layout.tsx b/components/Layout.tsx index a77acc2..fa82918 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -1,41 +1,49 @@ -import Navbar from './Header' -import React from 'react' -import Head from 'next/head' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBug } from '@fortawesome/free-solid-svg-icons' -import Link from 'next/link' +import { ReactNode, useEffect } from 'react' +import { signOut, useSession } from 'next-auth/react' +import { Inter } from 'next/font/google' + +import SectionContainer from './SectionContainer' +import Footer from './Footer' +import Header from './Header' +import { useFundSlug } from '../utils/use-fund-slug' + +interface Props { + children: ReactNode +} + +const inter = Inter({ subsets: ['latin'] }) + +const LayoutWrapper = ({ children }: Props) => { + const fundSlug = useFundSlug() + const { data: session } = useSession() + + useEffect(() => { + if (session?.error === 'RefreshAccessTokenError') { + if (fundSlug) { + signOut({ callbackUrl: `/${fundSlug}/?loginEmail=${session?.user.email}` }) + } else { + signOut({ callbackUrl: '/' }) + } + } + }, [session]) -const Layout: React.FC = ({ children }) => { return ( -
- - MAGIC Monero Fund - - + <> + - {/* Twitter */} - - - - - - - {/* Open Graph */} - - - - - - - - - -
{ children }
-
- © MAGIC Grants. This website builds upon technology by Open Sats. -
-
+ +
+
+
{children}
+
+
+
+ ) } -export default Layout +export default LayoutWrapper diff --git a/components/LoginFormModal.tsx b/components/LoginFormModal.tsx new file mode 100644 index 0000000..51a3316 --- /dev/null +++ b/components/LoginFormModal.tsx @@ -0,0 +1,149 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import { zodResolver } from '@hookform/resolvers/zod' +import { signIn, useSession } from 'next-auth/react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog' +import { Input } from './ui/input' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Button } from './ui/button' +import { useToast } from './ui/use-toast' +import { useFundSlug } from '../utils/use-fund-slug' +import Spinner from './Spinner' + +const schema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}) + +type LoginFormInputs = z.infer + +type Props = { + close: () => void + openPasswordResetModal: () => void + openRegisterModal: () => void +} + +function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Props) { + const { toast } = useToast() + const router = useRouter() + const fundSlug = useFundSlug() + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { email: '', password: '' }, + shouldFocusError: false, + }) + + useEffect(() => { + if (!fundSlug) return + if (router.query.loginEmail) { + form.setValue('email', router.query.loginEmail as string) + setTimeout(() => form.setFocus('password'), 100) + router.replace(`/${fundSlug}`) + } + }, [router.query.loginEmail]) + + async function onSubmit(data: LoginFormInputs) { + const result = await signIn('credentials', { + redirect: false, + email: data.email, + password: data.password, + }) + + if (result?.error) { + if (result.error === 'INVALID_CREDENTIALS') { + return form.setError( + 'password', + { message: 'Invalid email or password.' }, + { shouldFocus: true } + ) + } + + return toast({ + title: 'Sorry, something went wrong.', + variant: 'destructive', + }) + } + + toast({ + title: 'Successfully logged in!', + }) + + close() + } + + return ( + <> + + Login + Log into your account. + + +
+ + ( + + Email + + + + + + )} + /> + +
+ ( + + Password + + + + + + )} + /> + + +
+ +
+ + + +
+ + + + ) +} + +export default LoginFormModal diff --git a/components/LogoCrystal.tsx b/components/LogoCrystal.tsx new file mode 100644 index 0000000..29790fc --- /dev/null +++ b/components/LogoCrystal.tsx @@ -0,0 +1,725 @@ +import { SVGProps } from "react"; + +function Logo(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default Logo; diff --git a/components/MagicLogo.tsx b/components/MagicLogo.tsx new file mode 100644 index 0000000..0bb1f1a --- /dev/null +++ b/components/MagicLogo.tsx @@ -0,0 +1,118 @@ +import { SVGProps } from 'react' + +function MagicLogo(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default MagicLogo diff --git a/components/MembershipFormModal.tsx b/components/MembershipFormModal.tsx new file mode 100644 index 0000000..9bfc043 --- /dev/null +++ b/components/MembershipFormModal.tsx @@ -0,0 +1,295 @@ +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { faMonero } from '@fortawesome/free-brands-svg-icons' +import { faCreditCard } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { DollarSign } from 'lucide-react' +import { useSession } from 'next-auth/react' +import { z } from 'zod' +import Image from 'next/image' + +import { MAX_AMOUNT } from '../config' +import Spinner from './Spinner' +import { trpc } from '../utils/trpc' +import { useToast } from './ui/use-toast' +import { Button } from './ui/button' +import { RadioGroup, RadioGroupItem } from './ui/radio-group' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Input } from './ui/input' +import { ProjectItem } from '../utils/types' +import { useFundSlug } from '../utils/use-fund-slug' + +type Props = { + project: ProjectItem | undefined + close: () => void + openRegisterModal: () => void +} + +const MembershipFormModal: React.FC = ({ project, close, openRegisterModal }) => { + const fundSlug = useFundSlug() + const session = useSession() + const isAuthed = session.status === 'authenticated' + + const schema = z + .object({ + name: z.string().optional(), + email: z.string().email().optional(), + amount: z.coerce.number().min(1).max(MAX_AMOUNT), + taxDeductible: z.enum(['yes', 'no']), + recurring: z.enum(['yes', 'no']), + }) + .refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), { + message: 'Name is required when the donation is tax deductible.', + path: ['name'], + }) + .refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), { + message: 'Email is required when the donation is tax deductible.', + path: ['email'], + }) + + type FormInputs = z.infer + + const { toast } = useToast() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + amount: 100, // a trick to get trigger to work when amount is empty + taxDeductible: 'no', + recurring: 'no', + }, + mode: 'all', + }) + + const taxDeductible = form.watch('taxDeductible') + const recurring = form.watch('recurring') + + const payMembershipWithFiatMutation = trpc.donation.payMembershipWithFiat.useMutation() + const payMembershipWithCryptoMutation = trpc.donation.payMembershipWithCrypto.useMutation() + + async function handleBtcPay(data: FormInputs) { + if (!project) return + if (!fundSlug) return + + try { + const result = await payMembershipWithCryptoMutation.mutateAsync({ + projectSlug: project.slug, + projectName: project.title, + fundSlug, + taxDeductible: data.taxDeductible === 'yes', + }) + + window.location.assign(result.url) + } catch (e) { + toast({ + title: 'Sorry, something went wrong.', + variant: 'destructive', + }) + } + } + + async function handleFiat(data: FormInputs) { + if (!project) return + if (!fundSlug) return + + try { + const result = await payMembershipWithFiatMutation.mutateAsync({ + projectSlug: project.slug, + projectName: project.title, + fundSlug, + recurring: data.recurring === 'yes', + taxDeductible: data.taxDeductible === 'yes', + }) + + if (!result.url) throw Error() + + window.location.assign(result.url) + } catch (e) { + toast({ + title: 'Sorry, something went wrong.', + variant: 'destructive', + }) + } + } + + if (!project) return <> + + return ( +
+
+
+ {project.title} +
+

{project.title}

+

Pledge your support

+
+
+
+ +
+ + {!isAuthed && ( + <> + ( + + Name {taxDeductible === 'no' && '(optional)'} + + + + + + )} + /> + + ( + + Email {taxDeductible === 'no' && '(optional)'} + + + + + + )} + /> + + )} + +
+ Amount + + + 100.00 + +
+ + ( + + Do you want your membership to be tax deductible? (US only) + + + + + + + No + + + + + + Yes + + + + + + )} + /> + + ( + + + Do you want your membership payment to be recurring? (Fiat only) + + + + + + + + No + + + + + + Yes + + + + + + )} + /> + +
+ + + +
+ + + + {!isAuthed &&
} + + {!isAuthed && ( +
+

Want to support more projects from now on?

+ + +
+ )} +
+ ) +} + +export default MembershipFormModal diff --git a/components/MobileNav.tsx b/components/MobileNav.tsx new file mode 100644 index 0000000..017d406 --- /dev/null +++ b/components/MobileNav.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react' + +import CustomLink from './CustomLink' +import { fundHeaderNavLinks } from '../data/headerNavLinks' +import { useFundSlug } from '../utils/use-fund-slug' +import { funds } from '../utils/funds' + +const MobileNav = () => { + const [navShow, setNavShow] = useState(false) + const fundSlug = useFundSlug() + + const fund = fundSlug ? funds[fundSlug] : null + + const onToggleNav = () => { + setNavShow((status) => { + if (status) { + document.body.style.overflow = 'auto' + } else { + // Prevent scrolling + document.body.style.overflow = 'hidden' + } + return !status + }) + } + + return ( +
+ +
+
+ +
+ +
+
+ ) +} + +export default MobileNav diff --git a/components/MoneroLogo.tsx b/components/MoneroLogo.tsx new file mode 100644 index 0000000..c6d32f0 --- /dev/null +++ b/components/MoneroLogo.tsx @@ -0,0 +1,58 @@ +import { SVGProps } from 'react' + +function MoneroLogo(props: SVGProps) { + return ( + + + + + + + + + + + + + + + ) +} +export default MoneroLogo diff --git a/components/PageHeading.tsx b/components/PageHeading.tsx new file mode 100644 index 0000000..40baaac --- /dev/null +++ b/components/PageHeading.tsx @@ -0,0 +1,77 @@ +import { networkFor, SocialIcon } from 'react-social-icons' +import { ReactNode } from 'react' +import Image from 'next/image' + +import { ProjectItem } from '../utils/types' +import CustomLink from './CustomLink' +import WebIcon from './WebIcon' + +interface Props { + project: ProjectItem + children: ReactNode +} + +export default function PageHeading({ project, children }: Props) { + return ( +
+
+ avatar + +

+ {!!project.website && ( + + {project.title} + + )} + {!project.website && project.title} +

+ +

{project.summary}

+ +
+ +
+

+ by {project.nym} +

+ +
+ {project.socialLinks.map((link) => + networkFor(link) !== 'sharethis' ? ( + + ) : ( + + + + ) + )} +
+
+
+ +
+ {children} +
+
+ ) +} diff --git a/components/PasswordResetFormModal.tsx b/components/PasswordResetFormModal.tsx new file mode 100644 index 0000000..55e1b18 --- /dev/null +++ b/components/PasswordResetFormModal.tsx @@ -0,0 +1,71 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog' +import { Input } from './ui/input' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Button } from './ui/button' +import { useToast } from './ui/use-toast' +import { trpc } from '../utils/trpc' +import Spinner from './Spinner' + +const schema = z.object({ + email: z.string().email(), +}) + +type PasswordResetFormInputs = z.infer + +type Props = { close: () => void } + +function PasswordResetFormModal({ close }: Props) { + const { toast } = useToast() + + const form = useForm({ resolver: zodResolver(schema) }) + + const requestPasswordResetMutation = trpc.auth.requestPasswordReset.useMutation() + + async function onSubmit(data: PasswordResetFormInputs) { + try { + await requestPasswordResetMutation.mutateAsync(data) + + toast({ title: 'A password reset link has been sent to your email.' }) + close() + } catch (error) { + toast({ title: 'Sorry, something went wrong.', variant: 'destructive' }) + } + } + + return ( + <> + + Reset Password + Recover your account. + + +
+ + ( + + Email + + + + + + )} + /> + + + + + + ) +} + +export default PasswordResetFormModal diff --git a/components/PaymentModal.tsx b/components/PaymentModal.tsx deleted file mode 100644 index 1a92391..0000000 --- a/components/PaymentModal.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import ReactModal from 'react-modal' -import Image from 'next/image' -import waffledog from '../public/waffledog.jpg' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faClose } from '@fortawesome/free-solid-svg-icons' -import DonationForm from './DonationForm' -import { ProjectItem } from '../utils/types' - -type ModalProps = { - isOpen: boolean - onRequestClose: () => void - project: ProjectItem | undefined -} -const PaymentModal: React.FC = ({ - isOpen, - onRequestClose, - project, -}) => { - if (!project) { - // We never see this yeah? - return
- } - - return ( - -
- -
-
-
- {project.title} -
-

{project.title}

-

Pledge your support

-
-
-
- -
- ) -} - -export default PaymentModal diff --git a/components/PrivacyGuidesLogo.tsx b/components/PrivacyGuidesLogo.tsx new file mode 100644 index 0000000..41bcc9a --- /dev/null +++ b/components/PrivacyGuidesLogo.tsx @@ -0,0 +1,36 @@ +import { SVGProps } from 'react' + +function PrivacyGuidesLogo(props: SVGProps) { + return ( + + + + + ) +} + +export default PrivacyGuidesLogo diff --git a/components/Progress.tsx b/components/Progress.tsx index 28b99b9..5c0b725 100644 --- a/components/Progress.tsx +++ b/components/Progress.tsx @@ -1,12 +1,24 @@ -import ProgressBar from 'react-bootstrap/ProgressBar'; +type ProgressProps = { current: number; goal: number } -type ProgressProps = { - text: number; - }; +const Progress = ({ current, goal }: ProgressProps) => { + const percent = Math.floor((current / goal) * 100) -const AnimatedExample = (props: ProgressProps) => { - const { text } = props; - return ; + return ( +
+ {/*
+ 0 ${numberFormat.format(goal)} +
*/} + +
+
+
+ + {percent < 100 ? percent : 100}% +
+ ) } -export default AnimatedExample; \ No newline at end of file +export default Progress diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index e4df7df..85cba9e 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -1,72 +1,86 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faArrowRight } from '@fortawesome/free-solid-svg-icons' -import escapeHTML from 'escape-html' import Image from 'next/image' import Link from 'next/link' +import { useState, useEffect } from 'react' import { ProjectItem } from '../utils/types' -import ShareButtons from './ShareButtons' +import { useFundSlug } from '../utils/use-fund-slug' +import Progress from './Progress' +import { cn } from '../utils/cn' + +const numberFormat = Intl.NumberFormat('en', { notation: 'compact', compactDisplay: 'short' }) export type ProjectCardProps = { project: ProjectItem - openPaymentModal: (project: ProjectItem) => void + customImageStyles?: React.CSSProperties } -const ProjectCard: React.FC = ({ - project, - openPaymentModal, -}) => { - const { slug, title, summary, coverImage, git, twitter, personalTwitter, personalWebsite, nym, goal, isFunded } = - project +const ProjectCard: React.FC = ({ project, customImageStyles }) => { + const [isHorizontal, setIsHorizontal] = useState(null) + + useEffect(() => { + const img = document.createElement('img') + img.src = project.coverImage + + // check if image is horizontal - added additional 10% to height to ensure only true + // horizontals get flagged. + img.onload = () => { + const { naturalWidth, naturalHeight } = img + const isHorizontal = naturalWidth >= naturalHeight * 1.1 + setIsHorizontal(isHorizontal) + } + }, [project.coverImage]) return ( -
-
- -
- {title} -
- -
- -
-

{title}

-

- by{' '} - - {nym} - -

-

{summary}

-
- - -
-
+ +
+
+
+

{project.title}

+ by {project.nym} +
+ + {project.summary} + + + Goal: ${numberFormat.format(project.goal)} + +
+ + +
+ + ) } diff --git a/components/ProjectList.tsx b/components/ProjectList.tsx index b7d6d2d..e0b8a47 100644 --- a/components/ProjectList.tsx +++ b/components/ProjectList.tsx @@ -9,37 +9,23 @@ type ProjectListProps = { header?: string exclude?: string projects: ProjectItem[] - openPaymentModal: (project: ProjectItem) => void } + const ProjectList: React.FC = ({ header = 'Explore Projects', exclude, projects, - openPaymentModal, }) => { - const [sortedProjects, setSortedProjects] = useState() - - useEffect(() => { - setSortedProjects(projects.filter(p => p.slug !== exclude).sort(() => 0.5 - Math.random())) - }, [projects]) - return ( -
-
-

{header}

-
- View All - -
-
-
    - {sortedProjects && - sortedProjects.slice(0, 3).map((p, i) => ( +
    +
      + {projects && + projects.slice(0, 6).map((p, i) => (
    • - +
    • ))}
    diff --git a/components/RegisterFormModal.tsx b/components/RegisterFormModal.tsx new file mode 100644 index 0000000..9c8ad1f --- /dev/null +++ b/components/RegisterFormModal.tsx @@ -0,0 +1,166 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from './ui/dialog' +import { Input } from './ui/input' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from './ui/form' +import { Button } from './ui/button' +import { useToast } from './ui/use-toast' +import { trpc } from '../utils/trpc' +import { useFundSlug } from '../utils/use-fund-slug' +import Spinner from './Spinner' + +const schema = z + .object({ + name: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), + confirmPassword: z.string().min(8), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }) + +type RegisterFormInputs = z.infer + +type Props = { close: () => void; openLoginModal: () => void } + +function RegisterFormModal({ close, openLoginModal }: Props) { + const { toast } = useToast() + const form = useForm({ resolver: zodResolver(schema) }) + const fundSlug = useFundSlug() + const registerMutation = trpc.auth.register.useMutation() + + async function onSubmit(data: RegisterFormInputs) { + if (!fundSlug) return + + try { + await registerMutation.mutateAsync({ ...data, fundSlug }) + + toast({ + title: 'Please check your email to verify your account.', + }) + + close() + } catch (error) { + const errorMessage = (error as any).message + + if (errorMessage === 'EMAIL_TAKEN') { + return form.setError('email', { message: 'Email is already taken.' }, { shouldFocus: true }) + } + + toast({ + title: 'Sorry, something went wrong.', + variant: 'destructive', + }) + } + } + + return ( + <> + + Register + Start supporting projects today! + + +
    + + ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Confirm password + + + + + + )} + /> + +
    + + + +
    + + + + ) +} +1 + +export default RegisterFormModal diff --git a/components/SectionContainer.tsx b/components/SectionContainer.tsx new file mode 100644 index 0000000..b314727 --- /dev/null +++ b/components/SectionContainer.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +export default function SectionContainer({ children }: Props) { + return ( +
    + {children} +
    + ) +} diff --git a/components/ShareButtons.tsx b/components/ShareButtons.tsx deleted file mode 100644 index 528edda..0000000 --- a/components/ShareButtons.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { faGithub, faTwitter } from "@fortawesome/free-brands-svg-icons" -import { faLink } from "@fortawesome/free-solid-svg-icons" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import Link from "next/link" -import escapeHTML from "escape-html" -import { ProjectItem } from "../utils/types" - -const ShareButtons: React.FC<{ project: ProjectItem }> = ({ project }) => { - const { git, twitter, website } = project; - return ( -
    - - - - - - - - - - - {website && - - - - } -
    - ) -} - - - -export default ShareButtons \ No newline at end of file diff --git a/components/Spinner.tsx b/components/Spinner.tsx index 4d83e3e..00a4257 100644 --- a/components/Spinner.tsx +++ b/components/Spinner.tsx @@ -2,14 +2,14 @@ const Spinner = () => { return ( { + const [mounted, setMounted] = useState(false) + const { theme, setTheme, resolvedTheme } = useTheme() + + // When mounted on client, now we can show the UI + useEffect(() => setMounted(true), []) + + return ( + + ) +} + +export default ThemeSwitch diff --git a/components/WebIcon.tsx b/components/WebIcon.tsx new file mode 100644 index 0000000..6f76e0c --- /dev/null +++ b/components/WebIcon.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from 'react' + +const WebIcon = (props: SVGProps) => ( + + + + +) + +export default WebIcon diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..c09a5d8 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '../../utils/cn' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-primary [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-white text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
    +)) +Alert.displayName = 'Alert' + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..bf15045 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '../../utils/cn' + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..f69df7f --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '../../utils/cn' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-semibold text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover', + light: 'text-primary bg-primary/10 hover:bg-primary hover:text-primary-foreground', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'text-primary border border-primary bg-background shadow-sm hover:bg-primary hover:text-primary-foreground', + ghost: 'hover:bg-primary/20 hover:text-primary-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-10 text-md', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..c60db50 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,99 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { Cross2Icon } from '@radix-ui/react-icons' + +import { cn } from '../../utils/cn' + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger +const DialogPortal = DialogPrimitive.Portal +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..6dc148a --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,184 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons' + +import { cn } from '../../utils/cn' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..8d3dcc6 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,179 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form' + +import { cn } from '../../utils/cn' +import { Label } from './label' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name) + // console.log(fieldState, fieldContext.name) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
    + + ) +}) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +