diff --git a/.cspell.json b/.cspell.json index 9c47d01cc..b22d44a42 100644 --- a/.cspell.json +++ b/.cspell.json @@ -6,6 +6,7 @@ "words": [ " X", " X ", + "hookform", "accepte", "Accordian", "adipiscing", @@ -24,6 +25,7 @@ "apidemodt", "apidemodts", "apidev", + "apikey", "apisauce", "apistage", "apistagecivo", @@ -92,6 +94,7 @@ "creatoe", "dailyplan", "Darkmode", + "DATACENTER", "datas", "dataToDisplay", "daygrid", @@ -240,6 +243,7 @@ "longpress", "Lorem", "lucide", + "mailchimp", "mainconfig", "mappagination", "mathieudutour", @@ -355,7 +359,6 @@ "tailess", "Tailess", "tailwindcss", - "timesheet-viewMode", "tanstack", "taskid", "taskstatus", @@ -367,6 +370,7 @@ "testid", "timegrid", "Timesheet", + "timesheet-viewMode", "Timesheets", "Timeslot", "tinvitations", @@ -400,6 +404,7 @@ "VERSONS", "vertificalline", "vhidden", + "Waitlist", "WARNING️", "wasabisys", "webm", diff --git a/apps/web/.env b/apps/web/.env index 9180c30c7..b6a667a29 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -137,3 +137,7 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # Warning: IF TRUE This allows production builds to successfully complete even if # your project has ESLint errors. NEXT_IGNORE_ESLINT_ERROR_ON_BUILD=true + +# Mailchimp +MAILCHIMP_API_KEY= +MAILCHIMP_LIST_ID= diff --git a/apps/web/.env.sample b/apps/web/.env.sample index 62ae03258..1989758f4 100644 --- a/apps/web/.env.sample +++ b/apps/web/.env.sample @@ -87,3 +87,7 @@ MEET_JWT_APP_SECRET= # Warning: IF TRUE This allows production builds to successfully complete even if # your project has ESLint errors. NEXT_IGNORE_ESLINT_ERROR_ON_BUILD=true + +# Mailchimp +MAILCHIMP_API_KEY= +MAILCHIMP_LIST_ID= diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx index 8dd5ce0ce..7353f53b4 100644 --- a/apps/web/app/[locale]/layout.tsx +++ b/apps/web/app/[locale]/layout.tsx @@ -1,7 +1,7 @@ /* eslint-disable no-mixed-spaces-and-tabs */ 'use client'; import 'react-loading-skeleton/dist/skeleton.css'; -import '../../styles/globals.css'; +import '@/styles/globals.css'; import clsx from 'clsx'; import { Provider } from 'jotai'; diff --git a/apps/web/app/[locale]/page-component.tsx b/apps/web/app/[locale]/page-component.tsx index 68f697eed..ddb2d6b58 100644 --- a/apps/web/app/[locale]/page-component.tsx +++ b/apps/web/app/[locale]/page-component.tsx @@ -66,9 +66,9 @@ function MainPage() { showTimer={headerSize <= 11.8 && isTrackingEnabled} className="h-full" mainHeaderSlot={ -
+
-
+
diff --git a/apps/web/app/api/subscribe/route.ts b/apps/web/app/api/subscribe/route.ts new file mode 100644 index 000000000..e7a08d50d --- /dev/null +++ b/apps/web/app/api/subscribe/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const POST = async (req: NextRequest) => { + // 1. Destructure the email address from the request body. + const reqData = (await req.json()) as { + email_address: string; + tags: string[]; + captcha?: string; + }; + + if (!reqData.email_address) { + // 2. Throw an error if an email wasn't provided. + return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + } + + if (!reqData.captcha) { + // 2. Display an error if the captcha code wasn't provided. + console.error('ERROR: Please provide required fields', 'STATUS: 400'); + } + + try { + // 3. Fetch the environment variables. + const LIST_ID = process.env.MAILCHIMP_LIST_ID; + const API_KEY = process.env.MAILCHIMP_API_KEY ? process.env.MAILCHIMP_API_KEY : ''; + if (!LIST_ID || !API_KEY) { + throw new Error('Missing Mailchimp environment variables'); + } + // 4. API keys are in the form -us3. + const DATACENTER = API_KEY.split('-')[1]; + const mailchimpData = { + email_address: reqData.email_address, + status: 'subscribed', + tags: reqData.tags ? [...reqData.tags] : ['Ever Teams'] + }; + // 5. Send a POST request to Mailchimp. + const response = await fetch(`https://${DATACENTER}.api.mailchimp.com/3.0/lists/${LIST_ID}/members`, { + body: JSON.stringify(mailchimpData), + headers: { + Authorization: `apikey ${API_KEY}`, + 'Content-Type': 'application/json' + }, + method: 'POST' + }); + console.log(response); + // 6. Swallow any errors from Mailchimp and return a better error message. + if (response.status >= 400) { + const errorResponse = await response.json(); + return NextResponse.json( + { + error: `There was an error subscribing to the newsletter: ${errorResponse.detail}` + }, + { status: 400 } + ); + } + + // 7. If we made it this far, it was a success! 🎉 + return NextResponse.json({ error: '', resp: response }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { + error: (error as Error).message || (error as Error).toString(), + resp: null + }, + { status: 500 } + ); + } +}; diff --git a/apps/web/components/app-sidebar.tsx b/apps/web/components/app-sidebar.tsx index eaf3048e7..8c304672e 100644 --- a/apps/web/components/app-sidebar.tsx +++ b/apps/web/components/app-sidebar.tsx @@ -3,38 +3,36 @@ import { MonitorSmartphone, LayoutDashboard, Heart, - FolderKanban, SquareActivity, - PlusIcon, Files, - X + X, + Command, + AudioWaveform, + GalleryVerticalEnd } from 'lucide-react'; -import { EverTeamsLogo, SymbolAppLogo } from '@/lib/components/svgs'; import { NavMain } from '@/components/nav-main'; import { Sidebar, SidebarContent, SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, SidebarRail, SidebarTrigger, useSidebar, - SidebarMenuSubButton + SidebarMenuSubButton, + SidebarFooter } from '@/components/ui/sidebar'; import Link from 'next/link'; import { cn } from '@/lib/utils'; -import { useOrganizationAndTeamManagers } from '@/app/hooks/features/useOrganizationTeamManagers'; import { useAuthenticateUser, useModal, useOrganizationTeams } from '@/app/hooks'; import { useFavoritesTask } from '@/app/hooks/features/useFavoritesTask'; -import { Button } from '@/lib/components/button'; import { CreateTeamModal, TaskIssueStatus } from '@/lib/features'; import { useTranslations } from 'next-intl'; +import { WorkspacesSwitcher } from './workspace-switcher'; +import { SidebarOptInForm } from './sidebar-opt-in-form'; +import { NavProjects } from './nav-projects'; type AppSidebarProps = React.ComponentProps & { publicTeam: boolean | undefined }; export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { - const { userManagedTeams } = useOrganizationAndTeamManagers(); const { user } = useAuthenticateUser(); const username = user?.name || user?.firstName || user?.lastName || user?.username; const { isTeamManager } = useOrganizationTeams(); @@ -44,11 +42,57 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { const t = useTranslations(); // This is sample data. const data = { - user: { - name: 'evereq', - email: 'evereq@ever.co', - avatar: '/assets/svg/profile.svg' - }, + workspaces: [ + { + name: 'Ever Teams', + logo: ({ className }: { className?: string }) => ( + + + + + + + + + + ), + plan: 'Enterprise' + }, + { + name: 'Ever Gauzy', + logo: AudioWaveform, + plan: 'Startup' + }, + { + name: 'Ever Cloc', + logo: GalleryVerticalEnd, + plan: 'Free' + }, + { + name: 'Ever Rec', + logo: Command, + plan: 'Free' + } + ], navMain: [ { title: t('sidebar.DASHBOARD'), @@ -138,35 +182,6 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { } ] }, - ...(userManagedTeams && userManagedTeams.length > 0 - ? [ - { - title: t('sidebar.PROJECTS'), - label: 'projects', - url: '#', - icon: FolderKanban, - items: [ - { - title: t('common.NO_PROJECT'), - label: 'no-project', - url: '#', - component: ( - - - - ) - } - ] - } - ] - : []), { title: t('sidebar.MY_WORKS'), url: '#', @@ -232,7 +247,8 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { } ] : []) - ] + ], + projects: [] }; return ( @@ -245,30 +261,20 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { - - - - -
- -
- {state === 'expanded' && } - -
-
-
+
+ + + + + diff --git a/apps/web/components/nav-main.tsx b/apps/web/components/nav-main.tsx index de5446bdf..79934f941 100644 --- a/apps/web/components/nav-main.tsx +++ b/apps/web/components/nav-main.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { SidebarGroup, + SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, @@ -64,7 +65,10 @@ export function NavMain({ }; return ( - + Platform + {items.map((item, index) => ( @@ -110,11 +114,11 @@ export function NavMain({ ) : ( @@ -142,23 +146,23 @@ export function NavMain({ {item.items?.length ? ( <> - + Toggle - + {item.items.map((subItem, key) => ( - + {subItem?.component || ( handleSubMenuToggle(key)} @@ -167,7 +171,7 @@ export function NavMain({ ) { - const { isMobile, state } = useSidebar(); +}) { + const { isMobile } = useSidebar(); - return ( + const { user } = useAuthenticateUser(); + + const { userManagedTeams } = useOrganizationAndTeamManagers(); + const t = useTranslations(); + return userManagedTeams && userManagedTeams.length > 0 ? ( Projects - - {projects.map((item) => ( - - - - - - {item.name} - - - - - - - - More - - - - - - + {projects && projects.length ? ( + <> + {projects.map((item) => ( + + + + + {item.name} + + + + + + + More + + + - View Project - - - - - - Share Project - - - - - - - Delete Project - - - - + + + View Project + + + + Share Project + + + + + Delete Project + + + + + ))} + + + + More + + + + ) : ( + + + + - ))} - - - - - More - - - + )} - ); + ) : null; } diff --git a/apps/web/components/nav-secondary.tsx b/apps/web/components/nav-secondary.tsx index 3b1bd5394..b4e2aec2b 100644 --- a/apps/web/components/nav-secondary.tsx +++ b/apps/web/components/nav-secondary.tsx @@ -25,10 +25,10 @@ export function NavSecondary({ return ( - + {items.map((item) => ( - - + + - {options.map(({ label, icon: Icon, view: optionView }) => ( - - - - ))} - - ); + return ( + <> + {options.map(({ label, icon: Icon, view: optionView }) => ( + + + + ))} + + ); } diff --git a/apps/web/components/pages/main/header-tabs.tsx b/apps/web/components/pages/main/header-tabs.tsx index 3cb3561e4..ee7fadb5c 100644 --- a/apps/web/components/pages/main/header-tabs.tsx +++ b/apps/web/components/pages/main/header-tabs.tsx @@ -1,69 +1,58 @@ import { clsxm } from '@app/utils'; import { Tooltip } from 'lib/components'; import LinkWrapper from '../kanban/link-wrapper'; -import { - QueueListIcon, - Squares2X2Icon, - TableCellsIcon -} from '@heroicons/react/20/solid'; +import { QueueListIcon, Squares2X2Icon, TableCellsIcon } from '@heroicons/react/20/solid'; import KanbanIcon from '@components/ui/svgs/kanban'; import { IssuesView } from '@app/constants'; import { useAtom } from 'jotai'; import { headerTabs } from '@app/stores/header-tabs'; import { DottedLanguageObjectStringPaths, useTranslations } from 'next-intl'; -const HeaderTabs = ({ - linkAll, - kanban = false -}: { - linkAll: boolean; - kanban?: boolean; -}) => { - const t = useTranslations(); - const options = [ - { label: 'CARDS', icon: QueueListIcon, view: IssuesView.CARDS }, - { label: 'TABLE', icon: TableCellsIcon, view: IssuesView.TABLE }, - { label: 'BLOCKS', icon: Squares2X2Icon, view: IssuesView.BLOCKS }, - { label: 'KANBAN', icon: KanbanIcon, view: IssuesView.KANBAN } - ]; - const links = linkAll - ? ['/', '/', '/', '/kanban'] - : [undefined, undefined, undefined, '/kanban']; - const [view, setView] = useAtom(headerTabs); - const activeView = kanban ? IssuesView.KANBAN : view; - return ( - <> - {options.map(({ label, icon: Icon, view: optionView }, index) => ( - - - - - - ))} - - ); +const HeaderTabs = ({ linkAll, kanban = false }: { linkAll: boolean; kanban?: boolean }) => { + const t = useTranslations(); + const options = [ + { label: 'CARDS', icon: QueueListIcon, view: IssuesView.CARDS }, + { label: 'TABLE', icon: TableCellsIcon, view: IssuesView.TABLE }, + { label: 'BLOCKS', icon: Squares2X2Icon, view: IssuesView.BLOCKS }, + { label: 'KANBAN', icon: KanbanIcon, view: IssuesView.KANBAN } + ]; + const links = linkAll ? ['/', '/', '/', '/kanban'] : [undefined, undefined, undefined, '/kanban']; + const [view, setView] = useAtom(headerTabs); + const activeView = kanban ? IssuesView.KANBAN : view; + return ( + <> + {options.map(({ label, icon: Icon, view: optionView }, index) => ( + + + + + + ))} + + ); }; export default HeaderTabs; diff --git a/apps/web/components/pages/task/description-block/editor-components/LinkElement.tsx b/apps/web/components/pages/task/description-block/editor-components/LinkElement.tsx index 3bc09d970..033e97093 100644 --- a/apps/web/components/pages/task/description-block/editor-components/LinkElement.tsx +++ b/apps/web/components/pages/task/description-block/editor-components/LinkElement.tsx @@ -53,7 +53,7 @@ const LinkElement = ({ attributes, element, children }: any) => { href={href} rel="noreferrer" target="_blank" - className=" text-[#5000B9] dark:text-primary-light truncate max-w-[240px] overflow-hidden whitespace-nowrap mr-0" + className=" text-[#5000B9] dark:text-primary-light truncate max-w-[230px] overflow-hidden whitespace-nowrap mr-0" style={{ textOverflow: 'ellipsis' }} > {element.href} diff --git a/apps/web/components/sidebar-opt-in-form.tsx b/apps/web/components/sidebar-opt-in-form.tsx new file mode 100644 index 000000000..670acd645 --- /dev/null +++ b/apps/web/components/sidebar-opt-in-form.tsx @@ -0,0 +1,111 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { SidebarInput, useSidebar } from '@/components/ui/sidebar'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { ToastAction } from './ui/toast'; +import { toast } from './ui/use-toast'; +import { useState } from 'react'; + +export function SidebarOptInForm() { + const { state } = useSidebar(); + const [isLoading, setLoading] = useState(false); + const subscribeFormSchema = z + .object({ + email: z.string().email() + }) + .required(); + const form = useForm>({ + resolver: zodResolver(subscribeFormSchema) + }); + + const subscribe = async () => { + let tags = ['Ever Teams, Ever Teams App', 'Open', 'Cloud']; + setLoading((prev) => true); + const res = await fetch('/api/subscribe', { + body: JSON.stringify({ + email_address: form.getValues('email'), + captcha: '', + tags: tags, + status: 'subscribed' + }), + headers: { + 'Content-Type': 'application/json' + }, + method: 'POST' + }); + const { error } = (await res.json()) as { + error: string; + status: number; + [key: string]: unknown; + }; + + if (error) { + setLoading((prev) => false); + toast({ + title: 'Waiting list registration error', + description: `We have encountered a problem ${error} with your registration to our waiting list for Ever Teams`, + variant: 'destructive' + }); + return; + } + + setLoading(() => false); + toast({ + title: 'Confirmation of registration on waiting list', + description: "Thank you for joining our waiting list! We're delighted you're interested in Ever Teams", + variant: 'default', + className: 'bg-green-50 text-green-600 border-green-500', + action: Undo + }); + }; + + const onSubmit = (data: z.infer) => { + console.log(data); + (async () => await subscribe())(); + }; + + return state == 'expanded' ? ( +
+ + + + Subscribe to our newsletter + + Opt-in to receive updates and news about Ever Teams. + + + + ( + + + + + + + )} + /> + + + +
+ + ) : null; +} diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx new file mode 100644 index 000000000..fbb0c3772 --- /dev/null +++ b/apps/web/components/ui/card.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
+); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/web/components/ui/form.tsx b/apps/web/components/ui/form.tsx new file mode 100644 index 000000000..4bcc587c8 --- /dev/null +++ b/apps/web/components/ui/form.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'; + +import { cn } from '@/lib/utils'; +import { Label } from 'components/ui/label'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + const fieldState = getFieldState(fieldContext.name, formState); + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); + } +); +FormItem.displayName = 'FormItem'; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return