damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

commit 0773a062ebd4dc3f9c1a2d6dc02de499a4a0836a
parent e9a9ffee71317c51fe6c8b9c8c32ff18bd006eea
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Wed, 23 Oct 2024 10:57:45 -0700

Merge pull request #28 from danieldaquino/notedeck#330

Non-iOS Purple checkout flows (client-side)
Diffstat:
M.gitignore | 1+
Mbuild | 14++++----------
Mcontent/compiled-locales/en.json | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcontent/locales/en.json | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mnext.config.js | 2++
Mpackage-lock.json | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 2++
Asrc/components/NostrProfile.tsx | 29+++++++++++++++++++++++++++++
Asrc/components/NostrUserInput.tsx | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/OTPAuth.tsx | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/pages/purple-welcome.tsx | 30++++++++++++++++++++++++++++++
Msrc/components/sections/FinalCTA.tsx | 6+++---
Msrc/components/sections/Hero.tsx | 9++++-----
Msrc/components/sections/Notedeck/NotedeckHero.tsx | 14++++++++++++--
Msrc/components/sections/Notedeck/WaitlistForm.tsx | 6+++---
Msrc/components/sections/PurpleAccount.tsx | 52++++++++++++++--------------------------------------
Msrc/components/sections/PurpleBanner.tsx | 1-
Msrc/components/sections/PurpleCheckout.tsx | 460+++++++------------------------------------------------------------------------
Asrc/components/sections/PurpleCheckoutDetails/AccountExistsNote.tsx | 23+++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/CheckoutSuccess.tsx | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/ErrorDialog.tsx | 34++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/Step1ProductSelection.tsx | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/Step2DamusIOSVerification.tsx | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/Step2OTPVerification.tsx | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/Step2UserVerification.tsx | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/Step3Payment.tsx | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/StepHeader.tsx | 14++++++++++++++
Asrc/components/sections/PurpleCheckoutDetails/Types.tsx | 25+++++++++++++++++++++++++
Msrc/components/sections/PurpleFinalCTA.tsx | 1-
Msrc/components/sections/PurpleHero.tsx | 63+++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/components/sections/PurpleLogin.tsx | 86+++++++++++++++++++++++++------------------------------------------------------
Asrc/components/sections/PurpleWelcome.tsx | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/sections/TopMenu.tsx | 17+++++++++++++----
Asrc/components/ui/CopiableUrl.tsx | 21+++++++++++++++++++++
Msrc/components/ui/RoundedContainerWithGradientBorder.tsx | 2+-
Asrc/components/ui/Tabs.tsx | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hooks/usePurpleLoginSession.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/constants.ts | 2+-
Asrc/pages/purple/welcome/index.tsx | 39+++++++++++++++++++++++++++++++++++++++
39 files changed, 2254 insertions(+), 575 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -13,6 +13,7 @@ /out/ /out-staging /out-production +/out-dev # misc .DS_Store diff --git a/build b/build @@ -10,6 +10,10 @@ if [ "$env" == "staging" ] then export OUT_DIR="$PWD/out-staging" export NEXT_PUBLIC_PURPLE_API_BASE_URL="https://api-staging.damus.io" +elif [ "$env" == "dev" ] +then + export OUT_DIR="$PWD/out-dev" + # Do not modify env vars for dev else export OUT_DIR="$PWD/out-production" export NEXT_PUBLIC_PURPLE_API_BASE_URL="https://api.damus.io" @@ -18,13 +22,3 @@ fi npx next build rm -rf "$OUT_DIR" mv out "$OUT_DIR" - - -# TODO: next doesn't seem to set this up properly. bug? -mkdir -p "$OUT_DIR/purple/checkout" -mkdir -p "$OUT_DIR/purple/account" -mkdir -p "$OUT_DIR/purple/login" -ln "$OUT_DIR/purple.html" "$OUT_DIR/purple/index.html" -ln "$OUT_DIR/purple/checkout.html" "$OUT_DIR/purple/checkout/index.html" -ln "$OUT_DIR/purple/account.html" "$OUT_DIR/purple/account/index.html" -ln "$OUT_DIR/purple/login.html" "$OUT_DIR/purple/login/index.html" diff --git a/content/compiled-locales/en.json b/content/compiled-locales/en.json @@ -247,22 +247,22 @@ "value": "Available in" } ], - "home.hero.download_now": [ + "home.hero.check-out-notedeck": [ { "type": 0, - "value": "Download now" + "value": "Check Notedeck, our cross-platform client" } ], - "home.hero.headline": [ + "home.hero.download_now": [ { "type": 0, - "value": "The social network you control" + "value": "Download now" } ], - "home.hero.join_testflight": [ + "home.hero.headline": [ { "type": 0, - "value": "Join TestFlight Beta" + "value": "The social network you control" } ], "home.hero.subheadline": [ @@ -299,6 +299,12 @@ "value": "The highest performance Nostr client. Period." } ], + "notedeck.hero.menu.signup-for-the-waitlist": [ + { + "type": 0, + "value": "Sign up" + } + ], "notedeck.hero.signup-for-the-waitlist": [ { "type": 0, @@ -467,6 +473,12 @@ "value": "Hide QR code" } ], + "purple.checkout.continue.on-web": [ + { + "type": 0, + "value": "Continue" + } + ], "purple.checkout.continue.show-qr": [ { "type": 0, @@ -485,6 +497,18 @@ "value": "Use this page to purchase a new account, or to renew an existing one. You will need the latest Damus version to complete the checkout." } ], + "purple.checkout.is-this-you": [ + { + "type": 0, + "value": "Is this you?" + } + ], + "purple.checkout.logging-into": [ + { + "type": 0, + "value": "Logging into:" + } + ], "purple.checkout.open-in-app": [ { "type": 0, @@ -497,6 +521,12 @@ "value": "Open in wallet" } ], + "purple.checkout.paying-for": [ + { + "type": 0, + "value": "Purchasing membership for:" + } + ], "purple.checkout.payment-received": [ { "type": 0, @@ -713,12 +743,36 @@ "value": "For free-speech maximalists" } ], + "purple.hero.go-to-my-account": [ + { + "type": 0, + "value": "Go to my account" + } + ], "purple.hero.learn-more": [ { "type": 0, "value": "Learn more" } ], + "purple.hero.login": [ + { + "type": 0, + "value": "Login to my account" + } + ], + "purple.hero.menu.go-to-my-account": [ + { + "type": 0, + "value": "My Account" + } + ], + "purple.hero.menu.login": [ + { + "type": 0, + "value": "Login" + } + ], "purple.hero.renew": [ { "type": 0, @@ -743,12 +797,24 @@ "value": "Use this page to access your Purple account details" } ], + "purple.login.fetching-profile": [ + { + "type": 0, + "value": "Fetching profile info…" + } + ], "purple.login.is-this-you": [ { "type": 0, "value": "Is this you?" } ], + "purple.login.logging-into": [ + { + "type": 0, + "value": "Logging into:" + } + ], "purple.login.login-successful": [ { "type": 0, @@ -782,13 +848,13 @@ "purple.login.otp-sent": [ { "type": 0, - "value": "We sent you a code via a Nostr DM.\n Please enter it below" + "value": "We sent you a code via a Nostr direct message.\n Please enter it below" } ], "purple.login.stay-safe.message": [ { "type": 0, - "value": "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone.\n\n If you don't see the OTP code, please check the DM requests tab in Damus." + "value": "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone." } ], "purple.login.stay-safe.title": [ @@ -815,12 +881,42 @@ "value": "Access your account" } ], - "purple.login.unknown-user": [ + "purple.otp.troubleshooting.heading": [ + { + "type": 0, + "value": "Didn't receive the OTP?" + } + ], + "purple.otp.troubleshooting.message": [ + { + "type": 0, + "value": "If you don't see the OTP code, try the following steps:" + } + ], + "purple.otp.troubleshooting.step1": [ + { + "type": 0, + "value": "Check your DM request tab (if you are on Damus)" + } + ], + "purple.otp.troubleshooting.step2": [ + { + "type": 0, + "value": "Ensure your Nostr client is connected to our relay listed below:" + } + ], + "purple.profile.unknown-user": [ { "type": 0, "value": "Generic user avatar" } ], + "purple.welcome.continue": [ + { + "type": 0, + "value": "Continue" + } + ], "roles.brand-ambassador": [ { "type": 0, @@ -887,6 +983,12 @@ "value": "Damus logo" } ], + "topbar.notedeck": [ + { + "type": 0, + "value": "Notedeck" + } + ], "topbar.purple": [ { "type": 0, diff --git a/content/locales/en.json b/content/locales/en.json @@ -119,15 +119,15 @@ "home.hero.available_in": { "string": "Available in" }, + "home.hero.check-out-notedeck": { + "string": "Check Notedeck, our cross-platform client" + }, "home.hero.download_now": { "string": "Download now" }, "home.hero.headline": { "string": "The social network you control" }, - "home.hero.join_testflight": { - "string": "Join TestFlight Beta" - }, "home.hero.subheadline": { "string": "Your very own social network for your friends or business.\n Available Now on iOS, iPad and macOS (M1/M2)" }, @@ -143,6 +143,9 @@ "notedeck.hero.description": { "string": "The highest performance Nostr client. Period." }, + "notedeck.hero.menu.signup-for-the-waitlist": { + "string": "Sign up" + }, "notedeck.hero.signup-for-the-waitlist": { "string": "Sign up for the waitlist" }, @@ -227,6 +230,9 @@ "purple.checkout.continue.hide-qr": { "string": "Hide QR code" }, + "purple.checkout.continue.on-web": { + "string": "Continue" + }, "purple.checkout.continue.show-qr": { "string": "Show QR code" }, @@ -236,12 +242,21 @@ "purple.checkout.description-2": { "string": "Use this page to purchase a new account, or to renew an existing one. You will need the latest Damus version to complete the checkout." }, + "purple.checkout.is-this-you": { + "string": "Is this you?" + }, + "purple.checkout.logging-into": { + "string": "Logging into:" + }, "purple.checkout.open-in-app": { "string": "Open in Damus" }, "purple.checkout.open-in-wallet": { "string": "Open in wallet" }, + "purple.checkout.paying-for": { + "string": "Purchasing membership for:" + }, "purple.checkout.payment-received": { "string": "Payment received" }, @@ -350,9 +365,21 @@ "purple.hero.description": { "string": "For free-speech maximalists" }, + "purple.hero.go-to-my-account": { + "string": "Go to my account" + }, "purple.hero.learn-more": { "string": "Learn more" }, + "purple.hero.login": { + "string": "Login to my account" + }, + "purple.hero.menu.go-to-my-account": { + "string": "My Account" + }, + "purple.hero.menu.login": { + "string": "Login" + }, "purple.hero.renew": { "string": "Already a member? Click here to renew!" }, @@ -365,9 +392,15 @@ "purple.login.description": { "string": "Use this page to access your Purple account details" }, + "purple.login.fetching-profile": { + "string": "Fetching profile info…" + }, "purple.login.is-this-you": { "string": "Is this you?" }, + "purple.login.logging-into": { + "string": "Logging into:" + }, "purple.login.login-successful": { "string": "Login successful. You should be automatically redirected. If not, please click the button below." }, @@ -384,10 +417,10 @@ "string": "Invalid or expired OTP. Please try again." }, "purple.login.otp-sent": { - "string": "We sent you a code via a Nostr DM.\n Please enter it below" + "string": "We sent you a code via a Nostr direct message.\n Please enter it below" }, "purple.login.stay-safe.message": { - "string": "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone.\n\n If you don't see the OTP code, please check the DM requests tab in Damus." + "string": "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone." }, "purple.login.stay-safe.title": { "string": "Stay safe" @@ -401,9 +434,24 @@ "purple.login.title": { "string": "Access your account" }, - "purple.login.unknown-user": { + "purple.otp.troubleshooting.heading": { + "string": "Didn't receive the OTP?" + }, + "purple.otp.troubleshooting.message": { + "string": "If you don't see the OTP code, try the following steps:" + }, + "purple.otp.troubleshooting.step1": { + "string": "Check your DM request tab (if you are on Damus)" + }, + "purple.otp.troubleshooting.step2": { + "string": "Ensure your Nostr client is connected to our relay listed below:" + }, + "purple.profile.unknown-user": { "string": "Generic user avatar" }, + "purple.welcome.continue": { + "string": "Continue" + }, "roles.brand-ambassador": { "string": "Brand Ambassador" }, @@ -437,6 +485,9 @@ "topbar.logo_alt_text": { "string": "Damus logo" }, + "topbar.notedeck": { + "string": "Notedeck" + }, "topbar.purple": { "string": "Purple" }, diff --git a/next.config.js b/next.config.js @@ -2,6 +2,8 @@ const nextConfig = { output: 'export', reactStrictMode: true, + // Build `/src/pages/example/index.tsx` to `out/example/index.html` instead of `out/example.html` + trailingSlash: true, images: { unoptimized: true, }, diff --git a/package-lock.json b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "framer-motion": "^10.16.4", @@ -985,6 +986,221 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "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", @@ -1003,6 +1219,218 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", diff --git a/package.json b/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "build": "./build", "build-staging": "./build staging", + "build-dev": "./build dev", "start": "next start", "export": "npm run i18n && next export", "lint": "next lint", @@ -19,6 +20,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "framer-motion": "^10.16.4", diff --git a/src/components/NostrProfile.tsx b/src/components/NostrProfile.tsx @@ -0,0 +1,29 @@ +import { useIntl } from "react-intl"; +import Image from "next/image"; +import { nip19 } from "nostr-tools" +import { Profile } from "@/utils/PurpleUtils"; + +export interface NostrProfileProps { + profile: Profile + profileHeader: React.ReactNode + profileFooter: React.ReactNode +} + +export function NostrProfile(props: NostrProfileProps) { + const intl = useIntl() + const { profile } = props + const npub = nip19.npubEncode(profile.pubkey) + + return (<> + <div className="mt-2 mb-4 flex flex-col items-center"> + {props.profileHeader} + <div className="mt-4 flex flex-col gap-1 items-center justify-center mb-4"> + <Image src={profile?.picture || ("https://robohash.org/" + (profile.pubkey))} width={64} height={64} className="rounded-full" alt={profile.name || intl.formatMessage({ id: "purple.profile.unknown-user", defaultMessage: "Generic user avatar" })} /> + <div className="text-purple-100/90 font-semibold text-lg"> + {profile?.name || (npub.substring(0, 8) + ":" + npub.substring(npub.length - 8))} + </div> + </div> + {props.profileFooter} + </div> + </>) +} diff --git a/src/components/NostrUserInput.tsx b/src/components/NostrUserInput.tsx @@ -0,0 +1,102 @@ +import { FormattedMessage, useIntl } from "react-intl"; +import Link from "next/link"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { NostrEvent, Relay, nip19 } from "nostr-tools" +import { Loader2, Radius, Shell } from "lucide-react"; +import { Input } from "@/components/ui/Input" +import { Label } from "@/components/ui/Label" +import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; +import { ErrorDialog } from "./ErrorDialog"; +import { NostrProfile } from "./NostrProfile"; + + +// TODO: Double-check this regex and make it more accurate +const NPUB_REGEX = /^npub[0-9A-Za-z]+$/ + +export function NostrUserInput(props: { pubkey: string | null, setPubkey: (pubkey: string | null) => void, onProfileChange: (profile: Profile | undefined | null) => void, disabled?: boolean | undefined, profileHeader?: React.ReactNode, profileFooter?: React.ReactNode }) { + const intl = useIntl() + const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay + const [error, setError] = useState<string | null>(null) // An error message to display to the user + const [npubValidationError, setNpubValidationError] = useState<string | null>(null) + const [npub, setNpub] = useState<string>("") + + // MARK: - Functions + + const fetchProfile = async () => { + if (!props.pubkey) { + return + } + try { + const profile = await getProfile(props.pubkey) + setProfile(profile) + } + catch (e) { + console.error(e) + setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") + } + } + + // MARK: - Effects and hooks + + // Load the profile when the pubkey changes + useEffect(() => { + if (props.pubkey) { + fetchProfile() + } + }, [props.pubkey]) + + useEffect(() => { + if (npub.length > 0 && !NPUB_REGEX.test(npub)) { + setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) + setProfile(undefined) + } + else { + setNpubValidationError(null) + if (npub.length > 0) { + try { + const decoded = nip19.decode(npub) + props.setPubkey(decoded.data as string) + } + catch (e) { + props.setPubkey(null) + setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) + } + } + else { + setProfile(undefined) + props.setPubkey(null) + } + } + }, [npub]) + + // MARK: - Render + + return (<> + <ErrorDialog error={error} setError={setError} /> + <Label htmlFor="npub" className="text-purple-200/70 font-normal"> + {intl.formatMessage({ id: "purple.login.npub-label", defaultMessage: "Please enter your public key (npub) below" })} + </Label> + <Input id="npub" placeholder={intl.formatMessage({ id: "purple.login.npub-placeholder", defaultMessage: "npub…" })} type="text" className="mt-2" value={npub} onChange={(e) => setNpub(e.target.value)} required disabled={props.disabled} /> + {npubValidationError && + <Label htmlFor="npub" className="text-red-500 font-normal"> + {npubValidationError} + </Label> + } + {(profile === undefined && props.pubkey && props.pubkey.length > 0) && ( + <div className="mt-2 flex items-center justify-center"> + <Loader2 className="mr-2 animate-spin text-purple-200/90" size={16} /> + <div className="text-purple-200/70 font-normal text-sm"> + {intl.formatMessage({ id: "purple.login.fetching-profile", defaultMessage: "Fetching profile info…" })} + </div> + </div> + )} + {profile && (<> + <NostrProfile + profile={profile} + profileHeader={props.profileHeader} + profileFooter={props.profileFooter} + /> + </>)} + </>) +} diff --git a/src/components/OTPAuth.tsx b/src/components/OTPAuth.tsx @@ -0,0 +1,124 @@ +import { Button } from "./ui/Button"; +import { useIntl } from "react-intl"; +import { useEffect, useState } from "react"; +import { Info, Loader2, Mail } from "lucide-react"; +import { InputOTP6Digits } from "@/components/ui/InputOTP"; +import { ErrorDialog } from "./ErrorDialog"; +import { useTimeout } from "usehooks-ts"; +import CopiableUrl from "./ui/CopiableUrl"; + +export interface OTPAuthProps { + pubkey: string | null + verifyOTP: (otp: string) => void + sendOTP: () => void + otpVerified: boolean + setOTPVerified: (verified: boolean) => void + otpInvalid: boolean + setOTPInvalid: (invalid: boolean) => void + setError?: (error: string) => void + disabled?: boolean +} + +export function OTPAuth(props: OTPAuthProps) { + const intl = useIntl() + const { setError } = props + const [otp, setOTP] = useState<string>("") + const [showTroubleshootingMessage, setShowTroubleshootingMessage] = useState<boolean>(false) + useTimeout(() => setShowTroubleshootingMessage(true), otp.length == 0 ? 10000 : null) + + // MARK: - Functions + + const completeOTP = async () => { + if (!otp) { + return + } + props.verifyOTP(otp) + } + + // MARK: - Effects and hooks + + useEffect(() => { + setOTP("") + }, [props.pubkey]) + + useEffect(() => { + if (otp.length != 6) { + props.setOTPInvalid(false) + props.setOTPVerified(false) + } + if(otp.length > 0) { + setShowTroubleshootingMessage(false) + } + }, [otp]) + + // MARK: - Render + + return (<> + <div className="text-purple-200/50 font-normal flex items-center gap-2 rounded-full px-6 py-2 justify-center mt-2 mb-2"> + <div className="flex flex-col items-center"> + <Mail className="w-10 h-10 shrink-0 text-purple-100 mb-3" /> + <div className="text-purple-200/90 font-semibold text-md whitespace-pre-line text-center"> + {intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "We sent you a code via a Nostr direct message.\n Please enter it below" })} + </div> + </div> + </div> + <div className="mx-auto flex justify-center mb-4"> + <InputOTP6Digits value={otp} onChange={setOTP} onComplete={() => completeOTP()} disabled={props.disabled} /> + </div> + {!props.otpVerified && !props.otpInvalid && otp.length >= 6 && ( + <div className="flex flex-col items-center justify-center w-full mb-2"> + <div className="text-purple-200/50 font-normal text-sm flex items-center"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + Loading... + </div> + </div> + )} + {props.otpInvalid && (<div className="my-4 w-full flex flex-col gap-2"> + <div className="text-red-500 font-normal text-sm text-center"> + {intl.formatMessage({ id: "purple.login.otp-invalid", defaultMessage: "Invalid or expired OTP. Please try again." })} + </div> + <Button variant="default" className="w-full" onClick={() => { + props.sendOTP() + props.setOTPInvalid(false) + setOTP("") + }}>Resend OTP</Button> + </div>)} + {showTroubleshootingMessage ? ( + <InfoLabel + heading={intl.formatMessage({ id: "purple.otp.troubleshooting.heading", defaultMessage: "Didn't receive the OTP?" })} + message={<> + <p className="mb-3"> + {intl.formatMessage({ id: "purple.otp.troubleshooting.message", defaultMessage: "If you don't see the OTP code, try the following steps:" })} + </p> + <ol className="list-decimal list-inside text-xs space-y-1"> + <li>{intl.formatMessage({ id: "purple.otp.troubleshooting.step1", defaultMessage: "Check your DM request tab (if you are on Damus)" })}</li> + <li>{intl.formatMessage({ id: "purple.otp.troubleshooting.step2", defaultMessage: "Ensure your Nostr client is connected to our relay listed below:" })}</li> + </ol> + <CopiableUrl url={"wss://relay.damus.io"} className="mt-2"/> + </>} + /> + ) : + <InfoLabel + heading={intl.formatMessage({ id: "purple.login.stay-safe.title", defaultMessage: "Stay safe" })} + message={intl.formatMessage({ id: "purple.login.stay-safe.message", defaultMessage: "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone." })} + /> + } + </>) +} + + +function InfoLabel({ heading, message }: { heading: string, message: React.ReactNode }) { + return ( + <div className="text-purple-200/70 text-normal text-left font-semibold flex flex-col md:flex-row gap-3 rounded-lg bg-purple-200/10 p-3 items-center md:items-start"> + <Info className="w-6 h-6 shrink-0 mt-0 md:mt-1" /> + <div className="flex flex-col text-center md:text-left"> + <span className="text-normal md:text-lg mb-2"> + {heading} + </span> + <span className="text-xs text-purple-200/50 whitespace-pre-line"> + {message} + </span> + </div> + </div> + ) +} diff --git a/src/components/pages/purple-welcome.tsx b/src/components/pages/purple-welcome.tsx @@ -0,0 +1,30 @@ +import Head from "next/head"; +import { PurpleWelcome } from "../sections/PurpleWelcome"; +import { useIntl } from "react-intl"; +import { useEffect, useState } from "react"; +import { ErrorDialog } from "../ErrorDialog"; +import { usePurpleLoginSession } from "@/hooks/usePurpleLoginSession"; + + +export function PurpleWelcomePage() { + const intl = useIntl() + const [error, setError] = useState<string | null>(null) + const { accountInfo: existingAccountInfo, logout } = usePurpleLoginSession(setError) + + useEffect(() => { + if (existingAccountInfo === null) { + // Redirect to the login page + window.location.href = "/purple/login?redirect=" + encodeURIComponent("/purple/welcome") + } + }, [existingAccountInfo]) + + return (<> + <Head> + <title>Welcome to Damus Purple</title> + </Head> + <main style={{ scrollBehavior: "smooth" }}> + <ErrorDialog error={error} setError={setError} /> + {existingAccountInfo && <PurpleWelcome/>} + </main> + </>) +} diff --git a/src/components/sections/FinalCTA.tsx b/src/components/sections/FinalCTA.tsx @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"; import Image from "next/image"; import { Ticker, TickerImage } from "../ui/Ticker"; import { ArrowUpRight, MessageCircleIcon, GitBranch, Github } from "lucide-react"; -import { DAMUS_APP_STORE_URL, DAMUS_MERCH_STORE_URL, DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; +import { DAMUS_APP_STORE_URL } from "@/lib/constants"; import { MeshGradient4 } from "../effects/MeshGradient.4"; import { GithubIcon } from "../icons/GithubIcon"; @@ -50,9 +50,9 @@ export function FinalCTA({ className }: { className?: string }) { <ArrowUpRight className="ml-2" /> </Button> </Link> - <Link href={DAMUS_TESTFLIGHT_URL} target="_blank"> + <Link href={"/notedeck"} target="_blank"> <Button variant="link" className="w-full md:w-auto"> - { intl.formatMessage({ id: "home.hero.join_testflight", defaultMessage: "Join TestFlight Beta" }) } + { intl.formatMessage({ id: "home.hero.check-out-notedeck", defaultMessage: "Check Notedeck, our cross-platform client" }) } <ArrowUpRight className="text-damuspink-600 ml-2"/> </Button> </Link> diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx @@ -5,7 +5,7 @@ import { Button } from "../ui/Button"; import Image from "next/image" import { FormattedMessage, useIntl } from "react-intl"; import Link from "next/link"; -import { DAMUS_APP_STORE_URL, DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; +import { DAMUS_APP_STORE_URL } from "@/lib/constants"; import { motion } from "framer-motion"; import { NostrIcon } from "../icons/NostrIcon"; @@ -65,9 +65,9 @@ export function Hero() { <ArrowUpRight className="ml-2" /> </Button> </Link> - <Link href={DAMUS_TESTFLIGHT_URL} target="_blank"> + <Link href={"/notedeck"} target="_blank"> <Button variant="link" className="w-full md:w-auto"> - { intl.formatMessage({ id: "home.hero.join_testflight", defaultMessage: "Join TestFlight Beta" }) } + { intl.formatMessage({ id: "home.hero.check-out-notedeck", defaultMessage: "Check Notedeck, our cross-platform client" }) } <ArrowUpRight className="text-damuspink-600 ml-2"/> </Button> </Link> @@ -113,4 +113,4 @@ export function Hero() { </div> </div> </>) -}- \ No newline at end of file +} diff --git a/src/components/sections/Notedeck/NotedeckHero.tsx b/src/components/sections/Notedeck/NotedeckHero.tsx @@ -8,6 +8,7 @@ import { motion } from "framer-motion"; import Image from "next/image"; import StarField from "@/components/effects/StarField"; import { useScroll, useTransform } from "framer-motion"; +import { NOTEDECK_WAITLIST_URL } from "@/lib/constants"; export function NotedeckHero({ className }: { className?: string }) { const intl = useIntl() @@ -30,7 +31,16 @@ export function NotedeckHero({ className }: { className?: string }) { style={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 1.5, duration: 1 } }} > - <TopMenu className="w-full z-10" /> + <TopMenu + className="w-full z-10" + customCTA={ + <Link href={NOTEDECK_WAITLIST_URL}> + <Button variant="accent" className="w-full"> + {intl.formatMessage({ id: "notedeck.hero.menu.signup-for-the-waitlist", defaultMessage: "Sign up" })} + </Button> + </Link> + } + /> </motion.div> <motion.div className="flex flex-col items-center justify-center h-full grow mt-48 z-10" @@ -53,7 +63,7 @@ export function NotedeckHero({ className }: { className?: string }) { style={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 1.5, duration: 1 } }} > - <Link href="#tally-open=npVXbJ&tally-layout=modal&tally-align-left=1&tally-hide-title=1&tally-emoji-text=🚀&tally-emoji-animation=none&tally-auto-close=68000" className="w-full md:w-auto"> + <Link href={NOTEDECK_WAITLIST_URL} className="w-full md:w-auto"> <Button variant="default" className="w-full"> {intl.formatMessage({ id: "notedeck.hero.signup-for-the-waitlist", defaultMessage: "Sign up for the waitlist" })} </Button> diff --git a/src/components/sections/Notedeck/WaitlistForm.tsx b/src/components/sections/Notedeck/WaitlistForm.tsx @@ -11,6 +11,7 @@ import { MeshGradient5 } from "@/components/effects/MeshGradient.5"; import { MeshGradient4 } from "@/components/effects/MeshGradient.4"; import { cn } from "@/lib/utils"; import { motion } from "framer-motion"; +import { NOTEDECK_WAITLIST_URL } from "@/lib/constants"; export function NotedeckWaitlistForm({ className }: { className?: string }) { const intl = useIntl() @@ -36,7 +37,7 @@ export function NotedeckWaitlistForm({ className }: { className?: string }) { style={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 1.5, duration: 1 } }} > - <Link href="#tally-open=npVXbJ&tally-layout=modal&tally-align-left=1&tally-hide-title=1&tally-emoji-text=🚀&tally-emoji-animation=none&tally-auto-close=68000" className="w-full md:w-auto"> + <Link href={NOTEDECK_WAITLIST_URL} className="w-full md:w-auto"> <Button variant="default" className="w-full"> {intl.formatMessage({ id: "notedeck.hero.signup-for-the-waitlist", defaultMessage: "Sign up for the waitlist" })} </Button> @@ -47,4 +48,4 @@ export function NotedeckWaitlistForm({ className }: { className?: string }) { </div> </div> </>) -}- \ No newline at end of file +} diff --git a/src/components/sections/PurpleAccount.tsx b/src/components/sections/PurpleAccount.tsx @@ -8,13 +8,13 @@ import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/ import { useLocalStorage } from "usehooks-ts"; import { ErrorDialog } from "../ErrorDialog"; import { PurpleLayout } from "../PurpleLayout"; +import { usePurpleLoginSession } from "@/hooks/usePurpleLoginSession"; export function PurpleAccount() { const intl = useIntl() - const [sessionToken, setSessionToken] = useLocalStorage('session_token', null) - const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server const [error, setError] = useState<string | null>(null) + const { accountInfo: loggedInAccountInfo, logout } = usePurpleLoginSession(setError) const [profile, setProfile] = useState<Profile | null>(null) const [pubkey, setPubkey] = useState<string | null>(null) @@ -33,31 +33,7 @@ export function PurpleAccount() { setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") } } - - const fetchAccountInfo = async () => { - try { - const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/sessions/account", { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + sessionToken - }, - }) - if (!response.ok) { - setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") - return - } - const accountInfo = await response.json() - console.log(accountInfo) - setExistingAccountInfo(accountInfo) - setPubkey(accountInfo.pubkey) - } - catch (e) { - console.error(e) - setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") - } - } - + // MARK: - Effects and hooks // Load the profile when the pubkey changes @@ -68,14 +44,14 @@ export function PurpleAccount() { }, [pubkey]) useEffect(() => { - if (sessionToken) { - fetchAccountInfo() + if (loggedInAccountInfo) { + setPubkey(loggedInAccountInfo.pubkey) } - else if (sessionToken === null) { + else if (loggedInAccountInfo === null) { // Redirect to the login page window.location.href = "/purple/login?redirect=" + encodeURIComponent("/purple/account") } - }, [sessionToken]) + }, [loggedInAccountInfo]) // MARK: - Render @@ -92,10 +68,10 @@ export function PurpleAccount() { </div> <div className="text-purple-200/70 font-normal text-xs flex gap-1"> <Star strokeWidth={1.75} className="w-4 h-4 shrink-0 text-amber-400" fill="currentColor" /> - {existingAccountInfo?.created_at && unixTimestampToDateString(existingAccountInfo?.created_at)} + {loggedInAccountInfo?.created_at && unixTimestampToDateString(loggedInAccountInfo?.created_at)} </div> </div> - {existingAccountInfo?.active ? ( + {loggedInAccountInfo?.active ? ( <div className="flex gap-1 bg-gradient-to-r from-damuspink-500 to-damuspink-600 rounded-full px-3 py-1 items-center mt-3 mb-6"> <div className="w-4 h-4 rounded-full bg-white flex justify-center items-center"> <Check className="w-2 h-2 shrink-0 text-damuspink-500" strokeWidth={5} /> @@ -116,17 +92,17 @@ export function PurpleAccount() { )} </div> <div className="flex flex-col bg-purple-50/10 rounded-xl px-4 text-purple-50 w-full"> - <AccountInfoRow label={intl.formatMessage({ id: "purple.account.expiry-date", defaultMessage: "Expiry date" })} value={(existingAccountInfo?.expiry && unixTimestampToDateString(existingAccountInfo?.expiry)) || "N/A"} /> - <AccountInfoRow label={intl.formatMessage({ id: "purple.account.account-creation", defaultMessage: "Account creation" })} value={(existingAccountInfo?.created_at && unixTimestampToDateString(existingAccountInfo?.created_at)) || "N/A"} /> - <AccountInfoRow label={intl.formatMessage({ id: "purple.account.subscriber-number", defaultMessage: "Subscriber number" })} value={(existingAccountInfo?.subscriber_number && "#" + existingAccountInfo?.subscriber_number) || "N/A"} last={existingAccountInfo?.testflight_url ? false : true} /> - {existingAccountInfo?.testflight_url && <Link href={existingAccountInfo?.testflight_url} target="_blank"> + <AccountInfoRow label={intl.formatMessage({ id: "purple.account.expiry-date", defaultMessage: "Expiry date" })} value={(loggedInAccountInfo?.expiry && unixTimestampToDateString(loggedInAccountInfo?.expiry)) || "N/A"} /> + <AccountInfoRow label={intl.formatMessage({ id: "purple.account.account-creation", defaultMessage: "Account creation" })} value={(loggedInAccountInfo?.created_at && unixTimestampToDateString(loggedInAccountInfo?.created_at)) || "N/A"} /> + <AccountInfoRow label={intl.formatMessage({ id: "purple.account.subscriber-number", defaultMessage: "Subscriber number" })} value={(loggedInAccountInfo?.subscriber_number && "#" + loggedInAccountInfo?.subscriber_number) || "N/A"} last={loggedInAccountInfo?.testflight_url ? false : true} /> + {loggedInAccountInfo?.testflight_url && <Link href={loggedInAccountInfo?.testflight_url} target="_blank"> <Button variant="link" className="w-full text-left my-2"> <ArrowUpRight className="text-damuspink-600 mr-2" /> {intl.formatMessage({ id: "purple.account.testflight-link", defaultMessage: "Join TestFlight" })} </Button> </Link>} </div> - <Button className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4 text-sm" onClick={() => setSessionToken(null)} variant="link"> + <Button className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4 text-sm" onClick={() => logout()} variant="link"> <LogOut className="text-damuspink-600 mr-2" /> {intl.formatMessage({ id: "purple.account.sign-out", defaultMessage: "Sign out" })} </Button> diff --git a/src/components/sections/PurpleBanner.tsx b/src/components/sections/PurpleBanner.tsx @@ -4,7 +4,6 @@ import { TopMenu } from "./TopMenu"; import { Button } from "../ui/Button"; import { FormattedMessage, useIntl } from "react-intl"; import Link from "next/link"; -import { DAMUS_APP_STORE_URL, DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; import { motion } from "framer-motion"; import Image from "next/image"; import { PurpleIcon } from "../icons/PurpleIcon"; diff --git a/src/components/sections/PurpleCheckout.tsx b/src/components/sections/PurpleCheckout.tsx @@ -1,100 +1,26 @@ -import { ArrowLeft, ArrowUpRight, CheckCircle, ChevronRight, Copy, Globe2, Loader2, LucideZapOff, Sparkles, Zap, ZapIcon, ZapOff } from "lucide-react"; -import { Button } from "../ui/Button"; -import { FormattedMessage, useIntl } from "react-intl"; -import Link from "next/link"; -import { motion } from "framer-motion"; -import Image from "next/image"; -import { PurpleIcon } from "../icons/PurpleIcon"; -import { RoundedContainerWithGradientBorder } from "../ui/RoundedContainerWithGradientBorder"; -import { MeshGradient5 } from "../effects/MeshGradient.5"; -import { useEffect, useRef, useState } from "react"; -import { NostrEvent, Relay, nip19 } from "nostr-tools" -import { QRCodeSVG } from 'qrcode.react'; +import { useIntl } from "react-intl"; +import { useEffect, useState } from "react"; import { useInterval } from 'usehooks-ts' -import Lnmessage from 'lnmessage' -import { DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/AlertDialog"; import { Info } from "lucide-react"; -import { ErrorDialog } from "../ErrorDialog"; import { PurpleLayout } from "../PurpleLayout"; -import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; +import { AccountInfo } from "@/utils/PurpleUtils"; +import { Step1ProductSelection } from "./PurpleCheckoutDetails/Step1ProductSelection"; +import { LNCheckout } from "./PurpleCheckoutDetails/Types"; +import { Step2UserVerification } from "./PurpleCheckoutDetails/Step2UserVerification"; +import { Step3Payment } from "./PurpleCheckoutDetails/Step3Payment"; +import { PurpleCheckoutErrorDialog } from "./PurpleCheckoutDetails/ErrorDialog"; +import { CheckoutSuccess } from "./PurpleCheckoutDetails/CheckoutSuccess"; export function PurpleCheckout() { const intl = useIntl() const [lnCheckout, setLNCheckout] = useState<LNCheckout | null>(null) // The checkout object from the server - const [productTemplates, setProductTemplates] = useState<ProductTemplates | null>(null) // The different product options - const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified - const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay - const [continueShowQRCodes, setContinueShowQRCodes] = useState<boolean>(false) // Whether the user wants to show a QR code for the final step - const [lnInvoicePaid, setLNInvoicePaid] = useState<boolean | undefined>(undefined) // Whether the ln invoice has been paid - const [waitingForInvoice, setWaitingForInvoice] = useState<boolean>(false) // Whether we are waiting for a response from the LN node about the invoice const [error, setError] = useState<string | null>(null) // An error message to display to the user + const [selectedAuthMethod, setSelectedAuthMethod] = useState<string | "nostr-dm" | "damus-ios">("nostr-dm") const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server - const [lnConnectionRetryCount, setLnConnectionRetryCount] = useState<number>(0) // The number of times we have tried to connect to the LN node - const lnConnectionRetryLimit = 5 // The maximum number of times we will try to connect to the LN node before displaying an error - const [lnWaitinvoiceRetryCount, setLnWaitinvoiceRetryCount] = useState<number>(0) // The number of times we have tried to check the invoice status - const lnWaitinvoiceRetryLimit = 5 // The maximum number of times we will try to check the invoice status before displaying an error - // MARK: - Functions - const fetchProfile = async () => { - if (!pubkey) { - return - } - try { - const profile = await getProfile(pubkey) - setProfile(profile) - } - catch (e) { - console.error(e) - setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") - } - } - - const fetchAccountInfo = async () => { - if (!pubkey) { - setExistingAccountInfo(undefined) - return - } - try { - const accountInfo = await getPurpleAccountInfo(pubkey) - setExistingAccountInfo(accountInfo) - } - catch (e) { - console.error(e) - setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") - } - } - - const fetchProductTemplates = async () => { - try { - const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/products", { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - }, - }) - const data = await response.json() - setProductTemplates(data) - } - catch (e) { - console.error(e) - setError("Failed to get product list from our servers, please try again later in a few minutes. If the problem persists, please contact support.") - } - } - const refreshLNCheckout = async (id?: string) => { if (!lnCheckout && !id) { return @@ -115,100 +41,6 @@ export function PurpleCheckout() { } } - const selectProduct = async (productTemplateName: string) => { - try { - const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout", { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ product_template_name: productTemplateName }) - }) - const data: LNCheckout = await response.json() - setLNCheckout(data) - } - catch (e) { - console.error(e) - setError("Failed to begin the checkout process. Please wait a few minutes, refresh this page, and try again. If the problem persists, please contact support.") - } - } - - const checkLNInvoice = async () => { - console.log("Checking LN invoice...") - if (!lnCheckout?.invoice?.bolt11) { - return - } - let ln = null - try { - ln = new Lnmessage({ - // The public key of the node you would like to connect to - remoteNodePublicKey: lnCheckout.invoice.connection_params.nodeid, - // The websocket proxy address of the node - wsProxy: `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}`, - // The IP address of the node - ip: lnCheckout.invoice.connection_params.address, - // Protocol to use when connecting to the node - wsProtocol: 'wss:', - port: 9735, - }) - // TODO: This is a workaround due to a limitation in LNMessage URL formatting: (https://github.com/aaronbarnardsound/lnmessage/issues/52) - ln.wsUrl = `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}/${lnCheckout.invoice.connection_params.address}` - await ln.connect() - setWaitingForInvoice(true) // Indicate that we are waiting for a response from the LN node - } - catch (e) { - console.error(e) - if (lnConnectionRetryCount >= lnConnectionRetryLimit) { - setError("Failed to connect to the Lightning node. Please refresh this page, and try again in a few minutes. If the problem persists, please contact support.") - } - else { - setLnConnectionRetryCount(lnConnectionRetryCount + 1) - } - return - } - - try { - if (!ln) { return } - const res: any = await ln.commando({ - method: 'waitinvoice', - params: { label: lnCheckout.invoice.label }, - rune: lnCheckout.invoice.connection_params.rune, - }) - setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node - setLNInvoicePaid(!res.error) - if (res.error) { - console.error(res.error) - setError("The lightning payment failed. If you haven't paid yet, please start a new checkout from the beginning and try again. If you have already paid, please copy the reference ID shown below and contact support.") - } - } catch (e) { - setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node - console.error(e) - if (lnWaitinvoiceRetryCount >= lnWaitinvoiceRetryLimit) { - setError("There was an error checking the lightning payment status. If you haven't paid yet, please wait a few minutes, refresh the page, and try again. If you have already paid, please copy the reference ID shown below and contact support.") - } - else { - setLnWaitinvoiceRetryCount(lnWaitinvoiceRetryCount + 1) - } - } - } - - const tellServerToCheckLNInvoice = async () => { - try { - const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + lnCheckout?.id + "/check-invoice", { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - }) - const data: LNCheckout = await response.json() - setLNCheckout(data) - } - catch (e) { - console.error(e) - setError("Failed to finalize checkout. Please try refreshing the page. If the error persists, please copy the reference ID shown below and contact support.") - } - } - const pollState = async () => { if (!lnCheckout) { return @@ -216,22 +48,14 @@ export function PurpleCheckout() { if (!lnCheckout.verified_pubkey) { refreshLNCheckout() } - else if (!lnCheckout.invoice?.paid && !waitingForInvoice) { - checkLNInvoice() - } } - // MARK: - Effects and hooks // Keep checking the state of things when needed useInterval(pollState, 1000) useEffect(() => { - if (lnCheckout && lnCheckout.verified_pubkey) { - // Load the profile if the user has verified their pubkey - setPubkey(lnCheckout.verified_pubkey) - } // Set the query parameter on the URL to be the lnCheckout ID to avoid losing it on page refresh if (lnCheckout) { const url = new URL(window.location.href) @@ -240,17 +64,8 @@ export function PurpleCheckout() { } }, [lnCheckout]) - // Load the profile when the pubkey changes + // Load the LN checkout (if there is one) on page load useEffect(() => { - if (pubkey) { - fetchProfile() - fetchAccountInfo() - } - }, [pubkey]) - - // Load the products and the LN checkout (if there is one) on page load - useEffect(() => { - fetchProductTemplates() // Check if there is a lnCheckout ID in the URL query parameters. If so, fetch the lnCheckout const url = new URL(window.location.href) const id = url.searchParams.get("id") @@ -260,34 +75,11 @@ export function PurpleCheckout() { } }, []) - // Tell server to check the invoice as soon as we notice it has been paid - useEffect(() => { - if (lnInvoicePaid === true) { - tellServerToCheckLNInvoice() - } - }, [lnInvoicePaid]) - // MARK: - Render return (<> - <ErrorDialog error={error} setError={setError}> - {lnCheckout && lnCheckout.id && ( - <div className="flex items-center justify-between rounded-md bg-gray-200"> - <div className="text-xs text-gray-400 font-normal px-4 py-2"> - Reference: - </div> - <div className="w-full text-xs text-gray-500 font-normal px-4 py-2 overflow-x-scroll"> - {lnCheckout?.id} - </div> - <button - className="text-sm text-gray-500 font-normal px-4 py-2 active:text-gray-500/30 hover:text-gray-500/80 transition" - onClick={() => navigator.clipboard.writeText(lnCheckout?.id || "")} - > - <Copy /> - </button> - </div> - )} - </ErrorDialog> + <PurpleCheckoutErrorDialog lnCheckout={lnCheckout} error={error} setError={setError} /> + <PurpleLayout> <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-2"> {intl.formatMessage({ id: "purple.checkout.title", defaultMessage: "Checkout" })} @@ -303,208 +95,36 @@ export function PurpleCheckout() { </span> </div> </div> - <StepHeader - stepNumber={1} - title={intl.formatMessage({ id: "purple.checkout.step-1", defaultMessage: "Choose your plan" })} - done={lnCheckout?.product_template_name != null} - active={true} + + <Step1ProductSelection + lnCheckout={lnCheckout} + setLNCheckout={setLNCheckout} + setError={setError} /> - <div className="mt-3 mb-4 flex gap-2 items-center"> - {productTemplates ? Object.entries(productTemplates).map(([name, productTemplate]) => ( - <button - key={name} - className={`relative flex flex-col items-center justify-center p-3 pt-4 border rounded-lg ${name == lnCheckout?.product_template_name ? "border-green-500" : "border-purple-200/50"} disabled:opacity-50 disabled:cursor-not-allowed`} - onClick={() => selectProduct(name)} - disabled={lnCheckout?.verified_pubkey != null} - > - {productTemplate.special_label && ( - <div className="absolute top-0 right-0 -mt-4 -mr-2 bg-gradient-to-r from-damuspink-500 to-damuspink-600 rounded-full p-1 px-3"> - <div className="text-white text-xs font-semibold"> - {productTemplate.special_label} - </div> - </div> - )} - - <div className="text-purple-200/50 font-normal text-sm"> - {productTemplate.description} - </div> - <div className="mt-1 text-purple-100/90 font-semibold text-lg"> - {productTemplate.amount_msat / 1000} sats - </div> - </button> - )) : ( - <div className="flex flex-col items-center justify-center"> - <div className="text-purple-200/50 font-normal text-sm flex items-center"> - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - Loading... - </div> - </div> - )} - </div> - <StepHeader - stepNumber={2} - title={intl.formatMessage({ id: "purple.checkout.step-2", defaultMessage: "Verify your npub" })} - done={lnCheckout?.verified_pubkey != null} - active={lnCheckout?.product_template_name != null} + + <Step2UserVerification + lnCheckout={lnCheckout} + setLNCheckout={setLNCheckout} + selectedAuthMethod={selectedAuthMethod} + setSelectedAuthMethod={setSelectedAuthMethod} + setError={setError} /> - {lnCheckout && !lnCheckout.verified_pubkey && <> - <QRCodeSVG value={"damus:purple:verify?id=" + lnCheckout.id} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" /> - <Link href={"damus:purple:verify?id=" + lnCheckout.id} className="w-full md:w-auto opacity-70 hover:opacity-100 transition"> - <Button variant="link" className="w-full text-sm"> - {intl.formatMessage({ id: "purple.checkout.open-in-app", defaultMessage: "Open in Damus" })} - </Button> - </Link> - <div className="text-white/40 text-xs text-center mt-4 mb-6"> - {/* TODO: Localize later */} - Issues with this step? Please ensure you are running the latest Damus iOS version from <Link href={DAMUS_TESTFLIGHT_URL} className="text-damuspink-500 underline" target="_blank">TestFlight</Link> — or <Link href="mailto:support@damus.io" className="text-damuspink-500 underline">contact us</Link> - </div> - </> - } - {profile && - <div className="mt-2 mb-4 flex flex-col items-center"> - <div className="text-purple-200/50 font-normal text-sm"> - {existingAccountInfo === null || existingAccountInfo === undefined ? <> - {lnCheckout?.verified_pubkey && !lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchasing-for", defaultMessage: "Verified. Purchasing Damus Purple for:" })} - {lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchased-for", defaultMessage: "Purchased Damus Purple for:" })} - </> : <> - {lnCheckout?.verified_pubkey && !lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.renewing-for", defaultMessage: "Verified. Renewing Damus Purple for:" })} - {lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.renewed-for", defaultMessage: "Renewed Damus Purple for:" })} - </>} - </div> - <div className="mt-4 flex flex-col gap-1 items-center justify-center"> - <Image src={profile.picture || "https://robohash.org/" + profile.pubkey} width={64} height={64} className="rounded-full" alt={profile.name} /> - <div className="text-purple-100/90 font-semibold text-lg"> - {profile.name} - </div> - {existingAccountInfo !== null && existingAccountInfo !== undefined && ( - <div className="text-purple-200/50 font-normal flex items-center gap-2 bg-purple-300/10 rounded-full px-6 py-2 justify-center"> - <Sparkles className="w-4 h-4 shrink-0 text-purple-50" /> - <div className="flex flex-col"> - <div className="text-purple-200/90 font-semibold text-sm"> - {intl.formatMessage({ id: "purple-checkout.this-account-exists", defaultMessage: "Yay! We found your account" })} - </div> - <div className="text-purple-200/70 font-normal text-xs break-normal"> - {intl.formatMessage({ id: "purple-checkout.account-will-renew", defaultMessage: "Paying will renew or extend your membership." })} - </div> - </div> - </div> - )} - </div> - </div> - } - <StepHeader - stepNumber={3} - title={intl.formatMessage({ id: "purple.checkout.step-3", defaultMessage: "Lightning payment" })} - done={lnCheckout?.invoice?.paid === true} - active={lnCheckout?.verified_pubkey != null} + + <Step3Payment + lnCheckout={lnCheckout} + setLNCheckout={setLNCheckout} + setError={setError} + successView={<> + {lnCheckout && + <CheckoutSuccess + lnCheckout={lnCheckout} + existingAccountInfo={existingAccountInfo} + selectedAuthMethod={selectedAuthMethod} + setError={setError} + /> + } + </>} /> - {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && - <> - <QRCodeSVG value={"lightning:" + lnCheckout.invoice.bolt11} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6 border-[5px] border-white bg-white" /> - {/* Shows the bolt11 in for copy-paste with a copy and paste button */} - <div className="flex items-center justify-between rounded-md bg-purple-200/20"> - <div className="w-full text-sm text-purple-200/50 font-normal px-4 py-2 overflow-x-scroll"> - {lnCheckout.invoice.bolt11} - </div> - <button - className="text-sm text-purple-200/50 font-normal px-4 py-2 active:text-purple-200/30 hover:text-purple-200/80 transition" - onClick={() => navigator.clipboard.writeText(lnCheckout?.invoice?.bolt11 || "")} - > - <Copy /> - </button> - </div> - <Link href={"lightning:" + lnCheckout.invoice.bolt11} className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4"> - <Button variant="link" className="w-full text-sm"> - {intl.formatMessage({ id: "purple.checkout.open-in-wallet", defaultMessage: "Open in wallet" })} - <ArrowUpRight className="text-damuspink-600 ml-2" /> - </Button> - </Link> - <div className="mt-6 text-purple-200/50 font-normal text-sm text-center flex justify-center"> - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} - </div> - </> - } - {/* We use the lnCheckout object to check payment status (NOT lnInvoicePaid) to display the confirmation message, because the server is the ultimate source of truth */} - {lnCheckout?.invoice?.paid && lnCheckout?.completed && ( - <div className="flex flex-col items-center justify-center gap-3 mt-6"> - <CheckCircle className="w-16 h-16 text-green-500" /> - <div className="mt-3 mb-6 text-sm text-center text-green-500 font-bold"> - {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} - </div> - <Link - href={existingAccountInfo !== null && existingAccountInfo !== undefined ? `damus:purple:landing` : `damus:purple:welcome?id=${lnCheckout.id}`} - className="w-full text-sm flex justify-center" - > - <Button variant="default" className="w-full text-sm"> - {intl.formatMessage({ id: "purple.checkout.continue", defaultMessage: "Continue in the app" })} - <ChevronRight className="ml-1" /> - </Button> - </Link> - <button className="w-full text-sm text-damuspink-500 flex justify-center" onClick={() => setContinueShowQRCodes(!continueShowQRCodes)}> - {!continueShowQRCodes ? - intl.formatMessage({ id: "purple.checkout.continue.show-qr", defaultMessage: "Show QR code" }) - : intl.formatMessage({ id: "purple.checkout.continue.hide-qr", defaultMessage: "Hide QR code" }) - } - </button> - {continueShowQRCodes && ( - <> - <QRCodeSVG - value={existingAccountInfo !== null && existingAccountInfo !== undefined ? "damus:purple:landing" : "damus:purple:welcome?id=" + lnCheckout.id} - className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" - /> - </> - )} - <div className="text-white/40 text-xs text-center mt-4 mb-6"> - {/* TODO: Localize later */} - Issues with this step? Please ensure you are running the latest Damus iOS version from <Link href={DAMUS_TESTFLIGHT_URL} className="text-damuspink-500 underline" target="_blank">TestFlight</Link> — or <Link href="mailto:support@damus.io" className="text-damuspink-500 underline">contact us</Link> - </div> - </div> - )} </PurpleLayout> </>) } - -// MARK: - Helper components - -function StepHeader({ stepNumber, title, done, active }: { stepNumber: number, title: string, done: boolean, active: boolean }) { - return (<> - <div className={`flex items-center mb-2 ${active ? "" : "opacity-50"}`}> - <div className={`flex items-center justify-center w-8 h-8 rounded-full ${done ? "bg-green-500" : active ? "bg-purple-600" : "bg-gray-500"} text-white text-sm font-semibold`}> - {done ? <CheckCircle className="w-4 h-4" /> : stepNumber} - </div> - <div className="ml-2 text-lg text-purple-200 font-semibold"> - {title} - </div> - </div> - </>) -} - -// MARK: - Types - -interface LNCheckout { - id: string, - verified_pubkey?: string, - product_template_name?: string, - invoice?: { - bolt11: string, - paid?: boolean, - label: string, - connection_params: { - nodeid: string, - address: string, - rune: string, - ws_proxy_address: string, - } - } - completed: boolean, -} - -interface ProductTemplate { - description: string, - special_label?: string | null, - amount_msat: number, - expiry: number, -} - -type ProductTemplates = Record<string, ProductTemplate> diff --git a/src/components/sections/PurpleCheckoutDetails/AccountExistsNote.tsx b/src/components/sections/PurpleCheckoutDetails/AccountExistsNote.tsx @@ -0,0 +1,23 @@ +import { AccountInfo } from "@/utils/PurpleUtils"; +import { Sparkles } from "lucide-react"; +import { useIntl } from "react-intl"; + +export function AccountExistsNoteIfAccountExists({ existingAccountInfo }: { existingAccountInfo: AccountInfo | null | undefined }) { + const intl = useIntl(); + + return (<> + {existingAccountInfo != null && existingAccountInfo != undefined && ( + <div className="text-purple-200/50 font-normal flex items-center gap-2 bg-purple-300/10 rounded-full px-6 py-2 justify-center"> + <Sparkles className="w-4 h-4 shrink-0 text-purple-50" /> + <div className="flex flex-col"> + <div className="text-purple-200/90 font-semibold text-sm"> + {intl.formatMessage({ id: "purple-checkout.this-account-exists", defaultMessage: "Yay! We found your account" })} + </div> + <div className="text-purple-200/70 font-normal text-xs break-normal"> + {intl.formatMessage({ id: "purple-checkout.account-will-renew", defaultMessage: "Paying will renew or extend your membership." })} + </div> + </div> + </div> + )} + </>); +} diff --git a/src/components/sections/PurpleCheckoutDetails/CheckoutSuccess.tsx b/src/components/sections/PurpleCheckoutDetails/CheckoutSuccess.tsx @@ -0,0 +1,70 @@ +import { ChevronRight, Copy } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { useIntl } from "react-intl"; +import Link from "next/link"; +import { useState } from "react"; +import { QRCodeSVG } from 'qrcode.react'; +import { LNCheckout } from "./Types"; +import { AccountInfo } from "@/utils/PurpleUtils"; +import { usePurpleLoginSession } from "@/hooks/usePurpleLoginSession"; + +export interface SuccessViewProps { + existingAccountInfo: AccountInfo | null | undefined + selectedAuthMethod: string + lnCheckout: LNCheckout + setError: (error: string) => void +} + +export function CheckoutSuccess(props: SuccessViewProps) { + const intl = useIntl() + const [continueShowQRCodes, setContinueShowQRCodes] = useState<boolean>(false) // Whether the user wants to show a QR code for the final step + const { existingAccountInfo, selectedAuthMethod, lnCheckout } = props + const { accountInfo: loggedInAccount, logout } = usePurpleLoginSession(props.setError) + + // MARK: - Functions + + // MARK: - Render + + return (<> + {(selectedAuthMethod == "damus-ios" || loggedInAccount == null) ? (<> + <Link + href={existingAccountInfo !== null && existingAccountInfo !== undefined ? `damus:purple:landing` : `damus:purple:welcome?id=${lnCheckout.id}`} + className="w-full text-sm flex justify-center" + > + <Button variant="default" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.continue", defaultMessage: "Continue in the app" })} + <ChevronRight className="ml-1" /> + </Button> + </Link> + <button className="w-full text-sm text-damuspink-500 flex justify-center" onClick={() => setContinueShowQRCodes(!continueShowQRCodes)}> + {!continueShowQRCodes ? + intl.formatMessage({ id: "purple.checkout.continue.show-qr", defaultMessage: "Show QR code" }) + : intl.formatMessage({ id: "purple.checkout.continue.hide-qr", defaultMessage: "Hide QR code" }) + } + </button> + {continueShowQRCodes && ( + <> + <QRCodeSVG + value={existingAccountInfo !== null && existingAccountInfo !== undefined ? "damus:purple:landing" : "damus:purple:welcome?id=" + lnCheckout.id} + className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" + /> + </> + )} + <div className="text-white/40 text-xs text-center mt-4 mb-6"> + {/* TODO: Localize later */} + Issues with this step? Please ensure you are running the latest Damus iOS version — or <Link href="mailto:support@damus.io" className="text-damuspink-500 underline">contact us</Link> + </div> + </>) + : selectedAuthMethod == "nostr-dm" && ( + <Link + href={existingAccountInfo !== null && existingAccountInfo !== undefined ? "/purple/account" : "/purple/welcome"} + className="w-full text-sm flex justify-center" + > + <Button variant="default" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.continue.on-web", defaultMessage: "Continue" })} + <ChevronRight className="ml-1" /> + </Button> + </Link> + )} + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/ErrorDialog.tsx b/src/components/sections/PurpleCheckoutDetails/ErrorDialog.tsx @@ -0,0 +1,34 @@ +import { ErrorDialog } from "@/components/ErrorDialog"; +import { LNCheckout } from "./Types"; +import { Copy } from "lucide-react"; + +export interface PurpleCheckoutErrorDialogProps { + lnCheckout: LNCheckout | null; + error: string | null; + setError: (error: string | null) => void; +} + +export function PurpleCheckoutErrorDialog(props: PurpleCheckoutErrorDialogProps) { + const { lnCheckout, error, setError } = props; + + return ( + <ErrorDialog error={error} setError={setError}> + {lnCheckout && lnCheckout.id && ( + <div className="flex items-center justify-between rounded-md bg-gray-200"> + <div className="text-xs text-gray-400 font-normal px-4 py-2"> + Reference: + </div> + <div className="w-full text-xs text-gray-500 font-normal px-4 py-2 overflow-x-scroll"> + {lnCheckout?.id} + </div> + <button + className="text-sm text-gray-500 font-normal px-4 py-2 active:text-gray-500/30 hover:text-gray-500/80 transition" + onClick={() => navigator.clipboard.writeText(lnCheckout?.id || "")} + > + <Copy /> + </button> + </div> + )} + </ErrorDialog> + ); +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step1ProductSelection.tsx b/src/components/sections/PurpleCheckoutDetails/Step1ProductSelection.tsx @@ -0,0 +1,105 @@ +import { FormattedMessage, useIntl } from "react-intl"; +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { LNCheckout, ProductTemplates } from "./Types"; +import { StepHeader } from "./StepHeader"; +import { Loader2 } from "lucide-react"; + +export interface Step1ProductSelectionProps { + lnCheckout: LNCheckout | null + setLNCheckout: (lnCheckout: LNCheckout) => void + setError: (error: string | null) => void +} + +export function Step1ProductSelection(props: Step1ProductSelectionProps) { + const intl = useIntl() + const [productTemplates, setProductTemplates] = useState<ProductTemplates | null>(null) // The different product options + const isStepDone = props.lnCheckout?.product_template_name != null + + // MARK: - Functions + + const fetchProductTemplates = async () => { + try { + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/products", { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }) + const data = await response.json() + setProductTemplates(data) + } + catch (e) { + console.error(e) + props.setError("Failed to get product list from our servers, please try again later in a few minutes. If the problem persists, please contact support.") + } + } + + const selectProduct = async (productTemplateName: string) => { + try { + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ product_template_name: productTemplateName }) + }) + const data: LNCheckout = await response.json() + props.setLNCheckout(data) + } + catch (e) { + console.error(e) + props.setError("Failed to begin the checkout process. Please wait a few minutes, refresh this page, and try again. If the problem persists, please contact support.") + } + } + + // MARK: - Effects and hooks + + // Load the products and the LN checkout (if there is one) on page load + useEffect(() => { + fetchProductTemplates() + }, []) + + // MARK: - Render + + return (<> + <StepHeader + stepNumber={1} + title={intl.formatMessage({ id: "purple.checkout.step-1", defaultMessage: "Choose your plan" })} + done={isStepDone} + active={true} + /> + <div className="mt-3 mb-4 flex gap-2 items-center"> + {productTemplates ? Object.entries(productTemplates).map(([name, productTemplate]) => ( + <button + key={name} + className={`relative flex flex-col items-center justify-center p-3 pt-4 border rounded-lg ${name == props.lnCheckout?.product_template_name ? "border-green-500" : "border-purple-200/50"} disabled:opacity-50 disabled:cursor-not-allowed`} + onClick={() => selectProduct(name)} + disabled={isStepDone} + > + {productTemplate.special_label && ( + <div className="absolute top-0 right-0 -mt-4 -mr-2 bg-gradient-to-r from-damuspink-500 to-damuspink-600 rounded-full p-1 px-3"> + <div className="text-white text-xs font-semibold"> + {productTemplate.special_label} + </div> + </div> + )} + + <div className="text-purple-200/50 font-normal text-sm"> + {productTemplate.description} + </div> + <div className="mt-1 text-purple-100/90 font-semibold text-lg"> + {productTemplate.amount_msat / 1000} sats + </div> + </button> + )) : ( + <div className="flex flex-col items-center justify-center"> + <div className="text-purple-200/50 font-normal text-sm flex items-center"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + Loading... + </div> + </div> + )} + </div> + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step2DamusIOSVerification.tsx b/src/components/sections/PurpleCheckoutDetails/Step2DamusIOSVerification.tsx @@ -0,0 +1,86 @@ +import { Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { useIntl } from "react-intl"; +import Link from "next/link"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { QRCodeSVG } from 'qrcode.react'; +import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; +import { LNCheckout } from "./Types"; + +export interface Step2DamusIOSVerificationProps { + lnCheckout: LNCheckout + setLNCheckout: (checkout: LNCheckout) => void + pubkey: string | null, + setPubkey: (pubkey: string | null) => void + profile: Profile | undefined | null + setProfile: (profile: Profile | undefined | null) => void + setError: (error: string) => void +} + +export function Step2DamusIOSVerification(props: Step2DamusIOSVerificationProps) { + const intl = useIntl() + const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server + + const step1Done = props.lnCheckout?.product_template_name != null + const step2Done = props.lnCheckout?.verified_pubkey != null + + // MARK: - Functions + + const fetchProfile = async () => { + if (!props.pubkey) { + return + } + try { + const profile = await getProfile(props.pubkey) + props.setProfile(profile) + } + catch (e) { + console.error(e) + props.setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") + } + } + + const fetchAccountInfo = async () => { + if (!props.pubkey) { + setExistingAccountInfo(undefined) + return + } + try { + const accountInfo = await getPurpleAccountInfo(props.pubkey) + setExistingAccountInfo(accountInfo) + } + catch (e) { + console.error(e) + props.setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") + } + } + + // MARK: - Effects and hooks + + // Load the profile when the pubkey changes + useEffect(() => { + if (props.pubkey) { + fetchProfile() + fetchAccountInfo() + } + }, [props.pubkey]) + + // MARK: - Render + + return (<> + {props.lnCheckout && !step2Done && <> + <QRCodeSVG value={"damus:purple:verify?id=" + props.lnCheckout.id} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" /> + <Link href={"damus:purple:verify?id=" + props.lnCheckout.id} className="w-full md:w-auto opacity-70 hover:opacity-100 transition"> + <Button variant="link" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.open-in-app", defaultMessage: "Open in Damus" })} + </Button> + </Link> + <div className="text-white/40 text-xs text-center mt-4 mb-6"> + {/* TODO: Localize later */} + Issues with this step? Please ensure you are running the latest Damus iOS version — or <Link href="mailto:support@damus.io" className="text-damuspink-500 underline">contact us</Link> + </div> + </> + } + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step2OTPVerification.tsx b/src/components/sections/PurpleCheckoutDetails/Step2OTPVerification.tsx @@ -0,0 +1,136 @@ +import { Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { useIntl } from "react-intl"; +import Link from "next/link"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { QRCodeSVG } from 'qrcode.react'; +import { useLocalStorage } from 'usehooks-ts' +import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/Tabs"; +import { NostrUserInput } from "@/components/NostrUserInput"; +import { OTPAuth } from "@/components/OTPAuth"; +import { StepHeader } from "./StepHeader"; +import { LNCheckout } from "./Types"; +import { profile } from "console"; +import React from "react"; +import { AccountExistsNoteIfAccountExists } from "./AccountExistsNote"; + +export interface Step2OTPVerificationProps { + lnCheckout: LNCheckout + setLNCheckout: (checkout: LNCheckout) => void + pubkey: string | null, + setPubkey: (pubkey: string | null) => void + profile: Profile | null | undefined + setProfile: (profile: Profile | null | undefined) => void + existingAccountInfo: AccountInfo | null | undefined + setError: (error: string) => void +} + +export function Step2OTPVerification(props: Step2OTPVerificationProps) { + const intl = useIntl() + const [otpSent, setOTPSent] = useState<boolean>(false) + const [otpVerified, setOTPVerified] = useState<boolean>(false) + const [otpInvalid, setOTPInvalid] = useState<boolean>(false) + const [sessionToken, setSessionToken] = useLocalStorage('session_token', null) + + const step1Done = props.lnCheckout?.product_template_name != null + const step2Done = props.lnCheckout?.verified_pubkey != null + + // MARK: - Functions + + const beginOTPAuth = async() => { + if (!props.pubkey || !props.lnCheckout) { + return + } + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + props.lnCheckout.id + "/request-otp/" + props.pubkey, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + }) + if (!response.ok) { + props.setError("Failed to send OTP. Please try again later.") + return + } + setOTPSent(true) + setOTPInvalid(false) + } + + const validateOTP = async (otp: string) => { + if (!props.pubkey || !props.lnCheckout || !otp) { + return + } + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + props.lnCheckout.id + "/verify-otp/" + props.pubkey, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ otp_code: otp }) + }) + if (response.status != 200 && response.status != 401) { + props.setError("Failed to verify OTP. Please try again later.") + return + } + const json = await response.json() + if (json?.otp?.valid) { + /* + Response format: + { + checkout_object: response.checkout_object, + otp: { valid: true, session_token: session_token } + } + */ + props.setLNCheckout(json.checkout_object) + setSessionToken(json.otp.session_token) + setOTPInvalid(false) + setOTPVerified(true) + } + else { + setOTPInvalid(true) + } + } + + // MARK: - Effects and hooks + + + // MARK: - Render + + return (<> + <NostrUserInput + pubkey={props.pubkey} + setPubkey={props.setPubkey} + onProfileChange={props.setProfile} + profileHeader={ + <div className="text-purple-200/50 font-normal text-sm"> + {props.lnCheckout?.invoice?.bolt11 ? + intl.formatMessage({ id: "purple.checkout.paying-for", defaultMessage: "Purchasing membership for:" }) + : otpSent ? + intl.formatMessage({ id: "purple.checkout.logging-into", defaultMessage: "Logging into:" }) + : intl.formatMessage({ id: "purple.checkout.is-this-you", defaultMessage: "Is this you?" }) + } + </div> + } + profileFooter={<> + <AccountExistsNoteIfAccountExists existingAccountInfo={props.existingAccountInfo}/> + {!otpSent && !step2Done && ( + <Button variant="default" className="w-full mt-2" onClick={() => beginOTPAuth()}>Continue</Button> + )} + </>} + disabled={step2Done} + /> + {otpSent && !step2Done && + <OTPAuth + pubkey={props.pubkey} + verifyOTP={validateOTP} + sendOTP={beginOTPAuth} + otpVerified={otpVerified} + setOTPVerified={setOTPVerified} + otpInvalid={otpInvalid} + setOTPInvalid={setOTPInvalid} + setError={props.setError} + disabled={props.lnCheckout?.invoice?.bolt11 != undefined} + /> + } + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step2UserVerification.tsx b/src/components/sections/PurpleCheckoutDetails/Step2UserVerification.tsx @@ -0,0 +1,143 @@ +import { useIntl } from "react-intl"; +import { useEffect, useState } from "react"; +import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/Tabs"; +import { StepHeader } from "./StepHeader"; +import { LNCheckout } from "./Types"; +import { Step2OTPVerification } from "./Step2OTPVerification"; +import { Step2DamusIOSVerification } from "./Step2DamusIOSVerification"; +import { NostrProfile } from "@/components/NostrProfile"; +import { Sparkles } from "lucide-react"; +import { AccountExistsNoteIfAccountExists } from "./AccountExistsNote"; + +export interface Step2UserVerificationProps { + lnCheckout: LNCheckout | null + setLNCheckout: (checkout: LNCheckout) => void + selectedAuthMethod: string | "nostr-dm" | "damus-ios" + setSelectedAuthMethod: (method: string) => void + setError: (error: string) => void +} + +export function Step2UserVerification(props: Step2UserVerificationProps) { + const intl = useIntl() + const [pubkey, setPubkey] = useState<string | null>(null) + const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay + const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server + + const step1Done = props.lnCheckout?.product_template_name != null + const step2Done = props.lnCheckout?.verified_pubkey != null + + // MARK: - Functions + + const fetchProfile = async () => { + if (!pubkey) { + return + } + try { + const profile = await getProfile(pubkey) + setProfile(profile) + } + catch (e) { + console.error(e) + props.setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") + } + } + + const fetchAccountInfo = async () => { + if (!pubkey) { + setExistingAccountInfo(undefined) + return + } + try { + const accountInfo = await getPurpleAccountInfo(pubkey) + setExistingAccountInfo(accountInfo) + } + catch (e) { + console.error(e) + props.setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") + } + } + + // MARK: - Effects and hooks + + // Load the profile when the pubkey changes + useEffect(() => { + if (pubkey) { + fetchProfile() + fetchAccountInfo() + } + }, [pubkey]) + + // Reset pubkey if user switches tabs + useEffect(() => { + setPubkey(null) + setProfile(null) + }, [props.selectedAuthMethod]) + + useEffect(() => { + if (props.lnCheckout?.verified_pubkey) { + setPubkey(props.lnCheckout.verified_pubkey) + } + }, [props.lnCheckout]) + + // MARK: - Render + + return (<> + <StepHeader + stepNumber={2} + title={intl.formatMessage({ id: "purple.checkout.step-2", defaultMessage: "Verify your npub" })} + done={step2Done} + active={step1Done} + /> + {props.lnCheckout && !step2Done && + <Tabs defaultValue="nostr-dm" className="w-full flex flex-col items-center mb-6" value={props.selectedAuthMethod} onValueChange={(newValue: string) => { props.setSelectedAuthMethod(newValue) } }> + <TabsList className="mx-auto"> + <TabsTrigger value="nostr-dm" disabled={step2Done}>via Nostr DMs</TabsTrigger> + <TabsTrigger value="damus-ios" disabled={step2Done}>via Damus iOS</TabsTrigger> + </TabsList> + <TabsContent value="nostr-dm"> + <Step2OTPVerification + lnCheckout={props.lnCheckout} + setLNCheckout={props.setLNCheckout} + pubkey={pubkey} + setPubkey={setPubkey} + profile={profile} + setProfile={setProfile} + existingAccountInfo={existingAccountInfo} + setError={props.setError} + /> + </TabsContent> + <TabsContent value="damus-ios"> + <Step2DamusIOSVerification + lnCheckout={props.lnCheckout} + setLNCheckout={props.setLNCheckout} + pubkey={pubkey} + setPubkey={setPubkey} + profile={profile} + setProfile={setProfile} + setError={props.setError} + /> + </TabsContent> + </Tabs> + } + {step2Done && profile && <> + <NostrProfile + profile={profile} + profileHeader={<> + <div className="text-purple-200/50 font-normal text-sm"> + {existingAccountInfo === null || existingAccountInfo === undefined ? <> + {props.lnCheckout?.verified_pubkey && !props.lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchasing-for", defaultMessage: "Verified. Purchasing Damus Purple for:" })} + {props.lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchased-for", defaultMessage: "Purchased Damus Purple for:" })} + </> : <> + {props.lnCheckout?.verified_pubkey && !props.lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.renewing-for", defaultMessage: "Verified. Renewing Damus Purple for:" })} + {props.lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.renewed-for", defaultMessage: "Renewed Damus Purple for:" })} + </>} + </div> + </>} + profileFooter={<> + <AccountExistsNoteIfAccountExists existingAccountInfo={existingAccountInfo}/> + </>} + /> + </>} + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step3Payment.tsx b/src/components/sections/PurpleCheckoutDetails/Step3Payment.tsx @@ -0,0 +1,171 @@ +import { ArrowUpRight, CheckCircle, Copy, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { useIntl } from "react-intl"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { QRCodeSVG } from 'qrcode.react'; +import { useInterval } from 'usehooks-ts' +import Lnmessage from 'lnmessage' +import { LNCheckout } from "./Types"; +import { StepHeader } from "./StepHeader"; +import CopiableUrl from "@/components/ui/CopiableUrl"; + +export interface Step3PaymentProps { + lnCheckout: LNCheckout | null + setLNCheckout: (checkout: LNCheckout | null) => void + setError: (error: string) => void + successView: React.ReactNode +} + +export function Step3Payment(props: Step3PaymentProps) { + const intl = useIntl() + const { lnCheckout, setLNCheckout } = props + const [lnInvoicePaid, setLNInvoicePaid] = useState<boolean | undefined>(undefined) // Whether the ln invoice has been paid + const [waitingForInvoice, setWaitingForInvoice] = useState<boolean>(false) // Whether we are waiting for a response from the LN node about the invoice + + const [lnConnectionRetryCount, setLnConnectionRetryCount] = useState<number>(0) // The number of times we have tried to connect to the LN node + const lnConnectionRetryLimit = 5 // The maximum number of times we will try to connect to the LN node before displaying an error + const [lnWaitinvoiceRetryCount, setLnWaitinvoiceRetryCount] = useState<number>(0) // The number of times we have tried to check the invoice status + const lnWaitinvoiceRetryLimit = 5 // The maximum number of times we will try to check the invoice status before displaying an error + + const step1Done = props.lnCheckout?.product_template_name != null + const step2Done = props.lnCheckout?.verified_pubkey != null + const step3Done = props.lnCheckout?.invoice?.paid == true + + // MARK: - Functions + + const checkLNInvoice = async () => { + console.log("Checking LN invoice...") + if (!lnCheckout?.invoice?.bolt11) { + return + } + let ln = null + try { + ln = new Lnmessage({ + // The public key of the node you would like to connect to + remoteNodePublicKey: lnCheckout.invoice.connection_params.nodeid, + // The websocket proxy address of the node + wsProxy: `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}`, + // The IP address of the node + ip: lnCheckout.invoice.connection_params.address, + // Protocol to use when connecting to the node + wsProtocol: 'wss:', + port: 9735, + }) + // TODO: This is a workaround due to a limitation in LNMessage URL formatting: (https://github.com/aaronbarnardsound/lnmessage/issues/52) + ln.wsUrl = `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}/${lnCheckout.invoice.connection_params.address}` + await ln.connect() + setWaitingForInvoice(true) // Indicate that we are waiting for a response from the LN node + } + catch (e) { + console.error(e) + if (lnConnectionRetryCount >= lnConnectionRetryLimit) { + props.setError("Failed to connect to the Lightning node. Please refresh this page, and try again in a few minutes. If the problem persists, please contact support.") + } + else { + setLnConnectionRetryCount(lnConnectionRetryCount + 1) + } + return + } + + try { + if (!ln) { return } + const res: any = await ln.commando({ + method: 'waitinvoice', + params: { label: lnCheckout.invoice.label }, + rune: lnCheckout.invoice.connection_params.rune, + }) + setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node + setLNInvoicePaid(!res.error) + if (res.error) { + console.error(res.error) + props.setError("The lightning payment failed. If you haven't paid yet, please start a new checkout from the beginning and try again. If you have already paid, please copy the reference ID shown below and contact support.") + } + } catch (e) { + setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node + console.error(e) + if (lnWaitinvoiceRetryCount >= lnWaitinvoiceRetryLimit) { + props.setError("There was an error checking the lightning payment status. If you haven't paid yet, please wait a few minutes, refresh the page, and try again. If you have already paid, please copy the reference ID shown below and contact support.") + } + else { + setLnWaitinvoiceRetryCount(lnWaitinvoiceRetryCount + 1) + } + } + } + + const tellServerToCheckLNInvoice = async () => { + try { + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + lnCheckout?.id + "/check-invoice", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + }) + const data: LNCheckout = await response.json() + setLNCheckout(data) + } + catch (e) { + console.error(e) + props.setError("Failed to finalize checkout. Please try refreshing the page. If the error persists, please copy the reference ID shown below and contact support.") + } + } + + const pollState = async () => { + if (!lnCheckout) { + return + } + if (lnCheckout.invoice && !lnCheckout.invoice?.paid && !waitingForInvoice) { + checkLNInvoice() + } + } + + // MARK: - Effects and hooks + + // Keep checking the state of things when needed + useInterval(pollState, 1000) + + // Tell server to check the invoice as soon as we notice it has been paid + useEffect(() => { + if (lnInvoicePaid === true) { + tellServerToCheckLNInvoice() + } + }, [lnInvoicePaid]) + + // MARK: - Render + + return (<> + <StepHeader + stepNumber={3} + title={intl.formatMessage({ id: "purple.checkout.step-3", defaultMessage: "Lightning payment" })} + done={lnCheckout?.invoice?.paid === true} + active={lnCheckout?.verified_pubkey != null} + /> + {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && + <> + <QRCodeSVG value={"lightning:" + lnCheckout.invoice.bolt11} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6 border-[5px] border-white bg-white" /> + {/* Shows the bolt11 in for copy-paste with a copy and paste button */} + <CopiableUrl url={lnCheckout.invoice.bolt11} /> + <Link href={"lightning:" + lnCheckout.invoice.bolt11} className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4"> + <Button variant="link" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.open-in-wallet", defaultMessage: "Open in wallet" })} + <ArrowUpRight className="text-damuspink-600 ml-2" /> + </Button> + </Link> + <div className="mt-6 text-purple-200/50 font-normal text-sm text-center flex justify-center"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} + </div> + </> + } + {/* We use the lnCheckout object to check payment status (NOT lnInvoicePaid) to display the confirmation message, because the server is the ultimate source of truth */} + {lnCheckout?.invoice?.paid && lnCheckout?.completed && ( + <div className="flex flex-col items-center justify-center gap-3 mt-6"> + <CheckCircle className="w-16 h-16 text-green-500" /> + <div className="mt-3 mb-6 text-sm text-center text-green-500 font-bold"> + {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} + </div> + {props.successView} + </div> + )} + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/StepHeader.tsx b/src/components/sections/PurpleCheckoutDetails/StepHeader.tsx @@ -0,0 +1,14 @@ +import { CheckCircle } from "lucide-react"; + +export function StepHeader({ stepNumber, title, done, active }: { stepNumber: number, title: string, done: boolean, active: boolean }) { + return (<> + <div className={`flex items-center mb-2 ${active ? "" : "opacity-50"}`}> + <div className={`flex items-center justify-center w-8 h-8 rounded-full ${done ? "bg-green-500" : active ? "bg-purple-600" : "bg-gray-500"} text-white text-sm font-semibold`}> + {done ? <CheckCircle className="w-4 h-4" /> : stepNumber} + </div> + <div className="ml-2 text-lg text-purple-200 font-semibold"> + {title} + </div> + </div> + </>) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Types.tsx b/src/components/sections/PurpleCheckoutDetails/Types.tsx @@ -0,0 +1,25 @@ + +export interface LNCheckout { + id: string; + verified_pubkey?: string; + product_template_name?: string; + invoice?: { + bolt11: string; + paid?: boolean; + label: string; + connection_params: { + nodeid: string; + address: string; + rune: string; + ws_proxy_address: string; + }; + }; + completed: boolean; +} +interface ProductTemplate { + description: string; + special_label?: string | null; + amount_msat: number; + expiry: number; +} +export type ProductTemplates = Record<string, ProductTemplate>; diff --git a/src/components/sections/PurpleFinalCTA.tsx b/src/components/sections/PurpleFinalCTA.tsx @@ -5,7 +5,6 @@ import { motion } from "framer-motion"; import { cn } from "@/lib/utils"; import Image from "next/image"; import { ArrowUpRight, MessageCircleIcon, GitBranch, Github } from "lucide-react"; -import { DAMUS_APP_STORE_URL, DAMUS_MERCH_STORE_URL, DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; import { PurpleIcon } from "../icons/PurpleIcon"; import { MeshGradient5 } from "../effects/MeshGradient.5"; diff --git a/src/components/sections/PurpleHero.tsx b/src/components/sections/PurpleHero.tsx @@ -4,13 +4,18 @@ import { TopMenu } from "./TopMenu"; import { Button } from "../ui/Button"; import { FormattedMessage, useIntl } from "react-intl"; import Link from "next/link"; -import { DAMUS_APP_STORE_URL, DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; import { motion } from "framer-motion"; import Image from "next/image"; import { PurpleIcon } from "../icons/PurpleIcon"; +import { AccountInfo } from "@/utils/PurpleUtils"; +import { usePurpleLoginSession } from "@/hooks/usePurpleLoginSession"; export function PurpleHero() { const intl = useIntl() + const { accountInfo: loggedInAccountInfo, logout } = usePurpleLoginSession((error) => { + // Silently ignore errors, knowing whether the user is logged in is not essential in this context. + console.error("Error fetching account info", error) + }) return (<> <div @@ -21,7 +26,24 @@ export function PurpleHero() { <MeshGradient1 className="-translate-x-1/3" /> </div> <div className="container z-10 mx-auto px-6 pt-12 h-full min-h-screen flex flex-col justify-center"> - <TopMenu className="w-full" /> + <TopMenu + className="w-full" + customCTA={<> + {loggedInAccountInfo ? + <Link href="/purple/account"> + <Button variant="accent"> + {intl.formatMessage({ id: "purple.hero.menu.go-to-my-account", defaultMessage: "My Account" })} + </Button> + </Link> + : + <Link href="/purple/login"> + <Button variant="accent"> + {intl.formatMessage({ id: "purple.hero.menu.login", defaultMessage: "Login" })} + </Button> + </Link> + } + </>} + /> <div className="flex flex-col items-center justify-center h-full grow"> <Link href="/purple/checkout"> <motion.div @@ -48,16 +70,33 @@ export function PurpleHero() { style={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 1.5, duration: 1 } }} > - <Link href="/purple/checkout" className="w-full md:w-auto"> - <Button variant="default" className="w-full"> - {intl.formatMessage({ id: "purple.hero.become-a-member", defaultMessage: "Become a member" })} - </Button> - </Link> - <Link href="#benefits" className="w-full md:w-auto"> - <Button variant="link" className="w-full"> - {intl.formatMessage({ id: "purple.hero.learn-more", defaultMessage: "Learn more" })} - </Button> - </Link> + {loggedInAccountInfo ? + <> + <Link href="/purple/account" className="w-full md:w-auto"> + <Button variant="default" className="w-full"> + {intl.formatMessage({ id: "purple.hero.go-to-my-account", defaultMessage: "Go to my account" })} + </Button> + </Link> + <Link href="#benefits" className="w-full md:w-auto"> + <Button variant="link" className="w-full"> + {intl.formatMessage({ id: "purple.hero.learn-more", defaultMessage: "Learn more" })} + </Button> + </Link> + </> + : + <> + <Link href="/purple/checkout" className="w-full md:w-auto"> + <Button variant="default" className="w-full"> + {intl.formatMessage({ id: "purple.hero.become-a-member", defaultMessage: "Become a member" })} + </Button> + </Link> + <Link href="/purple/account" className="w-full md:w-auto"> + <Button variant="link" className="w-full"> + {intl.formatMessage({ id: "purple.hero.login", defaultMessage: "Login to my account" })} + </Button> + </Link> + </> + } </motion.div> </div> </div> diff --git a/src/components/sections/PurpleLogin.tsx b/src/components/sections/PurpleLogin.tsx @@ -13,6 +13,8 @@ import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/ import { useLocalStorage } from "usehooks-ts"; import { ErrorDialog } from "../ErrorDialog"; import { PurpleLayout } from "../PurpleLayout"; +import { NostrUserInput } from "../NostrUserInput"; +import { OTPAuth } from "../OTPAuth"; // TODO: Double-check this regex and make it more accurate @@ -28,7 +30,6 @@ export function PurpleLogin() { const [npub, setNpub] = useState<string>("") const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server const [otpSent, setOTPSent] = useState<boolean>(false) - const [otp, setOTP] = useState<string>("") const [otpVerified, setOTPVerified] = useState<boolean>(false) const [otpInvalid, setOTPInvalid] = useState<boolean>(false) const loginSuccessful = sessionToken !== null && otpVerified === true; @@ -80,10 +81,9 @@ export function PurpleLogin() { } setOTPSent(true) setOTPInvalid(false) - setOTP("") } - const completeOTP = async () => { + const completeOTP = async (otp: string) => { if (!pubkey || !existingAccountInfo || !otp) { return } @@ -141,7 +141,6 @@ export function PurpleLogin() { useEffect(() => { setOTPSent(false) setOTPVerified(false) - setOTP("") if (npub.length > 0 && !NPUB_REGEX.test(npub)) { setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) setProfile(undefined) @@ -164,14 +163,6 @@ export function PurpleLogin() { } }, [npub]) - useEffect(() => { - if (otp.length != 6) { - setOTPInvalid(false) - setOTPVerified(false) - } - }, [otp]) - - // MARK: - Render return (<> @@ -185,17 +176,12 @@ export function PurpleLogin() { {intl.formatMessage({ id: "purple.login.description", defaultMessage: "Use this page to access your Purple account details" })} </span> </div> - <Label htmlFor="npub" className="text-purple-200/70 font-normal"> - {intl.formatMessage({ id: "purple.login.npub-label", defaultMessage: "Please enter your public key (npub) below" })} - </Label> - <Input id="npub" placeholder={intl.formatMessage({ id: "purple.login.npub-placeholder", defaultMessage: "npub…" })} type="text" className="mt-2" value={npub} onChange={(e) => setNpub(e.target.value)} required disabled={loginSuccessful} /> - {npubValidationError && - <Label htmlFor="npub" className="text-red-500 font-normal"> - {npubValidationError} - </Label> - } - {((profile || profile === null) && pubkey) && (<> - <div className="mt-2 mb-4 flex flex-col items-center"> + <NostrUserInput + pubkey={pubkey} + setPubkey={setPubkey} + onProfileChange={setProfile} + disabled={loginSuccessful} + profileHeader={<> {existingAccountInfo !== null && existingAccountInfo !== undefined && otpSent !== true && ( <div className="text-purple-200/50 font-normal flex items-center gap-2 rounded-full px-6 py-2 justify-center mt-2 mb-2"> <Sparkles className="w-4 h-4 shrink-0 text-purple-50" /> @@ -206,17 +192,12 @@ export function PurpleLogin() { </div> </div> )} - <div className="text-purple-200/50 font-normal text-sm"> - {otpSent ? intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "Logging into:" }) + {otpSent ? intl.formatMessage({ id: "purple.login.logging-into", defaultMessage: "Logging into:" }) : intl.formatMessage({ id: "purple.login.is-this-you", defaultMessage: "Is this you?" })} </div> - <div className="mt-4 flex flex-col gap-1 items-center justify-center mb-4"> - <Image src={profile?.picture || ("https://robohash.org/" + (profile?.pubkey || pubkey))} width={64} height={64} className="rounded-full" alt={profile?.name || intl.formatMessage({ id: "purple.login.unknown-user", defaultMessage: "Generic user avatar" })} /> - <div className="text-purple-100/90 font-semibold text-lg"> - {profile?.name || (npub.substring(0, 8) + ":" + npub.substring(npub.length - 8))} - </div> - </div> + </>} + profileFooter={<> {existingAccountInfo === null && ( <div className="text-purple-200/50 font-normal flex items-center gap-2 bg-purple-300/10 rounded-full px-8 py-2 justify-center mt-2 mb-2 w-fit mx-auto"> <Frown className="w-6 h-6 shrink-0 text-purple-50" /> @@ -230,38 +211,25 @@ export function PurpleLogin() { </div> </div> )} + </>} + /> + {((profile || profile === null) && pubkey) && (<> + <div className="mt-2 mb-4 flex flex-col items-center"> {existingAccountInfo !== null && !otpSent && ( <Button variant="default" className="w-full" onClick={() => beginLogin()}>Continue</Button> )} {otpSent && (<> - <div className="text-purple-200/50 font-normal flex items-center gap-2 rounded-full px-6 py-2 justify-center mt-2 mb-2"> - <div className="flex flex-col items-center"> - <Mail className="w-10 h-10 shrink-0 text-purple-100 mb-3" /> - <div className="text-purple-200/90 font-semibold text-md whitespace-pre-line text-center"> - {intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "We sent you a code via a Nostr DM.\n Please enter it below" })} - </div> - </div> - </div> - <div className="mx-auto flex justify-center mb-4"> - <InputOTP6Digits value={otp} onChange={setOTP} onComplete={() => completeOTP()} disabled={loginSuccessful} /> - </div> - {otpInvalid && (<div className="my-4 w-full flex flex-col gap-2"> - <div className="text-red-500 font-normal text-sm text-center"> - {intl.formatMessage({ id: "purple.login.otp-invalid", defaultMessage: "Invalid or expired OTP. Please try again." })} - </div> - <Button variant="default" className="w-full" onClick={() => beginLogin()}>Resend OTP</Button> - </div>)} - <div className="text-purple-200/70 text-normal text-left font-semibold flex flex-col md:flex-row gap-3 rounded-lg bg-purple-200/10 p-3 items-center md:items-start"> - <Info className="w-6 h-6 shrink-0 mt-0 md:mt-1" /> - <div className="flex flex-col text-center md:text-left"> - <span className="text-normal md:text-lg mb-2"> - {intl.formatMessage({ id: "purple.login.stay-safe.title", defaultMessage: "Stay safe" })} - </span> - <span className="text-xs text-purple-200/50 whitespace-pre-line"> - {intl.formatMessage({ id: "purple.login.stay-safe.message", defaultMessage: "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone.\n\n If you don't see the OTP code, please check the DM requests tab in Damus." })} - </span> - </div> - </div> + <OTPAuth + pubkey={pubkey} + verifyOTP={completeOTP} + sendOTP={beginLogin} + otpVerified={otpVerified} + setOTPVerified={setOTPVerified} + otpInvalid={otpInvalid} + setOTPInvalid={setOTPInvalid} + setError={setError} + disabled={loginSuccessful} + /> {loginSuccessful && (<> <div className="flex flex-col justify-center items-center gap-2 mt-8"> <CheckCircle className="w-12 h-12 shrink-0 text-green-500" /> diff --git a/src/components/sections/PurpleWelcome.tsx b/src/components/sections/PurpleWelcome.tsx @@ -0,0 +1,226 @@ +import { MeshGradient5 } from "@/components/effects/MeshGradient.5"; +import { MotionValue, circOut, easeInOut, easeOut, motion, useMotionValue, useTime, useTransform } from "framer-motion"; +import Image from "next/image"; +import { PurpleIcon } from "../icons/PurpleIcon"; +import { Button } from "../ui/Button"; +import { useIntl } from "react-intl"; +import { MeshGradient1 } from "../effects/MeshGradient.1"; +import Link from "next/link"; + + +export function PurpleWelcome() { + const intl = useIntl() + const time = useTime() + const headingGradient = useTransform( + time, + [0, 3000], + [ + "linear-gradient(to right, #000000 0%, #D34CD9 1000%, #F869B6 3000%)", + "linear-gradient(to right, #000000 -10%, #D34CD9 0%, #F869B6 100%)" + ], + { + clamp: true, + ease: circOut + } + ) + const headingOpacity = useTransform( + time, + [0, 3000], + [0, 1], + { + clamp: true, + ease: easeInOut + } + ) + const secondaryContentOpacity = useTransform( + time, + [3000, 5000], + [0, 1], + { + clamp: true, + ease: easeInOut + } + ) + const starsBgScale = useTransform( + time, + [0, 3000], + [1.2, 1.0], + { + clamp: true, + ease: circOut + } + ) + + return ( + <motion.div + className="bg-black overflow-hidden relative" + > + <motion.div + className="absolute z-0 w-full h-full pointer-events-none" + style={{ scale: starsBgScale }} + > + <Image src="/stars-bg.webp" fill className="absolute top-0 left-0 object-cover lg:object-contain object-center w-full h-full" alt="" aria-hidden="true" /> + <MeshGradient1 className="translate-y-1/4 translate-x-32 z-0" /> + </motion.div> + <div className="container z-30 mx-auto px-6 pt-12 h-full min-h-screen flex flex-col gap-y-4 justify-center items-center"> + <motion.div + style={{ opacity: secondaryContentOpacity }} + > + <PurpleIcon className="w-16 h-16" /> + </motion.div> + <motion.h1 + className="text-4xl md:text-6xl text-center text-transparent bg-clip-text font-semibold break-keep tracking-tight z-30" + style={{ + backgroundImage: headingGradient, + opacity: headingOpacity, + }} + > + Welcome to Purple + </motion.h1> + <PurpleStarIcon className="w-32 h-32 z-40" time={time} /> + <motion.div + className="text-center text-purple-200/80 text-lg max-w-lg p-6 space-y-4 z-30" + style={{ opacity: secondaryContentOpacity }} + > + <p> + Thank you very much for signing up for Damus&nbsp;Purple. Your contribution helps us continue our fight for a more Open and Free&nbsp;internet. + </p> + <p> + You will also get access to premium features, and a star badge on your profile. + </p> + <p> + Enjoy! + </p> + <div className="pt-8"> + <Link href="/purple/account" className="w-full justify-center"> + <Button variant="accent" className="w-48"> + { intl.formatMessage({ id: "purple.welcome.continue", defaultMessage: "Continue" }) } + </Button> + </Link> + </div> + </motion.div> + </div> + </motion.div> + ) +} + +interface StarIconProps { + className?: string; + time: MotionValue<number>; +} + +function PurpleStarIcon(props: StarIconProps) { + const { className, time } = props; + + const purple1Color = "#D34CD9"; + const purple2Color = "#F869B6"; + + const starScale = useTransform( + time, + [0, 3000], // For the first 3 seconds... + [3, 1.0], // ...scale from 3x to 1x + { + clamp: true, + ease: circOut + } + ) + + const starOpacity = useTransform( + time, + [0, 3000], + [0, 1], + { + clamp: true, + ease: circOut + } + ) + + const starShadowColor = useTransform( + time, + [0, 4000], + [ + "#FFFFFF00", + "#FFFFFFFF", + ], + { + clamp: true, + ease: circOut + } + ) + + const gradientOffsetKeyframes = [0, 1000, 5000] + + const whiteGradientStopOffset = useTransform( + time, + gradientOffsetKeyframes, + ["0%", "0%", "70%"], + { + clamp: true, + ease: circOut + } + ); + + const purple2GradientStopOffset = useTransform( + time, + gradientOffsetKeyframes, + ["1%", "1%", "100%"], + { + clamp: true, + ease: circOut + } + ); + + const purple1GradientStopOffset = useTransform( + time, + gradientOffsetKeyframes, + ["2%", "2%", "125%"], + { + clamp: true, + ease: circOut + } + ); + + const blackGradientStopOffset = useTransform( + time, + gradientOffsetKeyframes, + ["3%", "3%", "250%"], + { + clamp: true, + ease: circOut + } + ); + + return ( + // Generated by Pixelmator Pro 3.6.8 + <motion.svg + className={className} + width="4267" height="4267" viewBox="0 0 4267 4267" xmlns="http://www.w3.org/2000/svg" + style={{ + scale: starScale, + opacity: starOpacity + }} + > + <defs> + <radialGradient id="purpleGradient" cx="150%" cy="-50%" r="250%"> + <motion.stop offset={whiteGradientStopOffset} style={{ stopColor: "#ffffff", stopOpacity: 1 }} /> + <motion.stop offset={purple2GradientStopOffset} style={{ stopColor: purple2Color, stopOpacity: 1 }} /> + <motion.stop offset={purple1GradientStopOffset} style={{ stopColor: purple1Color, stopOpacity: 1 }} /> + <motion.stop offset={blackGradientStopOffset} style={{ stopColor: "#000000", stopOpacity: 1 }} /> + </radialGradient> + <filter id="whiteShadow" x="-50%" y="-50%" width="200%" height="200%"> + <motion.feDropShadow dx="0" dy="0" stdDeviation="500" floodColor={starShadowColor} /> + </filter> + </defs> + <path + id="Star" + fill="url(#purpleGradient)" + stroke="url(#purpleGradient)" + fill-rule="evenodd" + stroke-width="400" + stroke-linejoin="round" + filter="url(#whiteShadow)" + d="M 2133.5 750 L 1812.484985 1691.660645 L 817.713318 1705.975098 L 1614.086914 2302.267578 L 1320.299072 3252.774902 L 2133.5 2679.643311 L 2946.700928 3252.774902 L 2652.913086 2302.267578 L 3449.286621 1705.975098 L 2454.515137 1691.660645 Z" + /> + </motion.svg> + ) +} diff --git a/src/components/sections/TopMenu.tsx b/src/components/sections/TopMenu.tsx @@ -10,6 +10,7 @@ import { motion } from "framer-motion"; let regularNavItems: { nameIntlId: string, href: string, target?: string }[] = [ { nameIntlId: "topbar.purple", href: "/purple" }, + { nameIntlId: "topbar.notedeck", href: "/notedeck" }, { nameIntlId: "topbar.store", href: DAMUS_MERCH_STORE_URL, target: "_blank" }, { nameIntlId: "topbar.events", href: "/#events" }, { nameIntlId: "topbar.team", href: "/#team" }, @@ -18,13 +19,19 @@ let regularNavItems: { nameIntlId: string, href: string, target?: string }[] = [ const ENABLE_FULL_MENU = true -export function TopMenu({ className }: { className?: string }) { +export interface TopMenuProps { + className?: string + customCTA?: React.ReactNode +} + +export function TopMenu({ className, customCTA }: TopMenuProps) { let navItemDefaultStyles = "hover:opacity-80 transition-opacity duration-200 ease-in-out" const intl = useIntl() // This is needed to allow intl commands to extract the strings const topbarItemNameIntl: Record<string, string> = { "topbar.purple": intl.formatMessage({ id: "topbar.purple", defaultMessage: "Purple" }), + "topbar.notedeck": intl.formatMessage({ id: "topbar.notedeck", defaultMessage: "Notedeck" }), "topbar.store": intl.formatMessage({ id: "topbar.store", defaultMessage: "Store" }), "topbar.events": intl.formatMessage({ id: "topbar.events", defaultMessage: "Events" }), "topbar.team": intl.formatMessage({ id: "topbar.team", defaultMessage: "Our Team" }), @@ -59,11 +66,13 @@ export function TopMenu({ className }: { className?: string }) { </NavigationMenu.Item> </>)} </NavigationMenu.List> - <Link href={DAMUS_APP_STORE_URL} target="_blank"> + {customCTA ? customCTA : <> + <Link href={DAMUS_APP_STORE_URL} target="_blank"> <Button variant="accent"> - { intl.formatMessage({ id: "topbar.download", defaultMessage: "Download" }) } + {intl.formatMessage({ id: "topbar.download", defaultMessage: "Download" })} </Button> - </Link> + </Link> + </>} </NavigationMenu.Root> </motion.div> ) diff --git a/src/components/ui/CopiableUrl.tsx b/src/components/ui/CopiableUrl.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Copy } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const CopiableUrl = ({ url, className }: { url: string; className?: string }) => { + return ( + <div className={cn("flex items-center justify-between rounded-md bg-purple-200/20", className)}> + <div className="w-full text-sm text-purple-200/50 font-normal px-4 py-2 overflow-x-scroll"> + {url} + </div> + <button + className="text-sm text-purple-200/50 font-normal px-4 py-2 active:text-purple-200/30 hover:text-purple-200/80 transition" + onClick={() => navigator.clipboard.writeText(url || "")} + > + <Copy /> + </button> + </div> + ); +}; + +export default CopiableUrl; diff --git a/src/components/ui/RoundedContainerWithGradientBorder.tsx b/src/components/ui/RoundedContainerWithGradientBorder.tsx @@ -15,7 +15,7 @@ export function RoundedContainerWithGradientBorder({ className, allItemsClassNam export function RoundedContainerWithColorGradientBorder({ className, allItemsClassName, children }: { className?: string, allItemsClassName?: string, children: React.ReactNode }) { return ( - <div className={cn("relative w-24 h-24 flex justify-center items-center backdrop-blur-sm", allItemsClassName, className)}> + <div className={cn("relative w-24 h-24 flex justify-center items-center", allItemsClassName, className)}> <div className="z-10 relative p-6 w-full h-full"> {children} </div> diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-10 items-center justify-center rounded-lg bg-purple-200/10 p-1 text-purple-200/70", + className + )} + {...props} + /> +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-damuspink-500 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-damuspink-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white/90 data-[state=active]:text-black/90 data-[state=active]:shadow-md", + className + )} + {...props} + /> +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2", + className + )} + {...props} + /> +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/hooks/usePurpleLoginSession.ts b/src/hooks/usePurpleLoginSession.ts @@ -0,0 +1,47 @@ +import { useState, useEffect } from "react"; +import { useLocalStorage } from "usehooks-ts"; +import { AccountInfo } from "@/utils/PurpleUtils"; + + +export function usePurpleLoginSession(setError: (message: string) => void) { + const [sessionToken, setSessionToken] = useLocalStorage('session_token', null); + const [accountInfo, setAccountInfo] = useState<AccountInfo | undefined | null>(undefined); + + const logout = () => { + setSessionToken(null); + setAccountInfo(null); + }; + + useEffect(() => { + const fetchAccountInfo = async () => { + if (!sessionToken) { + setAccountInfo(null); + return; + } + + try { + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/sessions/account", { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + sessionToken + }, + }); + + if (!response.ok) { + setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support."); + return; + } + + const accountInfo = await response.json(); + setAccountInfo(accountInfo); + } catch (e) { + setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support."); + } + }; + + fetchAccountInfo(); + }, [sessionToken]); + + return { accountInfo, logout } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts @@ -1,3 +1,3 @@ export const DAMUS_APP_STORE_URL = 'https://apps.apple.com/ca/app/damus/id1628663131' -export const DAMUS_TESTFLIGHT_URL = 'https://testflight.apple.com/join/CLwjLxWl' export const DAMUS_MERCH_STORE_URL = 'https://store.damus.io/?ref=damus_website' +export const NOTEDECK_WAITLIST_URL = "#tally-open=npVXbJ&tally-layout=modal&tally-align-left=1&tally-hide-title=1&tally-emoji-text=🚀&tally-emoji-animation=none&tally-auto-close=68000" diff --git a/src/pages/purple/welcome/index.tsx b/src/pages/purple/welcome/index.tsx @@ -0,0 +1,39 @@ +import { IntlProvider, useIntl } from 'react-intl' +import English from "@/../content/compiled-locales/en.json"; +import { useEffect } from 'react'; +import { useState } from 'react'; +import { PurpleWelcomePage } from '@/components/pages/purple-welcome'; + +export default function HomePage() { + // Automatically detect the user's locale based on their browser settings + const [language, setLanguage] = useState("en"); + const [messages, setMessages] = useState(English); + + useEffect(() => { + setLanguage(navigator.language); + }, []); + + useEffect(() => { + switch (language) { + case "en": + setMessages(English); + break; + case "ja": + // TODO: Add Japanese translations and then switch to "Japanese" below + setMessages(English); + break; + default: + setMessages(English); + break; + } + }, [language]); + + return (<> + <IntlProvider + locale={language} + messages={messages} + onError={() => null}> + <PurpleWelcomePage/> + </IntlProvider> + </>) +}