Skip to content

Commit

Permalink
Merge pull request dubinc#777 from dubinc/survey
Browse files Browse the repository at this point in the history
ENG-247: User Survey
  • Loading branch information
steven-tey authored Apr 15, 2024
2 parents c7e67e0 + 32a7fa1 commit 3ab0851
Show file tree
Hide file tree
Showing 20 changed files with 399 additions and 117 deletions.
12 changes: 10 additions & 2 deletions apps/web/app/api/user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { z } from "zod";

// GET /api/user – get a specific user
export const GET = withSession(async ({ session }) => {
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
});

const migratedWorkspace = await redis.hget(
"migrated_links_users",
session.user.id,
Expand All @@ -19,7 +25,7 @@ export const GET = withSession(async ({ session }) => {
}

return NextResponse.json({
...session.user,
...user,
migratedWorkspace,
});
});
Expand All @@ -28,11 +34,12 @@ const updateUserSchema = z.object({
name: z.preprocess(trim, z.string().min(1).max(64)).optional(),
email: z.preprocess(trim, z.string().email()).optional(),
image: z.string().url().optional(),
source: z.preprocess(trim, z.string().min(1).max(32)).optional(),
});

// PUT /api/user – edit a specific user
export const PUT = withSession(async ({ req, session }) => {
let { name, email, image } = await updateUserSchema.parseAsync(
let { name, email, image, source } = await updateUserSchema.parseAsync(
await req.json(),
);
try {
Expand All @@ -48,6 +55,7 @@ export const PUT = withSession(async ({ req, session }) => {
...(name && { name }),
...(email && { email }),
...(image && { image }),
...(source && { source }),
},
});
return NextResponse.json(response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import useUsers from "@/lib/swr/use-users";
import useWorkspace from "@/lib/swr/use-workspace";
import { UserProps } from "@/lib/types";
import { WorkspaceUserProps } from "@/lib/types";
import { useEditRoleModal } from "@/ui/modals/edit-role-modal";
import { useInviteCodeModal } from "@/ui/modals/invite-code-modal";
import { useInviteTeammateModal } from "@/ui/modals/invite-teammate-modal";
Expand Down Expand Up @@ -106,7 +106,7 @@ const UserCard = ({
user,
currentTab,
}: {
user: UserProps;
user: WorkspaceUserProps;
currentTab: "Members" | "Invitations";
}) => {
const [openPopover, setOpenPopover] = useState(false);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/app.dub.co/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import HelpPortal from "@/ui/layout/help";
import NavTabs from "@/ui/layout/nav-tabs";
import UpgradeBanner from "@/ui/layout/upgrade-banner";
import UserDropdown from "@/ui/layout/user-dropdown";
import UserSurveyPopup from "@/ui/layout/user-survey";
import WorkspaceSwitcher from "@/ui/layout/workspace-switcher";
import { Divider } from "@/ui/shared/icons";
import { Logo, MaxWidthWrapper } from "@dub/ui";
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function Layout({ children }: { children: ReactNode }) {
</div>
{children}
</div>
<UserSurveyPopup />
<HelpPortal />
</Providers>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/lib/swr/use-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { fetcher } from "@dub/utils";
import useSWRImmutable from "swr/immutable";
import { UserProps } from "../types";

export default function useUser() {
const { data, isLoading } = useSWRImmutable<UserProps>("/api/user", fetcher);

return {
user: data,
loading: isLoading,
};
}
4 changes: 2 additions & 2 deletions apps/web/lib/swr/use-users.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { UserProps } from "@/lib/types";
import { WorkspaceUserProps } from "@/lib/types";
import { fetcher } from "@dub/utils";
import useSWR from "swr";
import useWorkspace from "./use-workspace";

export default function useUsers({ invites }: { invites?: boolean } = {}) {
const { id } = useWorkspace();

const { data: users, error } = useSWR<UserProps[]>(
const { data: users, error } = useSWR<WorkspaceUserProps[]>(
id &&
(invites
? `/api/workspaces/${id}/invites`
Expand Down
6 changes: 5 additions & 1 deletion apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ export interface UserProps {
email: string;
image?: string;
createdAt: Date;
source: string | null;
migratedWorkspace: string | null;
}

export interface WorkspaceUserProps extends UserProps {
role: RoleProps;
projects?: { projectId: string }[];
}

export type DomainVerificationStatusProps =
Expand Down
3 changes: 3 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ model User {
tokens Token[]
createdAt DateTime @default(now())
subscribed Boolean @default(true)
source String?
@@index(source)
}

model Account {
Expand Down
18 changes: 4 additions & 14 deletions apps/web/ui/domains/domain-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {
import { Archive, Edit3, FileCog, FolderInput, QrCode } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import { useAddEditDomainModal } from "../modals/add-edit-domain-modal";
import { useArchiveDomainModal } from "../modals/archive-domain-modal";
import { useDeleteDomainModal } from "../modals/delete-domain-modal";
Expand All @@ -55,23 +56,14 @@ export default function DomainCard({ props }: { props: DomainProps }) {
const entry = useIntersectionObserver(domainRef, {});
const isVisible = !!entry?.isIntersecting;

const { data, isValidating } = useSWR<{
const { data, isValidating, mutate } = useSWRImmutable<{
status: DomainVerificationStatusProps;
response: any;
}>(
workspaceId &&
isVisible &&
!showLinkQRModal && // Don't fetch if QR modal is open – it'll cause it to re-render
`/api/domains/${domain}/verify?workspaceId=${workspaceId}`,
fetcher,
{
revalidateOnFocus: true,
revalidateOnMount: true,
revalidateOnReconnect: true,
refreshWhenOffline: false,
refreshWhenHidden: false,
refreshInterval: 0,
},
);

const { data: clicks } = useSWR<number>(
Expand Down Expand Up @@ -165,9 +157,7 @@ export default function DomainCard({ props }: { props: DomainProps }) {
variant="secondary"
loading={isValidating}
onClick={() => {
mutate(
`/api/domains/${domain}/verify?workspaceId=${workspaceId}`,
);
mutate();
}}
/>
<Popover
Expand Down
27 changes: 6 additions & 21 deletions apps/web/ui/layout/help/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Popover } from "@dub/ui";
import { Popover, useResizeObserver } from "@dub/ui";
import { fetcher } from "@dub/utils";
import { AnimatePresence, motion } from "framer-motion";
import { XIcon } from "lucide-react";
Expand Down Expand Up @@ -67,30 +67,15 @@ function HelpSection() {
const [screen, setScreen] = useState<"main" | "contact">("main");

const containerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState("auto");

useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
setHeight(`${entry.target.scrollHeight}px`);
}
});

if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}

return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, [containerRef.current]); // Ensure effect runs if ref changes
const resizeObserverEntry = useResizeObserver(containerRef);

return (
<motion.div
className="w-full overflow-scroll sm:w-[32rem]"
animate={{ height, maxHeight: "calc(100vh - 10rem)" }}
animate={{
height: resizeObserverEntry?.borderBoxSize[0].blockSize ?? "auto",
maxHeight: "calc(100vh - 10rem)",
}}
transition={{ type: "spring", duration: 0.3 }}
>
<div ref={containerRef}>
Expand Down
90 changes: 90 additions & 0 deletions apps/web/ui/layout/user-survey/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";

import useUser from "@/lib/swr/use-user";
import { CheckCircleFill } from "@/ui/shared/icons";
import { Popup, PopupContext, useResizeObserver } from "@dub/ui";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { createContext, useContext, useRef, useState } from "react";
import { toast } from "sonner";
import SurveyForm from "./survey-form";

type UserSurveyStatus = "idle" | "loading" | "success";

export const UserSurveyContext = createContext<{ status: UserSurveyStatus }>({
status: "idle",
});

export default function UserSurveyPopup() {
const { user } = useUser();

return (
user &&
!user.source && (
<Popup hiddenCookieId="hideUserSurveyPopup">
<UserSurveyPopupInner />
</Popup>
)
);
}

export function UserSurveyPopupInner() {
const { hidePopup } = useContext(PopupContext);

const contentWrapperRef = useRef<HTMLDivElement>(null);
const resizeObserverEntry = useResizeObserver(contentWrapperRef);

const [status, setStatus] = useState<UserSurveyStatus>("idle");

return (
<motion.div
animate={{
height: resizeObserverEntry?.borderBoxSize[0].blockSize ?? "auto",
}}
transition={{ type: "spring", duration: 0.3 }}
className="fixed bottom-4 z-50 mx-2 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md sm:left-4 sm:mx-auto sm:max-w-sm"
>
<div className="p-4" ref={contentWrapperRef}>
<button
className="absolute right-2.5 top-2.5 rounded-full p-1 transition-colors hover:bg-gray-100 active:scale-90"
onClick={hidePopup}
>
<X className="h-4 w-4 text-gray-500" />
</button>
<UserSurveyContext.Provider value={{ status }}>
<SurveyForm
onSubmit={async (source) => {
setStatus("loading");
try {
await fetch("/api/user", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ source }),
});
setStatus("success");
setTimeout(hidePopup, 3000);
} catch (e) {
toast.error("Error saving response. Please try again.");
setStatus("idle");
}
}}
/>
<AnimatePresence>
{status === "success" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute inset-0 flex flex-col items-center justify-center space-y-3 bg-white text-sm"
>
<CheckCircleFill className="h-8 w-8 text-green-500" />
<p className="text-gray-500">Thank you for your response!</p>
</motion.div>
)}
</AnimatePresence>
</UserSurveyContext.Provider>
</div>
</motion.div>
);
}
Loading

0 comments on commit 3ab0851

Please sign in to comment.