diff --git a/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py new file mode 100644 index 00000000..316e4a59 --- /dev/null +++ b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py @@ -0,0 +1,62 @@ +"""Add unverified tier + +Revision ID: 4f9ec96fc98e +Revises: 651ed2d244c5 +Create Date: 2024-10-31 14:30:56.099043 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlalchemy.orm as orm +from alembic_postgresql_enum import TableReference +from src.limits.models import Tier + +# revision identifiers, used by Alembic. +revision: str = "4f9ec96fc98e" +down_revision: Union[str, None] = "651ed2d244c5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + "public", + "tiernames", + ["FREE", "ADMIN", "PREMIUM", "UNVERIFIED"], + [ + TableReference( + table_schema="public", table_name="tier", column_name="tier_name" + ) + ], + enum_values_to_rename=[], + ) + session = orm.Session(bind=op.get_bind()) + session.add(Tier(tier_name="UNVERIFIED", label="Unverified", gp_question_limit=0)) + session.commit() + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + session = orm.Session(bind=op.get_bind()) + unverified = session.scalar(sa.select(Tier).where(Tier.label == "Unverified")) + session.delete(unverified) + session.commit() + + op.sync_enum_values( + "public", + "tiernames", + ["FREE", "ADMIN", "PREMIUM"], + [ + TableReference( + table_schema="public", table_name="tier", column_name="tier_name" + ) + ], + enum_values_to_rename=[], + ) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py b/backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py new file mode 100644 index 00000000..10a7577c --- /dev/null +++ b/backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py @@ -0,0 +1,48 @@ +"""Add essay rate limit + +Revision ID: 63af7264fba3 +Revises: 4f9ec96fc98e +Create Date: 2024-10-31 14:54:32.307467 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlalchemy.orm as orm +from src.limits.models import Tier + + +# revision identifiers, used by Alembic. +revision: str = "63af7264fba3" +down_revision: Union[str, None] = "4f9ec96fc98e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +ESSAY_LIMITS = {"Free": 3, "Premium": 10, "Unverified": 0, "Admin": 1000} + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "tier", + sa.Column("essay_limit", sa.Integer(), server_default="0", nullable=False), + ) + op.add_column( + "usage", sa.Column("essays", sa.Integer(), server_default="0", nullable=False) + ) + session = orm.Session(bind=op.get_bind()) + for tier_type, essay_limit in ESSAY_LIMITS.items(): + tier = session.scalar(sa.select(Tier).where(Tier.label == tier_type)) + tier.essay_limit = essay_limit + session.add(tier) + session.commit() + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("usage", "essays") + op.drop_column("tier", "essay_limit") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/651ed2d244c5_add_email_verification.py b/backend/alembic/versions/651ed2d244c5_add_email_verification.py new file mode 100644 index 00000000..94d7e495 --- /dev/null +++ b/backend/alembic/versions/651ed2d244c5_add_email_verification.py @@ -0,0 +1,54 @@ +"""Add email verification + +Revision ID: 651ed2d244c5 +Revises: 59cef91d2fa1 +Create Date: 2024-10-31 13:46:07.360330 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "651ed2d244c5" +down_revision: Union[str, None] = "59cef91d2fa1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "email_verification", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("used", sa.Boolean(), nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "user", + sa.Column("verified", sa.Boolean(), server_default="true", nullable=False), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "verified") + op.drop_table("email_verification") + # ### end Alembic commands ### diff --git a/backend/src/auth/models.py b/backend/src/auth/models.py index 986d1e53..9d831e14 100644 --- a/backend/src/auth/models.py +++ b/backend/src/auth/models.py @@ -27,6 +27,10 @@ class Role(str, Enum): ADMIN = "admin" +# TODO: it's probably safer to check with the db but it'll do for now +UNVERIFIED_TIER_ID = 4 + + class User(Base): __tablename__ = "user" @@ -35,18 +39,17 @@ class User(Base): hashed_password: Mapped[str] account_type: Mapped[AccountType] last_accessed: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + top_events_period: Mapped[int] = mapped_column(Integer, default=7) + tier_id: Mapped[int] = mapped_column( + ForeignKey("tier.id"), default=1, server_default="1" + ) + verified: Mapped[bool] = mapped_column(server_default="true") role: Mapped[Role] = mapped_column(server_default="NORMAL") - categories: Mapped[list[Category]] = relationship(secondary=user_category_table) notes: Mapped[list[Note]] = relationship("Note", backref="user") - top_events_period: Mapped[int] = mapped_column(Integer, default=7) - bookmarks: Mapped[list[Bookmark]] = relationship(backref="user") - tier_id: Mapped[int] = mapped_column( - ForeignKey("tier.id"), default=1, server_default="1" - ) subscription: Mapped[Subscription] = relationship( "Subscription", backref="user", lazy="selectin", uselist=False ) @@ -62,3 +65,12 @@ class PasswordReset(Base): user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) code: Mapped[str] used: Mapped[bool] + + +class EmailVerification(Base): + __tablename__ = "email_verification" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + code: Mapped[str] + used: Mapped[bool] = mapped_column(default=False) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 77dcb984..5809b918 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -9,8 +9,13 @@ import httpx from sqlalchemy import select, update from sqlalchemy.orm import selectinload -from src.auth.utils import create_token, send_reset_password_email +from src.auth.utils import ( + create_token, + send_reset_password_email, + send_verification_email, +) from src.common.constants import ( + FRONTEND_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, @@ -32,9 +37,18 @@ get_password_hash, verify_password, ) -from .models import AccountType, PasswordReset, User +from .models import ( + UNVERIFIED_TIER_ID, + AccountType, + EmailVerification, + PasswordReset, + User, +) router = APIRouter(prefix="/auth", tags=["auth"]) +routerWithAuth = APIRouter( + prefix="/auth", tags=["auth"], dependencies=[Depends(add_current_user)] +) ####################### # username & password # @@ -43,7 +57,10 @@ @router.post("/signup") def sign_up( - data: SignUpData, response: Response, session=Depends(get_session) + data: SignUpData, + response: Response, + background_task: BackgroundTasks, + session=Depends(get_session), ) -> Token: existing_user = session.scalars( select(User).where(User.email == data.email) @@ -55,6 +72,8 @@ def sign_up( email=data.email, hashed_password=get_password_hash(data.password), account_type=AccountType.NORMAL, + verified=False, + tier_id=UNVERIFIED_TIER_ID, ) session.add(new_user) session.commit() @@ -70,6 +89,13 @@ def sign_up( ) ) + code = str(uuid4()) + email_validation = EmailVerification(user_id=new_user.id, code=code, used=False) + session.add(email_validation) + session.commit() + verification_link = f"{FRONTEND_URL}/verify-email?code={code}" + background_task.add_task(send_verification_email, data.email, verification_link) + return create_token(new_user, response) @@ -85,6 +111,79 @@ def log_in( return create_token(user, response) +@routerWithAuth.put("/email-verification") +def complete_email_verification( + user: Annotated[User, Depends(get_current_user)], + code: str, + response: Response, + session=Depends(get_session), +) -> Token: + email_verification = session.scalar( + select(EmailVerification) + .where(EmailVerification.code == code) + .where(EmailVerification.user_id == user.id) # noqa: E712 + ) + if not email_verification: + raise HTTPException(HTTPStatus.NOT_FOUND) + elif email_verification.used: + print( + f"""ERROR: Attempt to reuse an old email verification code {code} for user with ID {email_verification.user_id}""" + ) + raise HTTPException(HTTPStatus.BAD_REQUEST) + + user = session.scalar( + select(User) + .where(User.id == email_verification.user_id) + .options( + selectinload(User.categories), + selectinload(User.tier), + selectinload(User.usage), + ) + ) + + if user.verified and user.tier_id != UNVERIFIED_TIER_ID: + print( + f"""ERROR: Attempt to verify email of user with ID {user.id} who is already verified""" + ) + raise HTTPException(HTTPStatus.CONFLICT) + + user.verified = True + user.tier_id = 1 + email_verification.used = True + session.add(user) + session.add(email_verification) + session.commit() + session.refresh(user) + + token = create_token(user, response) + + return token + + +@routerWithAuth.post("/email-verification") +def resend_verification_email( + user: Annotated[User, Depends(get_current_user)], + background_task: BackgroundTasks, + session=Depends(get_session), +): + existing_email_verifications = session.scalars( + select(EmailVerification).where(EmailVerification.user_id == user.id) + ) + for email_verification in existing_email_verifications: + email_verification.used = True + session.add(email_verification) + session.commit() + + code = str(uuid4()) + email_validation = EmailVerification(user_id=user.id, code=code, used=False) + session.add(email_validation) + session.commit() + verification_link = f"{FRONTEND_URL}/verify-email?code={code}" + background_task.add_task(send_verification_email, user.email, verification_link) + + return + + ####################### # google auth # ####################### @@ -144,11 +243,6 @@ def auth_google( return token -routerWithAuth = APIRouter( - prefix="/auth", tags=["auth"], dependencies=[Depends(add_current_user)] -) - - ####################### # Reset password # ####################### diff --git a/backend/src/auth/schemas.py b/backend/src/auth/schemas.py index c141a7ca..602621f9 100644 --- a/backend/src/auth/schemas.py +++ b/backend/src/auth/schemas.py @@ -20,6 +20,7 @@ class UserPublic(BaseModel): usage: UsageDTO | None = None tier: TierDTO + verified: bool class Token(BaseModel): diff --git a/backend/src/auth/utils.py b/backend/src/auth/utils.py index 68d4ef89..8b09bcfe 100644 --- a/backend/src/auth/utils.py +++ b/backend/src/auth/utils.py @@ -28,3 +28,42 @@ def send_reset_password_email(email: str, code: str): "Reset your password", f"Here is the link to reset your password.\n{FRONTEND_URL}/reset-password?code={code}", ) + + +def send_verification_email(receiving_email_addr: str, verification_link: str): + subject = "Verify Your Email for Jippy ✨" + plain_message = ( + "Hi there,\n\n" + "Thank you for signing up for Jippy! Please verify your email by clicking the link below:\n" + f"{verification_link}\n\n" + "If you didn't sign up, please ignore this email.\n\n" + "Best,\nThe Jippy Team" + ) + + # HTML message with a button + html_message = f""" + + +

Welcome to Jippy!

+

Thanks for signing up. Click the button below to verify your email address:

+ + Verify My Email + +

If the button doesn't work, you can also copy and paste the following link into your browser:

+

{verification_link}

+

If you didn't sign up, please ignore this email.

+

Best,
The Jippy Team

+ + + """ + + send_email(receiving_email_addr, subject, plain_message, html_message) diff --git a/backend/src/essays/dependencies.py b/backend/src/essays/dependencies.py new file mode 100644 index 00000000..c6fb1e52 --- /dev/null +++ b/backend/src/essays/dependencies.py @@ -0,0 +1,29 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import Depends, HTTPException +from src.auth.dependencies import get_current_user +from src.auth.models import User +from src.common.dependencies import get_session +from src.limits.models import Usage + + +def has_essay_tries_left( + user: Annotated[User, Depends(get_current_user)], + session=Depends(get_session), +): + usage = session.get(Usage, user.id) + if not usage: + usage = Usage(user_id=user.id) + # This is inefficient, refactor in the future. + session.add(usage) + session.commit() + + user_tier_limit = user.tier.essay_limit + user_essay_usage = usage.essays + if user_tier_limit - user_essay_usage <= 0: + raise HTTPException(HTTPStatus.TOO_MANY_REQUESTS) + + usage.essays += 1 + session.add(usage) + session.commit() diff --git a/backend/src/essays/router.py b/backend/src/essays/router.py index 07810f0d..0f392027 100644 --- a/backend/src/essays/router.py +++ b/backend/src/essays/router.py @@ -6,6 +6,7 @@ from src.auth.dependencies import get_current_user from src.auth.models import User from src.common.dependencies import get_session +from src.essays.dependencies import has_essay_tries_left from src.essays.models import ( Comment, CommentAnalysis, @@ -31,6 +32,7 @@ def create_essay( data: EssayCreate, user: Annotated[User, Depends(get_current_user)], session: Annotated[Session, Depends(get_session)], + _=Depends(has_essay_tries_left), ) -> EssayCreateDTO: essay = Essay(question=data.question, user_id=user.id) diff --git a/backend/src/limits/models.py b/backend/src/limits/models.py index fd99eb60..e9be11a4 100644 --- a/backend/src/limits/models.py +++ b/backend/src/limits/models.py @@ -8,13 +8,15 @@ class TierNames(str, Enum): FREE = "FREE" ADMIN = "ADMIN" PREMIUM = "PREMIUM" + UNVERIFIED = "UNVERIFIED" class Usage(Base): __tablename__ = "usage" user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) - gp_question_asked: Mapped[int] + gp_question_asked: Mapped[int] = mapped_column(default=0, server_default="0") + essays: Mapped[int] = mapped_column(default=0, server_default="0") class Tier(Base): @@ -24,3 +26,4 @@ class Tier(Base): tier_name: Mapped[TierNames] label: Mapped[str] gp_question_limit: Mapped[int] + essay_limit: Mapped[int] = mapped_column(server_default="0") diff --git a/backend/src/utils/mail.py b/backend/src/utils/mail.py index e217c1e7..27d99d0f 100644 --- a/backend/src/utils/mail.py +++ b/backend/src/utils/mail.py @@ -11,6 +11,7 @@ def send_email( receiving_email_addr: str, subject: str, message: str, + html_message: str | None = None, ): context = ssl.create_default_context() with smtplib.SMTP_SSL("smtp.gmail.com", PORT, context=context) as server: @@ -22,6 +23,9 @@ def send_email( msg["To"] = receiving_email_addr msg.set_content(message) + if html_message: + msg.add_alternative(html_message, subtype="html") + server.sendmail(GOOGLE_EMAIL, receiving_email_addr, msg.as_string()) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index c3edc84e..50b7af58 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/card"; import JippyIcon from "@/public/jippy-icon/jippy-icon-sm"; import { useUserStore } from "@/store/user/user-store-provider"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; import { getNextMonday, toQueryDateFriendly } from "@/utils/date"; const MAX_GP_QUESTION_LEN: number = 200; // max character count @@ -37,6 +38,32 @@ interface AskPageProps { isLoading: boolean; } +const LimitAlert = ({ + warningText, + isRedAlert, +}: { + warningText: string; + isRedAlert: boolean; +}) => { + return ( + +
+ +
+ + {warningText} + +
+ ); +}; + const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { const router = useRouter(); const [questionInput, setQuestionInput] = useState(""); @@ -44,6 +71,8 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { const [errorMsg, setErrorMsg] = useState(null); const user = useUserStore((store) => store.user); const setLoggedIn = useUserStore((store) => store.setLoggedIn); + const isUserUnverified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; if (!user) { // This should be impossible. @@ -124,52 +153,29 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => {

Ask Jippy a General Paper exam question

- {hasTriesLeft ? ( - -
- -
- - You have {triesLeft}{" "} - {triesLeft === 1 ? "question" : "questions"} left. It will - reset on 4 Nov 2024 12:00AM. - -
+ {isUserUnverified ? ( + + ) : hasTriesLeft ? ( + ) : ( - -
- -
- - You've reached the question limit. It will reset on{" "} - {toQueryDateFriendly(getNextMonday())} 12:00AM. - -
- )} - - {errorMsg && ( - -
- -
- - {errorMsg} - -
+ )}
setQuestionInput(event.target.value)} @@ -178,9 +184,10 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { />
{paragraphs.map((paragraph, index) => ( @@ -185,10 +227,27 @@ const EssayFeedbackPage = () => { anything else than providing you feedback.
+ {isUserUnverified && !errorMessage && ( +
+ +
+ +
+ + Verify your email to gain access to essay feedback. + +
+
+ )}
-
+
{ @@ -213,6 +273,7 @@ const EssayFeedbackPage = () => { @@ -222,7 +283,13 @@ const EssayFeedbackPage = () => { )} />
- diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 439f69de..94964e0a 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -1,16 +1,14 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { UserPublic } from "@/client"; import PricingTable from "@/components/billing/pricing-table"; -import Chip from "@/components/display/chip"; -import { Button } from "@/components/ui/button"; +import SubscriptionCard from "@/components/billing/subscription-card"; import { useToast } from "@/hooks/use-toast"; import { useCreateStripeCheckoutSession, - useCreateStripeCustomerPortalSession, useDowngradeSubscription, } from "@/queries/billing"; import { useUserStore } from "@/store/user/user-store-provider"; @@ -21,17 +19,19 @@ import { tierIDToTierFeature, tierIDToTierName, TierPrice, + UNVERIFIED_TIER_ID, } from "@/types/billing"; -const FREE_TIER_ID = 1; -const TIER_STATUS_ACTIVE = "active"; - const getPriceButtonText = ( priceTierId: number, user: UserPublic | undefined, ) => { const userTierId = user?.tier_id || 1; - if (priceTierId == userTierId) { + const isUserUnverified = + user?.verified === false || userTierId === UNVERIFIED_TIER_ID; + if (isUserUnverified) { + return "Not allowed"; + } else if (priceTierId == userTierId) { return "Current"; } else if (priceTierId > userTierId) { return "Upgrade"; @@ -44,26 +44,20 @@ const getPriceButtonText = ( } }; -const toPascalCase = (string: string) => { - return string.charAt(0).toUpperCase() + string.slice(1); -}; - const Page = () => { const user = useUserStore((store) => store.user); const stripeCheckoutMutation = useCreateStripeCheckoutSession(); - const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); const downgradeSubscription = useDowngradeSubscription(); const billingPath = usePathname(); const searchParams = useSearchParams(); + const isUserUnverified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; let isSuccess = searchParams.get("success") === "true"; let stripeSessionId = searchParams.get("session_id"); let isCancelled = searchParams.get("cancelled") === "true"; const router = useRouter(); - const [userTier, setUserTier] = useState(""); - const [userTierStatus, setUserTierStatus] = useState(""); - const { toast } = useToast(); // Display payment status toast for 5 secs const PAYMENT_TOAST_DURATION = 5000; @@ -72,14 +66,10 @@ const Page = () => { downgradeSubscription.mutate(); }; - const onClickManageSubscription = () => { - stripeCustomerPortalMutation.mutate(); - }; - const jippyTiers = [ { tierName: JippyTier.Free, - isButtonDisabled: user?.tier_id == JippyTierID.Free, + isButtonDisabled: isUserUnverified || user?.tier_id == JippyTierID.Free, buttonText: getPriceButtonText(JippyTierID.Free, user), onClickBuy: onClickDowngradeSubscription, price: TierPrice.Free, @@ -88,7 +78,8 @@ const Page = () => { }, { tierName: JippyTier.Premium, - isButtonDisabled: user?.tier_id == JippyTierID.Premium, + isButtonDisabled: + isUserUnverified || user?.tier_id == JippyTierID.Premium, buttonText: getPriceButtonText(JippyTierID.Premium, user), onClickBuy: () => { stripeCheckoutMutation.mutate({ @@ -140,16 +131,6 @@ const Page = () => { } }, [isSuccess, isCancelled, stripeSessionId]); - useEffect(() => { - if (user?.subscription) { - setUserTierStatus(user.subscription.status); - setUserTier(tierIDToTierName(user.tier_id || FREE_TIER_ID)); - } else { - setUserTier(tierIDToTierName(FREE_TIER_ID)); - setUserTierStatus(TIER_STATUS_ACTIVE); - } - }, [user?.subscription, user?.tier_id]); - return ( user && (
@@ -158,25 +139,7 @@ const Page = () => {
-

Your Tier

-
-

{userTier} Tier:

- -
- {user?.subscription && ( - - )} +

Our Tiers

diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx new file mode 100644 index 00000000..8b31e084 --- /dev/null +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { HttpStatusCode } from "axios"; +import { CheckCircleIcon, CircleX } from "lucide-react"; + +import { completeEmailVerificationAuthEmailVerificationPut } from "@/client/services.gen"; +import { Box } from "@/components/ui/box"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useUserStore } from "@/store/user/user-store-provider"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; + +const VerifyEmail = () => { + const VERIFY_SUCCESS_DELAY = 1; + const VERIFY_ERROR_DELAY = 5; + + const user = useUserStore((store) => store.user); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(true); + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + const isUserUnverified = + !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID; + const [isVerifySuccess, setIsVerifySuccess] = useState(false); + const [postVerifyTitle, setPostVerifyTitle] = useState( + "Verified! Redirecting you to Jippy...", + ); + const [postVerifySubtitle, setPostVerifySubtitle] = useState( + "All done! You'll be redirected to Jippy soon. ", + ); + + const redirectAfterVerify = async () => { + // Redirect user to Jippy home page after verifying email + router.replace("/", { scroll: false }); + }; + + useEffect(() => { + let timeout: NodeJS.Timeout | null = null; + if (code && isUserUnverified) { + (async () => { + const response = + await completeEmailVerificationAuthEmailVerificationPut({ + query: { code }, + }); + // There is some problem where this function runs twice, causing an error + // on the second run since the email verification is used. + if (response.data) { + setIsVerifySuccess(true); + setIsLoading(false); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_SUCCESS_DELAY * 1000, + ); + } else if (response.status == HttpStatusCode.Conflict) { + // User is already verified + setIsVerifySuccess(true); + setPostVerifySubtitle("Relax, you're already verified! :) "); + setIsLoading(false); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_SUCCESS_DELAY * 1000, + ); + console.log("WARNING: User is already verified"); + } else if (response.status == HttpStatusCode.BadRequest) { + // Email verification has already been used + console.log("ERROR: Reusing old email verification code"); + setIsVerifySuccess(false); + setPostVerifyTitle("Invalid verification link"); + setPostVerifySubtitle( + "Check your email again! Please click the latest verification link", + ); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); + } else if (response.error) { + if (response.status == HttpStatusCode.NotFound) { + console.error("ERROR: Invalid verification code"); + setIsVerifySuccess(false); + setPostVerifyTitle("Invalid verification link"); + setPostVerifySubtitle( + "Check whether you entered the correct verification link.\nNote: Never click on a verification link that is not sent by us", + ); + setIsLoading(false); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_ERROR_DELAY * 1000, + ); + } else { + console.error("ERROR while verifying email"); + setIsVerifySuccess(false); + setPostVerifyTitle("Verification error"); + setPostVerifySubtitle( + "We're very sorry, something went wrong while verifying you. Please try again later.", + ); + setIsLoading(false); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_ERROR_DELAY * 1000, + ); + } + } + })(); + } else if (!isUserUnverified) { + console.log("WARNING: User is already verified"); + // User is already verified, don't make the backend verify again + setIsVerifySuccess(true); + setPostVerifySubtitle("Relax, you're already verified! :) "); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + } + + return () => { + if (timeout) { + // Cleanup redirect timeout on unmount of the page + clearTimeout(timeout); + } + }; + }, [code, isUserUnverified, redirectAfterVerify]); + + return ( + + + + + {isLoading ? "Verifying your email" : postVerifyTitle} + + + + + + {isLoading ? ( + + ) : isVerifySuccess ? ( + + ) : ( + + )} + + + + {isLoading + ? "Hang tight! We're verifying your email. This shouldn't take long." + : postVerifySubtitle} + + {!isLoading && isVerifySuccess && ( + + Redirect now + + )} + + + + + ); +}; + +export default VerifyEmail; diff --git a/frontend/app/(unauthenticated)/login/page.tsx b/frontend/app/(unauthenticated)/login/page.tsx index 3929dc14..a4763175 100644 --- a/frontend/app/(unauthenticated)/login/page.tsx +++ b/frontend/app/(unauthenticated)/login/page.tsx @@ -58,7 +58,11 @@ function LoginPage() { } else { setIsError(false); setLoggedIn(response.data.user); - router.push("/"); + if (window.history?.length && window.history.length > 1) { + router.back(); + } else { + router.replace("/", { scroll: false }); + } } }; diff --git a/frontend/client/schemas.gen.ts b/frontend/client/schemas.gen.ts index 8a0f9558..c34ffc76 100644 --- a/frontend/client/schemas.gen.ts +++ b/frontend/client/schemas.gen.ts @@ -1783,7 +1783,7 @@ export const TierDTOSchema = { export const TierNamesSchema = { type: 'string', - enum: ['FREE', 'ADMIN', 'PREMIUM'], + enum: ['FREE', 'ADMIN', 'PREMIUM', 'UNVERIFIED'], title: 'TierNames' } as const; @@ -1873,10 +1873,14 @@ export const UserPublicSchema = { }, tier: { '$ref': '#/components/schemas/TierDTO' + }, + verified: { + type: 'boolean', + title: 'Verified' } }, type: 'object', - required: ['id', 'email', 'last_accessed', 'categories', 'tier'], + required: ['id', 'email', 'last_accessed', 'categories', 'tier', 'verified'], title: 'UserPublic' } as const; diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index 20f41e2b..fb929936 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type Options, urlSearchParamsBodySerializer } from './client'; -import type { SignUpAuthSignupPostData, SignUpAuthSignupPostError, SignUpAuthSignupPostResponse, LogInAuthLoginPostData, LogInAuthLoginPostError, LogInAuthLoginPostResponse, LoginGoogleAuthLoginGoogleGetError, LoginGoogleAuthLoginGoogleGetResponse, AuthGoogleAuthGoogleGetData, AuthGoogleAuthGoogleGetError, AuthGoogleAuthGoogleGetResponse, StripeWebhookBillingWebhookPostData, StripeWebhookBillingWebhookPostError, StripeWebhookBillingWebhookPostResponse, GetUserAuthSessionGetData, GetUserAuthSessionGetError, GetUserAuthSessionGetResponse, LogoutAuthLogoutGetData, LogoutAuthLogoutGetError, LogoutAuthLogoutGetResponse, RequestPasswordResetAuthPasswordResetPostData, RequestPasswordResetAuthPasswordResetPostError, RequestPasswordResetAuthPasswordResetPostResponse, CompletePasswordResetAuthPasswordResetPutData, CompletePasswordResetAuthPasswordResetPutError, CompletePasswordResetAuthPasswordResetPutResponse, ChangePasswordAuthChangePasswordPutData, ChangePasswordAuthChangePasswordPutError, ChangePasswordAuthChangePasswordPutResponse, CreateCheckoutSessionBillingCreateCheckoutSessionPostData, CreateCheckoutSessionBillingCreateCheckoutSessionPostError, CreateCheckoutSessionBillingCreateCheckoutSessionPostResponse, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostData, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostError, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostResponse, DowngradeSubscriptionBillingDowngradeSubscriptionPutData, DowngradeSubscriptionBillingDowngradeSubscriptionPutError, DowngradeSubscriptionBillingDowngradeSubscriptionPutResponse, GetCategoriesCategoriesGetData, GetCategoriesCategoriesGetError, GetCategoriesCategoriesGetResponse, UpdateProfileProfilePutData, UpdateProfileProfilePutError, UpdateProfileProfilePutResponse, GetEventsEventsGetData, GetEventsEventsGetError, GetEventsEventsGetResponse, GetEventEventsIdGetData, GetEventEventsIdGetError, GetEventEventsIdGetResponse, GetEventNotesEventsIdNotesGetData, GetEventNotesEventsIdNotesGetError, GetEventNotesEventsIdNotesGetResponse, ReadEventEventsIdReadPostData, ReadEventEventsIdReadPostError, ReadEventEventsIdReadPostResponse, SearchWhateverEventsSearchGetData, SearchWhateverEventsSearchGetError, SearchWhateverEventsSearchGetResponse, AddBookmarkEventsIdBookmarksPostData, AddBookmarkEventsIdBookmarksPostError, AddBookmarkEventsIdBookmarksPostResponse, DeleteBookmarkEventsIdBookmarksDeleteData, DeleteBookmarkEventsIdBookmarksDeleteError, DeleteBookmarkEventsIdBookmarksDeleteResponse, GetUserQuestionsUserQuestionsGetData, GetUserQuestionsUserQuestionsGetError, GetUserQuestionsUserQuestionsGetResponse, CreateUserQuestionUserQuestionsPostData, CreateUserQuestionUserQuestionsPostError, CreateUserQuestionUserQuestionsPostResponse, GetUserQuestionUserQuestionsIdGetData, GetUserQuestionUserQuestionsIdGetError, GetUserQuestionUserQuestionsIdGetResponse, ClassifyQuestionUserQuestionsClassifyPostData, ClassifyQuestionUserQuestionsClassifyPostError, ClassifyQuestionUserQuestionsClassifyPostResponse, GetAllNotesNotesGetData, GetAllNotesNotesGetError, GetAllNotesNotesGetResponse, CreateNoteNotesPostData, CreateNoteNotesPostError, CreateNoteNotesPostResponse, UpdateNoteNotesIdPutData, UpdateNoteNotesIdPutError, UpdateNoteNotesIdPutResponse, DeleteNoteNotesIdDeleteData, DeleteNoteNotesIdDeleteError, DeleteNoteNotesIdDeleteResponse, GetPointNotesPointsIdNotesGetData, GetPointNotesPointsIdNotesGetError, GetPointNotesPointsIdNotesGetResponse, UpsertLikeLikesPostData, UpsertLikeLikesPostError, UpsertLikeLikesPostResponse, CreateEssayEssaysPostData, CreateEssayEssaysPostError, CreateEssayEssaysPostResponse, GetEssaysEssaysGetData, GetEssaysEssaysGetError, GetEssaysEssaysGetResponse, GetEssayEssaysIdGetData, GetEssayEssaysIdGetError, GetEssayEssaysIdGetResponse, DeleteEssayEssaysIdDeleteData, DeleteEssayEssaysIdDeleteError, DeleteEssayEssaysIdDeleteResponse, GetArticlesArticlesGetData, GetArticlesArticlesGetError, GetArticlesArticlesGetResponse, GetTopArticlesArticlesTopGetData, GetTopArticlesArticlesTopGetError, GetTopArticlesArticlesTopGetResponse, GetArticleArticlesIdGetData, GetArticleArticlesIdGetError, GetArticleArticlesIdGetResponse, AddBookmarkArticlesIdBookmarksPostData, AddBookmarkArticlesIdBookmarksPostError, AddBookmarkArticlesIdBookmarksPostResponse, DeleteBookmarkArticlesIdBookmarksDeleteData, DeleteBookmarkArticlesIdBookmarksDeleteError, DeleteBookmarkArticlesIdBookmarksDeleteResponse, ReadArticleArticlesIdReadPostData, ReadArticleArticlesIdReadPostError, ReadArticleArticlesIdReadPostResponse, GetSubscriptionSubscriptionsIdGetData, GetSubscriptionSubscriptionsIdGetError, GetSubscriptionSubscriptionsIdGetResponse, GetSubscriptionStatusSubscriptionsIdStatusGetData, GetSubscriptionStatusSubscriptionsIdStatusGetError, GetSubscriptionStatusSubscriptionsIdStatusGetResponse } from './types.gen'; +import type { SignUpAuthSignupPostData, SignUpAuthSignupPostError, SignUpAuthSignupPostResponse, LogInAuthLoginPostData, LogInAuthLoginPostError, LogInAuthLoginPostResponse, CompleteEmailVerificationAuthEmailVerificationPutData, CompleteEmailVerificationAuthEmailVerificationPutError, CompleteEmailVerificationAuthEmailVerificationPutResponse, ResendVerificationEmailAuthEmailVerificationPostData, ResendVerificationEmailAuthEmailVerificationPostError, ResendVerificationEmailAuthEmailVerificationPostResponse, LoginGoogleAuthLoginGoogleGetError, LoginGoogleAuthLoginGoogleGetResponse, AuthGoogleAuthGoogleGetData, AuthGoogleAuthGoogleGetError, AuthGoogleAuthGoogleGetResponse, RequestPasswordResetAuthPasswordResetPostData, RequestPasswordResetAuthPasswordResetPostError, RequestPasswordResetAuthPasswordResetPostResponse, CompletePasswordResetAuthPasswordResetPutData, CompletePasswordResetAuthPasswordResetPutError, CompletePasswordResetAuthPasswordResetPutResponse, StripeWebhookBillingWebhookPostData, StripeWebhookBillingWebhookPostError, StripeWebhookBillingWebhookPostResponse, GetUserAuthSessionGetData, GetUserAuthSessionGetError, GetUserAuthSessionGetResponse, LogoutAuthLogoutGetData, LogoutAuthLogoutGetError, LogoutAuthLogoutGetResponse, ChangePasswordAuthChangePasswordPutData, ChangePasswordAuthChangePasswordPutError, ChangePasswordAuthChangePasswordPutResponse, CreateCheckoutSessionBillingCreateCheckoutSessionPostData, CreateCheckoutSessionBillingCreateCheckoutSessionPostError, CreateCheckoutSessionBillingCreateCheckoutSessionPostResponse, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostData, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostError, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostResponse, DowngradeSubscriptionBillingDowngradeSubscriptionPutData, DowngradeSubscriptionBillingDowngradeSubscriptionPutError, DowngradeSubscriptionBillingDowngradeSubscriptionPutResponse, GetCategoriesCategoriesGetData, GetCategoriesCategoriesGetError, GetCategoriesCategoriesGetResponse, UpdateProfileProfilePutData, UpdateProfileProfilePutError, UpdateProfileProfilePutResponse, GetEventsEventsGetData, GetEventsEventsGetError, GetEventsEventsGetResponse, GetEventEventsIdGetData, GetEventEventsIdGetError, GetEventEventsIdGetResponse, GetEventNotesEventsIdNotesGetData, GetEventNotesEventsIdNotesGetError, GetEventNotesEventsIdNotesGetResponse, ReadEventEventsIdReadPostData, ReadEventEventsIdReadPostError, ReadEventEventsIdReadPostResponse, SearchWhateverEventsSearchGetData, SearchWhateverEventsSearchGetError, SearchWhateverEventsSearchGetResponse, AddBookmarkEventsIdBookmarksPostData, AddBookmarkEventsIdBookmarksPostError, AddBookmarkEventsIdBookmarksPostResponse, DeleteBookmarkEventsIdBookmarksDeleteData, DeleteBookmarkEventsIdBookmarksDeleteError, DeleteBookmarkEventsIdBookmarksDeleteResponse, GetUserQuestionsUserQuestionsGetData, GetUserQuestionsUserQuestionsGetError, GetUserQuestionsUserQuestionsGetResponse, CreateUserQuestionUserQuestionsPostData, CreateUserQuestionUserQuestionsPostError, CreateUserQuestionUserQuestionsPostResponse, GetUserQuestionUserQuestionsIdGetData, GetUserQuestionUserQuestionsIdGetError, GetUserQuestionUserQuestionsIdGetResponse, ClassifyQuestionUserQuestionsClassifyPostData, ClassifyQuestionUserQuestionsClassifyPostError, ClassifyQuestionUserQuestionsClassifyPostResponse, GetAllNotesNotesGetData, GetAllNotesNotesGetError, GetAllNotesNotesGetResponse, CreateNoteNotesPostData, CreateNoteNotesPostError, CreateNoteNotesPostResponse, UpdateNoteNotesIdPutData, UpdateNoteNotesIdPutError, UpdateNoteNotesIdPutResponse, DeleteNoteNotesIdDeleteData, DeleteNoteNotesIdDeleteError, DeleteNoteNotesIdDeleteResponse, GetPointNotesPointsIdNotesGetData, GetPointNotesPointsIdNotesGetError, GetPointNotesPointsIdNotesGetResponse, UpsertLikeLikesPostData, UpsertLikeLikesPostError, UpsertLikeLikesPostResponse, CreateEssayEssaysPostData, CreateEssayEssaysPostError, CreateEssayEssaysPostResponse, GetEssaysEssaysGetData, GetEssaysEssaysGetError, GetEssaysEssaysGetResponse, GetEssayEssaysIdGetData, GetEssayEssaysIdGetError, GetEssayEssaysIdGetResponse, DeleteEssayEssaysIdDeleteData, DeleteEssayEssaysIdDeleteError, DeleteEssayEssaysIdDeleteResponse, GetArticlesArticlesGetData, GetArticlesArticlesGetError, GetArticlesArticlesGetResponse, GetTopArticlesArticlesTopGetData, GetTopArticlesArticlesTopGetError, GetTopArticlesArticlesTopGetResponse, GetArticleArticlesIdGetData, GetArticleArticlesIdGetError, GetArticleArticlesIdGetResponse, AddBookmarkArticlesIdBookmarksPostData, AddBookmarkArticlesIdBookmarksPostError, AddBookmarkArticlesIdBookmarksPostResponse, DeleteBookmarkArticlesIdBookmarksDeleteData, DeleteBookmarkArticlesIdBookmarksDeleteError, DeleteBookmarkArticlesIdBookmarksDeleteResponse, ReadArticleArticlesIdReadPostData, ReadArticleArticlesIdReadPostError, ReadArticleArticlesIdReadPostResponse, GetSubscriptionSubscriptionsIdGetData, GetSubscriptionSubscriptionsIdGetError, GetSubscriptionSubscriptionsIdGetResponse, GetSubscriptionStatusSubscriptionsIdStatusGetData, GetSubscriptionStatusSubscriptionsIdStatusGetError, GetSubscriptionStatusSubscriptionsIdStatusGetResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -26,6 +26,22 @@ export const logInAuthLoginPost = (options url: '/auth/login' }); }; +/** + * Complete Email Verification + */ +export const completeEmailVerificationAuthEmailVerificationPut = (options: Options) => { return (options?.client ?? client).put({ + ...options, + url: '/auth/email-verification' +}); }; + +/** + * Resend Verification Email + */ +export const resendVerificationEmailAuthEmailVerificationPost = (options?: Options) => { return (options?.client ?? client).post({ + ...options, + url: '/auth/email-verification' +}); }; + /** * Login Google */ @@ -43,43 +59,43 @@ export const authGoogleAuthGoogleGet = (op }); }; /** - * Stripe Webhook + * Request Password Reset */ -export const stripeWebhookBillingWebhookPost = (options?: Options) => { return (options?.client ?? client).post({ +export const requestPasswordResetAuthPasswordResetPost = (options: Options) => { return (options?.client ?? client).post({ ...options, - url: '/billing/webhook' + url: '/auth/password-reset' }); }; /** - * Get User + * Complete Password Reset */ -export const getUserAuthSessionGet = (options?: Options) => { return (options?.client ?? client).get({ +export const completePasswordResetAuthPasswordResetPut = (options: Options) => { return (options?.client ?? client).put({ ...options, - url: '/auth/session' + url: '/auth/password-reset' }); }; /** - * Logout + * Stripe Webhook */ -export const logoutAuthLogoutGet = (options?: Options) => { return (options?.client ?? client).get({ +export const stripeWebhookBillingWebhookPost = (options?: Options) => { return (options?.client ?? client).post({ ...options, - url: '/auth/logout' + url: '/billing/webhook' }); }; /** - * Request Password Reset + * Get User */ -export const requestPasswordResetAuthPasswordResetPost = (options: Options) => { return (options?.client ?? client).post({ +export const getUserAuthSessionGet = (options?: Options) => { return (options?.client ?? client).get({ ...options, - url: '/auth/password-reset' + url: '/auth/session' }); }; /** - * Complete Password Reset + * Logout */ -export const completePasswordResetAuthPasswordResetPut = (options: Options) => { return (options?.client ?? client).put({ +export const logoutAuthLogoutGet = (options?: Options) => { return (options?.client ?? client).get({ ...options, - url: '/auth/password-reset' + url: '/auth/logout' }); }; /** diff --git a/frontend/client/types.gen.ts b/frontend/client/types.gen.ts index 4fc8fbc0..7da1e3c7 100644 --- a/frontend/client/types.gen.ts +++ b/frontend/client/types.gen.ts @@ -410,7 +410,7 @@ export type TierDTO = { gp_question_limit: number; }; -export type TierNames = 'FREE' | 'ADMIN' | 'PREMIUM'; +export type TierNames = 'FREE' | 'ADMIN' | 'PREMIUM' | 'UNVERIFIED'; export type Token = { access_token: string; @@ -432,6 +432,7 @@ export type UserPublic = { subscription?: (SubscriptionDTO | null); usage?: (UsageDTO | null); tier: TierDTO; + verified: boolean; }; export type UserQuestionDTO = { @@ -500,6 +501,22 @@ export type LogInAuthLoginPostResponse = (Token); export type LogInAuthLoginPostError = (HTTPValidationError); +export type CompleteEmailVerificationAuthEmailVerificationPutData = { + query: { + code: string; + }; +}; + +export type CompleteEmailVerificationAuthEmailVerificationPutResponse = (Token); + +export type CompleteEmailVerificationAuthEmailVerificationPutError = (HTTPValidationError); + +export type ResendVerificationEmailAuthEmailVerificationPostData = unknown; + +export type ResendVerificationEmailAuthEmailVerificationPostResponse = (unknown); + +export type ResendVerificationEmailAuthEmailVerificationPostError = (HTTPValidationError); + export type LoginGoogleAuthLoginGoogleGetResponse = (unknown); export type LoginGoogleAuthLoginGoogleGetError = unknown; @@ -514,6 +531,25 @@ export type AuthGoogleAuthGoogleGetResponse = (Token); export type AuthGoogleAuthGoogleGetError = (HTTPValidationError); +export type RequestPasswordResetAuthPasswordResetPostData = { + body: PasswordResetRequestData; +}; + +export type RequestPasswordResetAuthPasswordResetPostResponse = (unknown); + +export type RequestPasswordResetAuthPasswordResetPostError = (HTTPValidationError); + +export type CompletePasswordResetAuthPasswordResetPutData = { + body: PasswordResetCompleteData; + query: { + code: string; + }; +}; + +export type CompletePasswordResetAuthPasswordResetPutResponse = (unknown); + +export type CompletePasswordResetAuthPasswordResetPutError = (HTTPValidationError); + export type StripeWebhookBillingWebhookPostData = { headers?: { 'Stripe-Signature'?: string; @@ -536,25 +572,6 @@ export type LogoutAuthLogoutGetResponse = (unknown); export type LogoutAuthLogoutGetError = (HTTPValidationError); -export type RequestPasswordResetAuthPasswordResetPostData = { - body: PasswordResetRequestData; -}; - -export type RequestPasswordResetAuthPasswordResetPostResponse = (unknown); - -export type RequestPasswordResetAuthPasswordResetPostError = (HTTPValidationError); - -export type CompletePasswordResetAuthPasswordResetPutData = { - body: PasswordResetCompleteData; - query: { - code: string; - }; -}; - -export type CompletePasswordResetAuthPasswordResetPutResponse = (unknown); - -export type CompletePasswordResetAuthPasswordResetPutError = (HTTPValidationError); - export type ChangePasswordAuthChangePasswordPutData = { body: PasswordResetMoreCompleteData; }; diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx new file mode 100644 index 00000000..d605ac6e --- /dev/null +++ b/frontend/components/billing/subscription-card.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useMemo } from "react"; +import { CalendarIcon, CircleAlert, CircleDollarSignIcon } from "lucide-react"; + +import { UserPublic } from "@/client/types.gen"; +import Chip from "@/components/display/chip"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; +import { useCreateStripeCustomerPortalSession } from "@/queries/billing"; +import { + JippyTier, + JippyTierID, + stripeSubscriptionStatusToTierStatus, + SubscriptionPeriod, + tierIDToPrice, + tierIDToTierName, +} from "@/types/billing"; + +const MAX_CARD_HEIGHT_PX = 400; +const TIER_STATUS_ACTIVE = "active"; +const UNVERIFIED_TIER_ID = 4; + +export interface SubscriptionInfo { + user: UserPublic | undefined; +} + +const getDateFrom = (dateString: string | null | undefined) => { + if (dateString) { + return new Date(dateString); + } + return undefined; +}; + +const toPascalCase = (string: string) => { + return string.charAt(0).toUpperCase() + string.slice(1); +}; + +const SubscriptionDetail = ({ + DetailIcon, + detailDescription, +}: { + DetailIcon: React.ComponentType>; + detailDescription: string; +}) => { + return ( +
+ +
+ {detailDescription} +
+
+ ); +}; + +const SubscriptionCard = ({ user }: SubscriptionInfo) => { + const currentTierName = useMemo(() => { + return tierIDToTierName(user?.tier_id || JippyTierID.Free); + }, [user?.tier_id]); + const tierEndDate = useMemo(() => { + return getDateFrom( + user?.subscription?.subscription_ended_date || + user?.subscription?.subscription_period_end, + ); + }, [user?.subscription]); + const tierStatus = useMemo(() => { + return toPascalCase( + stripeSubscriptionStatusToTierStatus( + user?.subscription ? user.subscription.status : TIER_STATUS_ACTIVE, + ), + ); + }, [user?.subscription]); + const tierPrice = useMemo(() => { + return tierIDToPrice(user?.tier_id || JippyTierID.Free); + }, [user?.tier_id]); + // TODO: Dynamically fetch the subscription period from Stripe if we ever support annual subscriptions + const tierSubscriptionPeriod = SubscriptionPeriod.Month; + const actionDescription = "Manage Subscription"; + + const isUserUnverified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + + const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); + + const onClickManageSubscription = () => { + stripeCustomerPortalMutation.mutate(); + }; + + return ( + + + + {/* TODO: Consider one day making an icon just for Jippy-branded subscriptions */} + + Your Jippy + + + {!isUserUnverified ? ( + <> +
+ {currentTierName} Tier + +
+ {user?.subscription && ( + + )} + + ) : ( +
+ + {JippyTier.Free} Tier + + +
+ +
+ + Unverified email. Verify your email now to enjoy full{" "} + {JippyTier.Free} Tier access. + +
+
+ )} +
+
+ {!isUserUnverified && ( + <> +
+ + + {tierEndDate && ( + + )} + + + )} +
+ ); +}; + +export default SubscriptionCard; diff --git a/frontend/components/layout/app-layout.tsx b/frontend/components/layout/app-layout.tsx index 474ff73d..8e2a5b86 100644 --- a/frontend/components/layout/app-layout.tsx +++ b/frontend/components/layout/app-layout.tsx @@ -5,23 +5,33 @@ import { useQuery } from "@tanstack/react-query"; import MobileNavbar from "@/components/navigation/mobile/mobile-navbar"; import Navbar from "@/components/navigation/navbar"; +import UnverifiedAlert from "@/components/navigation/unverified-alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Toaster } from "@/components/ui/toaster"; import { getUserProfile } from "@/queries/user"; import { useUserStore } from "@/store/user/user-store-provider"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; import ContentLayout from "./content-layout"; export const NAVBAR_HEIGHT = 84; const AppLayout = ({ children }: { children: ReactNode }) => { - const { setLoggedIn, setNotLoggedIn, setIsFetching, setIsNotFetching } = - useUserStore((state) => state); + const { + isLoggedIn, + setLoggedIn, + setNotLoggedIn, + setIsFetching, + setIsNotFetching, + user, + } = useUserStore((state) => state); const { data: userProfile, isSuccess: isUserProfileSuccess, isLoading: isUserProfileLoading, } = useQuery(getUserProfile()); + const isUserVerified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; useEffect(() => { if (isUserProfileLoading) { @@ -52,7 +62,7 @@ const AppLayout = ({ children }: { children: ReactNode }) => {
{
} > + {isLoggedIn && isUserVerified && ( +
+ +
+ )} {children} diff --git a/frontend/components/layout/content-layout.tsx b/frontend/components/layout/content-layout.tsx index e3306880..a1d30eea 100644 --- a/frontend/components/layout/content-layout.tsx +++ b/frontend/components/layout/content-layout.tsx @@ -43,21 +43,21 @@ const ContentLayout = ({ isLoading, children }: ContentLayoutProps) => { // TODO: fix all loading elements if (isLoading) return ( -
+
); if (isOnboarding) return ( -
+
{children}
); if (!isLoggedIn) return ( -
+
{children}
); @@ -116,7 +116,7 @@ const ContentLayout = ({ isLoading, children }: ContentLayoutProps) => { // For `sm` and `xs` breakpoints don't render anything return (
{children} diff --git a/frontend/components/navigation/mobile/mobile-navbar.tsx b/frontend/components/navigation/mobile/mobile-navbar.tsx index 164b933b..f33c3708 100644 --- a/frontend/components/navigation/mobile/mobile-navbar.tsx +++ b/frontend/components/navigation/mobile/mobile-navbar.tsx @@ -17,9 +17,9 @@ function MobileNavbar() { const isLoggedIn = useUserStore((state) => state.isLoggedIn); return ( - // min-h-[84px] max-h-[84px] + /* min-h-[84px] max-h-[84px] */
diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index cc18af64..f41ad12d 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -22,7 +22,7 @@ function Navbar() { const isLoggedIn = useUserStore((state) => state.isLoggedIn); return ( - // min-h-[84px] max-h-[84px] + /* min-h-[84px] max-h-[84px] */
diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx new file mode 100644 index 00000000..639d9d6d --- /dev/null +++ b/frontend/components/navigation/unverified-alert.tsx @@ -0,0 +1,49 @@ +import { CircleAlert } from "lucide-react"; + +import { resendVerificationEmailAuthEmailVerificationPost } from "@/client"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { toast } from "@/hooks/use-toast"; + +const UnverifiedAlert = () => { + const onClickResendVerification = async () => { + const response = await resendVerificationEmailAuthEmailVerificationPost(); + if (response.error) { + toast({ + variant: "destructive", + title: "Error", + description: + "Error while resending a new verification email. Please try again", + }); + console.error( + "Error while sending new verification email: ", + response.error, + ); + } + }; + + return ( + +
+ +
+
+ + Verify your email with the link we sent to you. Didn't receive + it?{" "} + + Resend + + +
+
+ ); +}; + +export default UnverifiedAlert; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 70e7e7c6..bb4304e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3902,11 +3902,6 @@ "dev": true, "license": "ISC" }, - "node_modules/embla-carousel": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", - "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==" - }, "node_modules/embla-carousel-react": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz", @@ -3919,7 +3914,12 @@ "react": "^16.8.0 || ^17.0.1 || ^18.0.0" } }, - "node_modules/embla-carousel-reactive-utils": { + "node_modules/embla-carousel-react/node_modules/embla-carousel": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", + "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==" + }, + "node_modules/embla-carousel-react/node_modules/embla-carousel-reactive-utils": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.3.0.tgz", "integrity": "sha512-EYdhhJ302SC4Lmkx8GRsp0sjUhEN4WyFXPOk0kGu9OXZSRMmcBlRgTvHcq8eKJE1bXWBsOi1T83B+BSSVZSmwQ==", diff --git a/frontend/queries/user.ts b/frontend/queries/user.ts index 2718e4b5..1d00ef16 100644 --- a/frontend/queries/user.ts +++ b/frontend/queries/user.ts @@ -6,6 +6,7 @@ import { import { changePasswordAuthChangePasswordPut, + completeEmailVerificationAuthEmailVerificationPut, getUserAuthSessionGet, updateProfileProfilePut, } from "@/client/services.gen"; @@ -75,3 +76,18 @@ export const useChangePassword = () => { }, }); }; + +export const useCompleteEmailVerification = () => { + return useMutation({ + mutationFn: ({code}: {code: string}) => { + return completeEmailVerificationAuthEmailVerificationPut({ + query: { + code + }, + }); + }, + onError: (error) => { + console.error("Error completing email verification: ", error); + } + }); +} diff --git a/frontend/types/billing.ts b/frontend/types/billing.ts index 9deb70f1..ae5881f3 100644 --- a/frontend/types/billing.ts +++ b/frontend/types/billing.ts @@ -1,3 +1,5 @@ +export const UNVERIFIED_TIER_ID = 4; + export enum JippyTier { Free = "Free", Premium = "Premium", @@ -10,12 +12,56 @@ export enum JippyTierID { Enterprise = 3, } +export enum JippyTierStatus { + Active = "active", + Paused = "paused", + Cancelled = "cancelled", + Expired = "expired", + Unknown = "", +} + +export enum SubscriptionPeriod { + Month = "month", + Year = "year", +} + export enum TierPrice { Free = 0, Premium = 3.49, Enterprise = 26.18, } +/** + * Parses a Stripe subscription status and returns a user-friendly Jippy tier status string + */ +export const stripeSubscriptionStatusToTierStatus = (status: string): JippyTierStatus => { + switch (status) { + case "active": + return JippyTierStatus.Active; + case "paused": + return JippyTierStatus.Paused; + case "canceled": + return JippyTierStatus.Cancelled; + case "past_due": + return JippyTierStatus.Expired; + default: + return JippyTierStatus.Unknown; + } +}; + +export const tierIDToPrice = (tierID: JippyTierID): number => { + switch (tierID) { + case JippyTierID.Free: + return TierPrice.Free; + case JippyTierID.Premium: + return TierPrice.Premium; + case JippyTierID.Enterprise: + return TierPrice.Enterprise; + default: + return 0; + } +}; + export const tierIDToTierName = (tierID: JippyTierID): string => { switch (tierID) { case JippyTierID.Free: