diff --git a/.gitignore b/.gitignore index 3f7c52c..4fb0d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ /out/ /out-staging /out-production +/out-dev # misc .DS_Store diff --git a/build b/build index b203eba..44a21bc 100755 --- 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 index bfb3ba6..a138be9 100644 --- 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 index 71872ee..cdd7b59 100644 --- 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 index fafe57c..6d28300 100644 --- 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 index 940a5b3..9f84e10 100644 --- 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 index 99c0851..25f4c99 100644 --- 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 new file mode 100644 index 0000000..8322c0f --- /dev/null +++ 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 (<> +
+ {props.profileHeader} +
+ {profile.name +
+ {profile?.name || (npub.substring(0, 8) + ":" + npub.substring(npub.length - 8))} +
+
+ {props.profileFooter} +
+ ) +} diff --git a/src/components/NostrUserInput.tsx b/src/components/NostrUserInput.tsx new file mode 100644 index 0000000..2541b0e --- /dev/null +++ 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(undefined) // The profile info fetched from the Damus relay + const [error, setError] = useState(null) // An error message to display to the user + const [npubValidationError, setNpubValidationError] = useState(null) + const [npub, setNpub] = useState("") + + // 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 (<> + + + setNpub(e.target.value)} required disabled={props.disabled} /> + {npubValidationError && + + } + {(profile === undefined && props.pubkey && props.pubkey.length > 0) && ( +
+ +
+ {intl.formatMessage({ id: "purple.login.fetching-profile", defaultMessage: "Fetching profile info…" })} +
+
+ )} + {profile && (<> + + )} + ) +} diff --git a/src/components/OTPAuth.tsx b/src/components/OTPAuth.tsx new file mode 100644 index 0000000..1e83c92 --- /dev/null +++ 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("") + const [showTroubleshootingMessage, setShowTroubleshootingMessage] = useState(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 (<> +
+
+ +
+ {intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "We sent you a code via a Nostr direct message.\n Please enter it below" })} +
+
+
+
+ completeOTP()} disabled={props.disabled} /> +
+ {!props.otpVerified && !props.otpInvalid && otp.length >= 6 && ( +
+
+ + Loading... +
+
+ )} + {props.otpInvalid && (
+
+ {intl.formatMessage({ id: "purple.login.otp-invalid", defaultMessage: "Invalid or expired OTP. Please try again." })} +
+ +
)} + {showTroubleshootingMessage ? ( + +

+ {intl.formatMessage({ id: "purple.otp.troubleshooting.message", defaultMessage: "If you don't see the OTP code, try the following steps:" })} +

+
    +
  1. {intl.formatMessage({ id: "purple.otp.troubleshooting.step1", defaultMessage: "Check your DM request tab (if you are on Damus)" })}
  2. +
  3. {intl.formatMessage({ id: "purple.otp.troubleshooting.step2", defaultMessage: "Ensure your Nostr client is connected to our relay listed below:" })}
  4. +
+ + } + /> + ) : + + } + ) +} + + +function InfoLabel({ heading, message }: { heading: string, message: React.ReactNode }) { + return ( +
+ +
+ + {heading} + + + {message} + +
+
+ ) +} diff --git a/src/components/pages/purple-welcome.tsx b/src/components/pages/purple-welcome.tsx new file mode 100644 index 0000000..09d4f83 --- /dev/null +++ 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(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 (<> + + Welcome to Damus Purple + +
+ + {existingAccountInfo && } +
+ ) +} diff --git a/src/components/sections/FinalCTA.tsx b/src/components/sections/FinalCTA.tsx index 57da45c..7175137 100644 --- 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 }) { - + diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index dac17e8..64983e9 100644 --- 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() { - + @@ -113,4 +113,4 @@ export function Hero() { ) -} \ No newline at end of file +} diff --git a/src/components/sections/Notedeck/NotedeckHero.tsx b/src/components/sections/Notedeck/NotedeckHero.tsx index 0b05fd3..30b23e7 100644 --- 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 } }} > - + + + + } + /> - + diff --git a/src/components/sections/Notedeck/WaitlistForm.tsx b/src/components/sections/Notedeck/WaitlistForm.tsx index 3e0fa39..de615ed 100644 --- 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 } }} > - + @@ -47,4 +48,4 @@ export function NotedeckWaitlistForm({ className }: { className?: string }) { ) -} \ No newline at end of file +} diff --git a/src/components/sections/PurpleAccount.tsx b/src/components/sections/PurpleAccount.tsx index 9fbccb9..c33020b 100644 --- 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(undefined) // The account info fetched from the server const [error, setError] = useState(null) + const { accountInfo: loggedInAccountInfo, logout } = usePurpleLoginSession(setError) const [profile, setProfile] = useState(null) const [pubkey, setPubkey] = useState(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() {
- {existingAccountInfo?.created_at && unixTimestampToDateString(existingAccountInfo?.created_at)} + {loggedInAccountInfo?.created_at && unixTimestampToDateString(loggedInAccountInfo?.created_at)}
- {existingAccountInfo?.active ? ( + {loggedInAccountInfo?.active ? (
@@ -116,17 +92,17 @@ export function PurpleAccount() { )}
- - - - {existingAccountInfo?.testflight_url && + + + + {loggedInAccountInfo?.testflight_url && }
- diff --git a/src/components/sections/PurpleBanner.tsx b/src/components/sections/PurpleBanner.tsx index 7f2fcdc..c5e0da9 100644 --- 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 index 5d2806e..2b7ed79 100644 --- 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(null) // The checkout object from the server - const [productTemplates, setProductTemplates] = useState(null) // The different product options - const [pubkey, setPubkey] = useState(null) // The pubkey of the user, if verified - const [profile, setProfile] = useState(undefined) // The profile info fetched from the Damus relay - const [continueShowQRCodes, setContinueShowQRCodes] = useState(false) // Whether the user wants to show a QR code for the final step - const [lnInvoicePaid, setLNInvoicePaid] = useState(undefined) // Whether the ln invoice has been paid - const [waitingForInvoice, setWaitingForInvoice] = useState(false) // Whether we are waiting for a response from the LN node about the invoice const [error, setError] = useState(null) // An error message to display to the user + const [selectedAuthMethod, setSelectedAuthMethod] = useState("nostr-dm") const [existingAccountInfo, setExistingAccountInfo] = useState(undefined) // The account info fetched from the server - const [lnConnectionRetryCount, setLnConnectionRetryCount] = useState(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(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 (<> - - {lnCheckout && lnCheckout.id && ( -
-
- Reference: -
-
- {lnCheckout?.id} -
- -
- )} -
+ +

{intl.formatMessage({ id: "purple.checkout.title", defaultMessage: "Checkout" })} @@ -303,208 +95,36 @@ export function PurpleCheckout() {

- -
- {productTemplates ? Object.entries(productTemplates).map(([name, productTemplate]) => ( - - )) : ( -
-
- - Loading... -
-
- )} -
- - {lnCheckout && !lnCheckout.verified_pubkey && <> - - - - -
- {/* TODO: Localize later */} - Issues with this step? Please ensure you are running the latest Damus iOS version from TestFlight — or contact us -
- - } - {profile && -
-
- {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:" })} - } -
-
- {profile.name} -
- {profile.name} -
- {existingAccountInfo !== null && existingAccountInfo !== undefined && ( -
- -
-
- {intl.formatMessage({ id: "purple-checkout.this-account-exists", defaultMessage: "Yay! We found your account" })} -
-
- {intl.formatMessage({ id: "purple-checkout.account-will-renew", defaultMessage: "Paying will renew or extend your membership." })} -
-
-
- )} -
-
- } - + {lnCheckout && + + } + } /> - {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && - <> - - {/* Shows the bolt11 in for copy-paste with a copy and paste button */} -
-
- {lnCheckout.invoice.bolt11} -
- -
- - - -
- - {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} -
- - } - {/* 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 && ( -
- -
- {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} -
- - - - - {continueShowQRCodes && ( - <> - - - )} -
- {/* TODO: Localize later */} - Issues with this step? Please ensure you are running the latest Damus iOS version from TestFlight — or contact us -
-
- )} ) } - -// MARK: - Helper components - -function StepHeader({ stepNumber, title, done, active }: { stepNumber: number, title: string, done: boolean, active: boolean }) { - return (<> -
-
- {done ? : stepNumber} -
-
- {title} -
-
- ) -} - -// 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 diff --git a/src/components/sections/PurpleCheckoutDetails/AccountExistsNote.tsx b/src/components/sections/PurpleCheckoutDetails/AccountExistsNote.tsx new file mode 100644 index 0000000..8a90696 --- /dev/null +++ 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 && ( +
+ +
+
+ {intl.formatMessage({ id: "purple-checkout.this-account-exists", defaultMessage: "Yay! We found your account" })} +
+
+ {intl.formatMessage({ id: "purple-checkout.account-will-renew", defaultMessage: "Paying will renew or extend your membership." })} +
+
+
+ )} + ); +} diff --git a/src/components/sections/PurpleCheckoutDetails/CheckoutSuccess.tsx b/src/components/sections/PurpleCheckoutDetails/CheckoutSuccess.tsx new file mode 100644 index 0000000..210e979 --- /dev/null +++ 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(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) ? (<> + + + + + {continueShowQRCodes && ( + <> + + + )} +
+ {/* TODO: Localize later */} + Issues with this step? Please ensure you are running the latest Damus iOS version — or contact us +
+ ) + : selectedAuthMethod == "nostr-dm" && ( + + + + )} + ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/ErrorDialog.tsx b/src/components/sections/PurpleCheckoutDetails/ErrorDialog.tsx new file mode 100644 index 0000000..6a64c2b --- /dev/null +++ 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 ( + + {lnCheckout && lnCheckout.id && ( +
+
+ Reference: +
+
+ {lnCheckout?.id} +
+ +
+ )} +
+ ); +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step1ProductSelection.tsx b/src/components/sections/PurpleCheckoutDetails/Step1ProductSelection.tsx new file mode 100644 index 0000000..1fa85de --- /dev/null +++ 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(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 (<> + +
+ {productTemplates ? Object.entries(productTemplates).map(([name, productTemplate]) => ( + + )) : ( +
+
+ + Loading... +
+
+ )} +
+ ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step2DamusIOSVerification.tsx b/src/components/sections/PurpleCheckoutDetails/Step2DamusIOSVerification.tsx new file mode 100644 index 0000000..1eccd02 --- /dev/null +++ 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(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 && <> + + + + +
+ {/* TODO: Localize later */} + Issues with this step? Please ensure you are running the latest Damus iOS version — or contact us +
+ + } + ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step2OTPVerification.tsx b/src/components/sections/PurpleCheckoutDetails/Step2OTPVerification.tsx new file mode 100644 index 0000000..e886b22 --- /dev/null +++ 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(false) + const [otpVerified, setOTPVerified] = useState(false) + const [otpInvalid, setOTPInvalid] = useState(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 (<> + + {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?" }) + } + + } + profileFooter={<> + + {!otpSent && !step2Done && ( + + )} + } + disabled={step2Done} + /> + {otpSent && !step2Done && + + } + ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step2UserVerification.tsx b/src/components/sections/PurpleCheckoutDetails/Step2UserVerification.tsx new file mode 100644 index 0000000..ea42154 --- /dev/null +++ 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(null) + const [profile, setProfile] = useState(undefined) // The profile info fetched from the Damus relay + const [existingAccountInfo, setExistingAccountInfo] = useState(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 (<> + + {props.lnCheckout && !step2Done && + { props.setSelectedAuthMethod(newValue) } }> + + via Nostr DMs + via Damus iOS + + + + + + + + + } + {step2Done && profile && <> + +
+ {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:" })} + } +
+ } + profileFooter={<> + + } + /> + } + ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Step3Payment.tsx b/src/components/sections/PurpleCheckoutDetails/Step3Payment.tsx new file mode 100644 index 0000000..423187a --- /dev/null +++ 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(undefined) // Whether the ln invoice has been paid + const [waitingForInvoice, setWaitingForInvoice] = useState(false) // Whether we are waiting for a response from the LN node about the invoice + + const [lnConnectionRetryCount, setLnConnectionRetryCount] = useState(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(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 (<> + + {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && + <> + + {/* Shows the bolt11 in for copy-paste with a copy and paste button */} + + + + +
+ + {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} +
+ + } + {/* 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 && ( +
+ +
+ {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} +
+ {props.successView} +
+ )} + ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/StepHeader.tsx b/src/components/sections/PurpleCheckoutDetails/StepHeader.tsx new file mode 100644 index 0000000..223d645 --- /dev/null +++ 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 (<> +
+
+ {done ? : stepNumber} +
+
+ {title} +
+
+ ) +} diff --git a/src/components/sections/PurpleCheckoutDetails/Types.tsx b/src/components/sections/PurpleCheckoutDetails/Types.tsx new file mode 100644 index 0000000..38c4d56 --- /dev/null +++ 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; diff --git a/src/components/sections/PurpleFinalCTA.tsx b/src/components/sections/PurpleFinalCTA.tsx index 777ad71..56c7806 100644 --- 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 index 80da7f7..5cfbe59 100644 --- 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 (<>
- + + {loggedInAccountInfo ? + + + + : + + + + } + } + />
- - - - - - + {loggedInAccountInfo ? + <> + + + + + + + + : + <> + + + + + + + + }
diff --git a/src/components/sections/PurpleLogin.tsx b/src/components/sections/PurpleLogin.tsx index 21f4ab7..51faa6b 100644 --- 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("") const [existingAccountInfo, setExistingAccountInfo] = useState(undefined) // The account info fetched from the server const [otpSent, setOTPSent] = useState(false) - const [otp, setOTP] = useState("") const [otpVerified, setOTPVerified] = useState(false) const [otpInvalid, setOTPInvalid] = useState(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" })} - - setNpub(e.target.value)} required disabled={loginSuccessful} /> - {npubValidationError && - - } - {((profile || profile === null) && pubkey) && (<> -
+ {existingAccountInfo !== null && existingAccountInfo !== undefined && otpSent !== true && (
@@ -206,17 +192,12 @@ export function PurpleLogin() {
)} -
- {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?" })}
-
- {profile?.name -
- {profile?.name || (npub.substring(0, 8) + ":" + npub.substring(npub.length - 8))} -
-
+ } + profileFooter={<> {existingAccountInfo === null && (
@@ -230,38 +211,25 @@ export function PurpleLogin() {
)} + } + /> + {((profile || profile === null) && pubkey) && (<> +
{existingAccountInfo !== null && !otpSent && ( )} {otpSent && (<> -
-
- -
- {intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "We sent you a code via a Nostr DM.\n Please enter it below" })} -
-
-
-
- completeOTP()} disabled={loginSuccessful} /> -
- {otpInvalid && (
-
- {intl.formatMessage({ id: "purple.login.otp-invalid", defaultMessage: "Invalid or expired OTP. Please try again." })} -
- -
)} -
- -
- - {intl.formatMessage({ id: "purple.login.stay-safe.title", defaultMessage: "Stay safe" })} - - - {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." })} - -
-
+ {loginSuccessful && (<>
diff --git a/src/components/sections/PurpleWelcome.tsx b/src/components/sections/PurpleWelcome.tsx new file mode 100644 index 0000000..b0326f0 --- /dev/null +++ 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 ( + + + + + +
+ + + + + Welcome to Purple + + + +

+ Thank you very much for signing up for Damus Purple. Your contribution helps us continue our fight for a more Open and Free internet. +

+

+ You will also get access to premium features, and a star badge on your profile. +

+

+ Enjoy! +

+
+ + + +
+
+
+
+ ) +} + +interface StarIconProps { + className?: string; + time: MotionValue; +} + +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 + + + + + + + + + + + + + + + ) +} diff --git a/src/components/sections/TopMenu.tsx b/src/components/sections/TopMenu.tsx index 2087e67..faa7c52 100644 --- 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 = { "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 }) { )} - + {customCTA ? customCTA : <> + - + + } ) diff --git a/src/components/ui/CopiableUrl.tsx b/src/components/ui/CopiableUrl.tsx new file mode 100644 index 0000000..0ece5e2 --- /dev/null +++ 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 ( +
+
+ {url} +
+ +
+ ); +}; + +export default CopiableUrl; diff --git a/src/components/ui/RoundedContainerWithGradientBorder.tsx b/src/components/ui/RoundedContainerWithGradientBorder.tsx index 13cd738..94d86e0 100644 --- 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 ( -
+
{children}
diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..7f0c943 --- /dev/null +++ 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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/hooks/usePurpleLoginSession.ts b/src/hooks/usePurpleLoginSession.ts new file mode 100644 index 0000000..042a9d1 --- /dev/null +++ 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(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 index a3e91c7..ba8846c 100644 --- 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 new file mode 100644 index 0000000..bf22803 --- /dev/null +++ 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 (<> + null}> + + + ) +}