diff --git a/app/components/subscription/successful-subscription-modal.tsx b/app/components/subscription/successful-subscription-modal.tsx index 2e72ffd64..95d736ca1 100644 --- a/app/components/subscription/successful-subscription-modal.tsx +++ b/app/components/subscription/successful-subscription-modal.tsx @@ -1,67 +1,145 @@ -import { useCallback } from "react"; -import { useLoaderData } from "@remix-run/react"; +import { BellIcon } from "@radix-ui/react-icons"; +import { useBlocker } from "@remix-run/react"; import { AnimatePresence } from "framer-motion"; import { useSearchParams } from "~/hooks/search-params"; -import type { loader } from "~/routes/_layout+/account-details.subscription"; import { Button } from "../shared/button"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../shared/modal"; +import { WarningBox } from "../shared/warning-box"; export default function SuccessfulSubscriptionModal() { - const [params, setParams] = useSearchParams(); - const success = params.get("success") || false; - const isTeam = params.get("team") || false; - const handleBackdropClose = useCallback( - (e: React.MouseEvent) => { - if (e.target !== e.currentTarget) return; - params.delete("success"); - setParams(params); - }, - [params, setParams] - ); - - const { activeProduct } = useLoaderData(); + const [searchParams, setSearchParams] = useSearchParams(); + const success = searchParams.get("success") || false; + const isTeam = searchParams.get("team") === "true"; + const hasExistingWorkspace = + searchParams.get("hasExistingWorkspace") === "true"; return ( - - {success ? ( -
- -
- -
-

- You are all set! -

-

- {activeProduct?.name} features unlocked.{" "} - {isTeam && "Now, create a team workspace."} -

-
- {isTeam ? ( - - ) : ( - - )} -
-
-
- ) : null} -
+ + +
+

+ You are all set! +

+

+ {isTeam ? "Team" : "Plus"} features unlocked.{" "} + {isTeam && !hasExistingWorkspace + ? "Now, it is time to create your team workspace and start adding assets." + : "Now, it is time to start adding assets. Make sure you are in the right workspace."} +

+
+ {isTeam && !hasExistingWorkspace ? ( + <> +
+ IMPORTANT: To use the Team features you + need to use your Team workspace. Make sure to create it + before you continue. +
+ + + ) : ( + + )} + + + + ) : null} + + + ); } + +function AreYouSureModal({ shouldBlock }: { shouldBlock: boolean }) { + // Block navigating elsewhere when data has been entered into the input + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + shouldBlock && currentLocation.pathname !== nextLocation.pathname + ); + return blocker && blocker.state === "blocked" ? ( + + + +
+ + + +
+ + Are you sure you want to leave the page? + + + You just got your team subscription. Do you want to leave without + creating a Team workspace? + + + <> + {" "} + IMPORTANT: To use the Team features you need to + use your Team workspace. Make sure to create it before you + continue. + + +
+ +
+ + + +
+
+
+
+ ) : null; +} diff --git a/app/components/workspace/form.tsx b/app/components/workspace/form.tsx index 0e2f2f1e9..d661593fd 100644 --- a/app/components/workspace/form.tsx +++ b/app/components/workspace/form.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import type { Organization, Currency } from "@prisma/client"; import { useLoaderData, useNavigation } from "@remix-run/react"; import { useAtom, useAtomValue } from "jotai"; @@ -5,6 +6,7 @@ import { useZorm } from "react-zorm"; import { z } from "zod"; import { updateDynamicTitleAtom } from "~/atoms/dynamic-title-atom"; import { fileErrorAtom, validateFileAtom } from "~/atoms/file"; +import { useSearchParams } from "~/hooks/search-params"; import type { loader } from "~/routes/_layout+/account-details.workspace.new"; import { isFormProcessing } from "~/utils/form"; import { zodFieldIsRequired } from "~/utils/zod"; @@ -38,12 +40,21 @@ interface Props { export const WorkspaceForm = ({ name, currency, children }: Props) => { const { curriences } = useLoaderData(); + const [searchParams] = useSearchParams(); const navigation = useNavigation(); const zo = useZorm("NewQuestionWizardScreen", NewWorkspaceFormSchema); const disabled = isFormProcessing(navigation.state); const fileError = useAtomValue(fileErrorAtom); const [, validateFile] = useAtom(validateFileAtom); const [, updateTitle] = useAtom(updateDynamicTitleAtom); + const nameFieldRef = useRef(null); + + useEffect(() => { + const team = searchParams.get("team"); + if (!team && nameFieldRef.current) { + nameFieldRef.current.focus(); + } + }, [searchParams]); return ( @@ -55,6 +66,9 @@ export const WorkspaceForm = ({ name, currency, children }: Props) => { > @@ -70,10 +84,17 @@ export const WorkspaceForm = ({ name, currency, children }: Props) => { defaultValue={name || undefined} placeholder="" required={zodFieldIsRequired(NewWorkspaceFormSchema.shape.name)} + ref={nameFieldRef} /> - +

Accepts PNG, JPG or JPEG (max.4 MB) diff --git a/app/emails/stripe/welcome-to-trial.tsx b/app/emails/stripe/welcome-to-trial.tsx new file mode 100644 index 000000000..d02b6c0ac --- /dev/null +++ b/app/emails/stripe/welcome-to-trial.tsx @@ -0,0 +1,173 @@ +import { + Html, + Text, + Link, + Head, + render, + Container, +} from "@react-email/components"; +import { config } from "~/config/shelf.config"; +import { SERVER_URL } from "~/utils/env"; +import { ShelfError } from "~/utils/error"; +import { Logger } from "~/utils/logger"; +import { LogoForEmail } from "../logo"; +import { sendEmail } from "../mail.server"; +import { styles } from "../styles"; + +export const sendTeamTrialWelcomeEmail = ({ email }: { email: string }) => { + try { + const subject = `Your Shelf Team Trial is Ready - Next Steps`; + const html = welcomeToTrialEmailHtml(); + const text = welcomeToTrialEmailText(); + + void sendEmail({ + to: email, + subject, + html, + text, + }); + } catch (cause) { + Logger.error( + new ShelfError({ + cause, + message: "Something went wrong while sending the welcome email", + additionalData: { email }, + label: "User", + }) + ); + } +}; + +/** + * THis is the text version of the onboarding email + */ +export const welcomeToTrialEmailText = () => `Dear Shelf user, +Carlos Virreira here, Co-founder of Shelf Asset Management, Inc. I'm thrilled to inform you that your Shelf Team Trial has been activated! This is an excellent step towards more efficient asset management for your team. + +To get started with your trial: + +Create Your Team Workspace: + +Visit https://app.shelf.nu/account-details/workspace to see all your workspaces. You'll find a "NEW WORKSPACE" button enabled - click this to create your team workspace if you haven't already. + + +Add Your First Assets: +Start populating your inventory to see Shelf in action. Don't forget to try our QR code feature for easy asset tracking. + + +Invite Team Members: +Collaboration is key. Add your colleagues to truly experience the power of Shelf. + + +Explore Key Features: +Custom Fields: Tailor Shelf to your specific needs - https://www.shelf.nu/knowledge-base/custom-field-types-in-shelf +Bookings: Efficiently manage equipment reservations - https://www.shelf.nu/knowledge-base/use-case-scenarios-explaing-our-bookings-feature +Kits: Group related assets for easier management - https://www.shelf.nu/features/kits + +Need help? Our support team is ready to assist you. Check out our Knowledge Base for quick answers, or reach out directly at support@shelf.nu. + +Remember, your trial gives you full access to all our premium features. Make the most of it! + +Happy asset tracking, +Carlos Virreira +Co-founder, Shelf Asset Management, Inc. +P.S. Have questions or feedback? I'd love to hear from you. Reply directly to this email, and let's chat! +`; + +function WelcomeToTrialEmailTemplate() { + const { emailPrimaryColor } = config; + + return ( + + + Your Shelf Team Trial is Ready - Next Steps + + + + + +

+ Dear Shelf user, + + Carlos Virreira here, Co-founder of Shelf Asset Management, Inc. I'm + thrilled to inform you that your Shelf Team Trial has been + activated! This is an excellent step towards more efficient asset + management for your team. +
+ To get started with your trial: +
+

Create Your Team Workspace:

+
+
    +
  1. + Visit{" "} + + {SERVER_URL}/account-details/workspace + {" "} + to see all your workspaces. You'll find a "NEW WORKSPACE" button + enabled - click this to create your team workspace if you haven't + already. +
  2. +
  3. + Add Your First Assets: Start populating your inventory to see + Shelf in action. Don't forget to try our QR code feature for easy + asset tracking. +
  4. +
  5. + Invite Team Members: Collaboration is key. Add your colleagues to + truly experience the power of Shelf. +
  6. +
+

Explore Key Features:

+ + Custom Fields: Tailor Shelf to your specific needs + +
+ + Bookings: Efficiently manage equipment reservations + +
+ + Kits: Group related assets for easier management + +
+ + Need help? Our support team is ready to assist you. Check out our + Knowledge Base for quick answers, or reach out directly at + support@shelf.nu. +
+ Remember, your trial gives you full access to all our premium + features. Make the most of it! +
+
+ Happy asset tracking,
+ Carlos Virreira
+ Co-founder, Shelf Asset Management, Inc. +
+ P.S. Have questions or feedback? I'd love to hear from you. Reply + directly to this email, and let's chat! +
+
+ + + ); +} + +/* + *The HTML content of an email will be accessed by a server file to send email, + we cannot import a TSX component in a server file so we are exporting TSX converted to HTML string using render function by react-email. + */ +export const welcomeToTrialEmailHtml = () => + render(); diff --git a/app/emails/styles.ts b/app/emails/styles.ts index b6eb65fbc..d70fbd706 100644 --- a/app/emails/styles.ts +++ b/app/emails/styles.ts @@ -30,4 +30,5 @@ export const styles = { marginBottom: "16px", }, p: { fontSize: "16px", color: "#344054" }, + li: { fontSize: "16px", color: "#344054" }, }; diff --git a/app/routes/_layout+/account-details.subscription.tsx b/app/routes/_layout+/account-details.subscription.tsx index 82123c268..452399ef5 100644 --- a/app/routes/_layout+/account-details.subscription.tsx +++ b/app/routes/_layout+/account-details.subscription.tsx @@ -24,7 +24,7 @@ import SuccessfulSubscriptionModal from "~/components/subscription/successful-su import { db } from "~/database/db.server"; import { getUserTierLimit } from "~/modules/tier/service.server"; -import { getUserByID, updateUser } from "~/modules/user/service.server"; +import { getUserByID } from "~/modules/user/service.server"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { ENABLE_PREMIUM_FEATURES } from "~/utils/env"; import { ShelfError, makeShelfError } from "~/utils/error"; @@ -168,11 +168,6 @@ export async function action({ context, request }: ActionFunctionArgs) { shelfTier, }); - /** Update the user flag to mark them for having a trial */ - if (intent === "trial" && stripeRedirectUrl) { - await updateUser({ id: userId, usedFreeTrial: true }); - } - return redirect(stripeRedirectUrl); } catch (cause) { const reason = makeShelfError(cause, { userId }); diff --git a/app/routes/_layout+/account-details.workspace.new.tsx b/app/routes/_layout+/account-details.workspace.new.tsx index fa7256440..c6e3ee6fd 100644 --- a/app/routes/_layout+/account-details.workspace.new.tsx +++ b/app/routes/_layout+/account-details.workspace.new.tsx @@ -11,6 +11,7 @@ import { useAtomValue } from "jotai"; import { dynamicTitleAtom } from "~/atoms/dynamic-title-atom"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; +import SuccessfulSubscriptionModal from "~/components/subscription/successful-subscription-modal"; import { NewWorkspaceFormSchema, WorkspaceForm, @@ -127,6 +128,8 @@ export default function NewWorkspace() {
+ +
diff --git a/app/routes/api+/stripe-webhook.ts b/app/routes/api+/stripe-webhook.ts index 3d09565ba..cdb4386d5 100644 --- a/app/routes/api+/stripe-webhook.ts +++ b/app/routes/api+/stripe-webhook.ts @@ -5,6 +5,7 @@ import type Stripe from "stripe"; import { db } from "~/database/db.server"; import { sendEmail } from "~/emails/mail.server"; import { trialEndsSoonText } from "~/emails/stripe/trial-ends-soon"; +import { sendTeamTrialWelcomeEmail } from "~/emails/stripe/welcome-to-trial"; import { ShelfError, makeShelfError } from "~/utils/error"; import { error } from "~/utils/http.server"; import { @@ -108,11 +109,12 @@ export async function action({ request }: ActionFunctionArgs) { if (isTrialSubscription) { /** WHen its a trial subscription, update the tier of the user */ - await db.user + const user = await db.user .update({ where: { customerId }, data: { tierId: tierId as TierId, + usedFreeTrial: true, }, }) .catch((cause) => { @@ -124,6 +126,11 @@ export async function action({ request }: ActionFunctionArgs) { status: 500, }); }); + + /** Send the TRIAL welcome email with instructions */ + void sendTeamTrialWelcomeEmail({ + email: user.email, + }); } return new Response(null, { status: 200 }); diff --git a/app/utils/stripe.server.ts b/app/utils/stripe.server.ts index 86f30fc11..570523674 100644 --- a/app/utils/stripe.server.ts +++ b/app/utils/stripe.server.ts @@ -3,6 +3,7 @@ import Stripe from "stripe"; import type { PriceWithProduct } from "~/components/subscription/prices"; import { config } from "~/config/shelf.config"; import { db } from "~/database/db.server"; +import { getOrganizationByUserId } from "~/modules/organization/service.server"; import { getOrganizationTierLimit } from "~/modules/tier/service.server"; import { STRIPE_SECRET_KEY } from "./env"; import type { ErrorLabel } from "./error"; @@ -103,13 +104,18 @@ export async function createStripeCheckoutSession({ }, ]; + const successUrl = await generateReturnUrl({ + userId, + shelfTier, + intent, + domainUrl, + }); + const { url } = await stripe.checkout.sessions.create({ mode: "subscription", payment_method_types: ["card"], line_items: lineItems, - success_url: `${domainUrl}/account-details/subscription?success=true${ - shelfTier === "tier_2" ? "&team=true" : "" - }`, + success_url: successUrl, cancel_url: `${domainUrl}/account-details/subscription?canceled=true`, client_reference_id: userId, customer: customerId, @@ -123,7 +129,7 @@ export async function createStripeCheckoutSession({ trial_period_days: 14, }, payment_method_collection: "if_required", - }), // Add trial period if intent is trial + }), }); if (!url) { @@ -134,7 +140,6 @@ export async function createStripeCheckoutSession({ label, }); } - return url; } catch (cause) { throw new ShelfError({ @@ -412,3 +417,44 @@ export const disabledTeamOrg = async ({ ["free", "tier_1"].includes(tierLimit?.id) ); }; + +/** Generates the redirect URL based on relevant data */ +async function generateReturnUrl({ + userId, + shelfTier, + intent, + domainUrl, +}: { + userId: User["id"]; + shelfTier: "tier_1" | "tier_2" | "free" | "custom"; + intent: "trial" | "subscribe"; + domainUrl: string; +}) { + /** + * Here we have a few cases: + * 1. If its trial and tier_2, and they dont own team workspaces we redirect them to create a team workspace - we can safely assume that is their first entrance + * 3. If its any other tier, we redirect them to /account-details/subscription + */ + + /** We do a small try/catch to prevent throwing as we just need to continue */ + let userTeamOrg; + try { + userTeamOrg = await getOrganizationByUserId({ + userId, + orgType: "TEAM", + }); + } catch (cause) { + userTeamOrg = null; + } + + const urlSearchParams = new URLSearchParams({ + success: "true", + team: shelfTier === "tier_2" ? "true" : "", + ...(intent === "trial" && { trial: "true" }), + ...(userTeamOrg && { hasExistingWorkspace: "true" }), + }); + + return shelfTier === "tier_2" && !userTeamOrg // If the user is on tier_2, and they dont already OWN a team org we redirect them to create a team workspace + ? `${domainUrl}/account-details/workspace/new?${urlSearchParams.toString()}` + : `${domainUrl}/account-details/subscription?${urlSearchParams.toString()}`; +}