damus.io

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

commit 856ff538870396b03eb98a89ce3d31a5998595d7
parent 3657f2805795cf1b7b60f91871388dafc0f8c2b2
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Mon,  1 Apr 2024 14:21:55 +0000

Add account page

Testing
-------

PASS

Damus website: This commit
Damus API: 0d7c09d6d4de97dbe162092a61bd915c503ba7bc
Coverage:
1. Successful OTP login flow works and shows account info. PASS
2. Entering wrong OTP code shows an "invalid OTP" message. PASS
3. Clicking "Resend OTP works". PASS
4. Clicking on "Join TestFlight" works. PASS
5. Contact links work. PASS
6. Sign out works. PASS
7. Trying to login with an npub that does not have an account shows that such account does not exist and offers user to buy the membership.
8. Login page will indicate when an npub is invalid. PASS
9. Trying to login with a valid npub that has no profile on Nostr will show the npub as the profile name and robohash. PASS
10. Login persists after closing and reopening the page. PASS
11. Account and login pages look good on mobile and desktop. PASS
12. Trying to go to the account page without being logged in redirects to login page. PASS

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Asrc/components/pages/purple-account.tsx | 19+++++++++++++++++++
Asrc/components/sections/PurpleAccount.tsx | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pages/purple/account/index.tsx | 45+++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 218 insertions(+), 0 deletions(-)

diff --git a/src/components/pages/purple-account.tsx b/src/components/pages/purple-account.tsx @@ -0,0 +1,19 @@ +import Head from "next/head"; +import { useIntl } from 'react-intl' +import { Footer } from '@/components/sections/Footer'; +import { PurpleAccount } from "../sections/PurpleAccount"; + + +export function PurpleAccountPage() { + const intl = useIntl() + + return (<> + <Head> + <title>Damus Purple account</title> + </Head> + <main style={{ scrollBehavior: "smooth" }}> + <PurpleAccount /> + <Footer /> + </main> + </>) +} diff --git a/src/components/sections/PurpleAccount.tsx b/src/components/sections/PurpleAccount.tsx @@ -0,0 +1,154 @@ +import { ArrowUpRight, Star, Check, LogOut, X } from "lucide-react"; +import { Button } from "../ui/Button"; +import { FormattedMessage, useIntl } from "react-intl"; +import Link from "next/link"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; +import { useLocalStorage } from "usehooks-ts"; +import { ErrorDialog } from "../ErrorDialog"; +import { PurpleLayout } from "../PurpleLayout"; + + +export function PurpleAccount() { + const intl = useIntl() + const [sessionToken, setSessionToken] = useLocalStorage('session_token', null) + const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server + const [error, setError] = useState<string | null>(null) + const [profile, setProfile] = useState<Profile | null>(null) + const [pubkey, setPubkey] = useState<string | null>(null) + + // 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 () => { + 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 + useEffect(() => { + if (pubkey) { + fetchProfile() + } + }, [pubkey]) + + useEffect(() => { + if (sessionToken) { + fetchAccountInfo() + } + else if (sessionToken === null) { + // Redirect to the login page + window.location.href = "/purple/login?redirect=" + encodeURIComponent("/purple/account") + } + }, [sessionToken]) + + // MARK: - Render + + return (<> + <ErrorDialog error={error} setError={setError} /> + <PurpleLayout> + {((profile || profile === null) && pubkey) && (<> + <div className="mt-2 mb-4 flex flex-col items-center"> + <div className="mt-4 flex flex-col gap-1 items-center justify-center mb-4"> + <Image src={profile?.picture || ("https://robohash.org/" + (profile?.pubkey || pubkey))} width={128} height={128} className="rounded-full" alt={profile?.name || intl.formatMessage({ id: "purple.account.unknown-user", defaultMessage: "Generic user avatar" })} /> + <div className="flex flex-wrap gap-1 items-center"> + <div className="text-purple-50 font-semibold text-3xl"> + {profile?.name || "No name"} + </div> + <div className="text-purple-200/70 font-normal text-xs flex gap-1"> + <Star strokeWidth={1.75} className="w-4 h-4 shrink-0 text-amber-400" fill="currentColor" /> + {existingAccountInfo?.created_at && unixTimestampToDateString(existingAccountInfo?.created_at)} + </div> + </div> + {existingAccountInfo?.active ? ( + <div className="flex gap-1 bg-gradient-to-r from-damuspink-500 to-damuspink-600 rounded-full px-3 py-1 items-center mt-3 mb-6"> + <div className="w-4 h-4 rounded-full bg-white flex justify-center items-center"> + <Check className="w-2 h-2 shrink-0 text-damuspink-500" strokeWidth={5} /> + </div> + <div className="text-white font-semibold text-sm"> + {intl.formatMessage({ id: "purple.account.active", defaultMessage: "Active account" })} + </div> + </div> + ) : ( + <div className="flex gap-1 bg-red-200 rounded-full px-3 py-1 items-center mt-3 mb-6"> + <div className="w-4 h-4 rounded-full bg-red-500 flex justify-center items-center"> + <X className="w-2 h-2 shrink-0 text-red-200" strokeWidth={5} /> + </div> + <div className="text-red-500 font-semibold text-sm"> + {intl.formatMessage({ id: "purple.account.expired", defaultMessage: "Expired account" })} + </div> + </div> + )} + </div> + <div className="flex flex-col bg-purple-50/10 rounded-xl px-4 text-purple-50 w-full"> + <AccountInfoRow label={intl.formatMessage({ id: "purple.account.expiry-date", defaultMessage: "Expiry date" })} value={(existingAccountInfo?.expiry && unixTimestampToDateString(existingAccountInfo?.expiry)) || "N/A"} /> + <AccountInfoRow label={intl.formatMessage({ id: "purple.account.account-creation", defaultMessage: "Account creation" })} value={(existingAccountInfo?.created_at && unixTimestampToDateString(existingAccountInfo?.created_at)) || "N/A"} /> + <AccountInfoRow label={intl.formatMessage({ id: "purple.account.subscriber-number", defaultMessage: "Subscriber number" })} value={(existingAccountInfo?.subscriber_number && "#" + existingAccountInfo?.subscriber_number) || "N/A"} last={existingAccountInfo?.testflight_url ? false : true} /> + {existingAccountInfo?.testflight_url && <Link href={existingAccountInfo?.testflight_url} target="_blank"> + <Button variant="link" className="w-full text-left my-2"> + <ArrowUpRight className="text-damuspink-600 mr-2" /> + {intl.formatMessage({ id: "purple.account.testflight-link", defaultMessage: "Join TestFlight" })} + </Button> + </Link>} + </div> + <Button className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4 text-sm" onClick={() => setSessionToken(null)} variant="link"> + <LogOut className="text-damuspink-600 mr-2" /> + {intl.formatMessage({ id: "purple.account.sign-out", defaultMessage: "Sign out" })} + </Button> + </div> + </>)} + </PurpleLayout> + </>) +} + +function AccountInfoRow({ label, value, last }: { label: string, value: string | number, last?: boolean }) { + return ( + <div className={`flex gap-2 items-center justify-between ${last ? '' : 'border-b border-purple-200/20'} py-4`}> + <div className="font-bold"> + {label} + </div> + <div> + {value} + </div> + </div> + ); +} + +function unixTimestampToDateString(timestamp: number) { + return new Date(timestamp * 1000).toLocaleDateString() +} diff --git a/src/pages/purple/account/index.tsx b/src/pages/purple/account/index.tsx @@ -0,0 +1,45 @@ +import { Inter } from 'next/font/google' +import { IntlProvider, useIntl } from 'react-intl' +import English from "@/../content/compiled-locales/en.json"; +import Japanese from "@/../content/compiled-locales/ja.json"; +import { useEffect } from 'react'; +import { useState } from 'react'; +import { Purple } from '@/components/pages/purple'; +import { PurpleCheckout } from '@/components/pages/purple-checkout'; +import { PurpleLoginPage } from '@/components/pages/purple-login'; +import { PurpleAccountPage } from '@/components/pages/purple-account'; + +export default function HomePage() { + // Automatically detect the user's locale based on their browser settings + const [language, setLanguage] = useState("en"); + const [messages, setMessages] = useState(English); + + useEffect(() => { + setLanguage(navigator.language); + }, []); + + useEffect(() => { + switch (language) { + case "en": + setMessages(English); + break; + case "ja": + // TODO: Add Japanese translations and then switch to "Japanese" below + setMessages(English); + break; + default: + setMessages(English); + break; + } + }, [language]); + + return (<> + <IntlProvider + locale={language} + messages={messages} + onError={() => null}> + <PurpleAccountPage /> + </IntlProvider> + </>) +} +