diff --git a/apps/web/package.json b/apps/web/package.json index 62c50474..8142b4ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@mixan/queue": "workspace:^", "@mixan/types": "workspace:*", "@mixan/validation": "workspace:^", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", @@ -34,6 +35,8 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.9.7", "@t3-oss/env-nextjs": "^0.7.3", @@ -47,6 +50,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^0.2.1", + "date-fns": "^3.3.1", "embla-carousel-react": "8.0.0-rc22", "hamburger-react": "^2.5.0", "lodash.debounce": "^4.0.8", @@ -65,6 +69,7 @@ "react": "18.2.0", "react-animate-height": "^3.2.3", "react-animated-numbers": "^0.18.0", + "react-day-picker": "^8.10.0", "react-dom": "18.2.0", "react-hook-form": "^7.50.1", "react-in-viewport": "1.0.0-alpha.30", diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx new file mode 100644 index 00000000..84b1b53d --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Checkbox, CheckboxInput } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAppParams } from '@/hooks/useAppParams'; +import { clipboard } from '@/utils/clipboard'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Copy, SaveIcon } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import type { SubmitHandler } from 'react-hook-form'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { api, handleError } from '../../../_trpc/client'; + +const validation = z.object({ + name: z.string().min(1), + domain: z.string().optional(), + withSecret: z.boolean().optional(), +}); + +type IForm = z.infer; + +export function CreateClient() { + const [open, setOpen] = useState(false); + const { organizationId, projectId } = useAppParams(); + const clients = api.client.list.useQuery({ + organizationId, + }); + const clientsCount = clients.data?.length; + + useEffect(() => { + if (clientsCount === 0) { + setOpen(true); + } + }, [clientsCount]); + + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(validation), + defaultValues: { + withSecret: false, + name: '', + domain: '', + }, + }); + const mutation = api.client.create2.useMutation({ + onError: handleError, + onSuccess() { + toast.success('Client created'); + router.refresh(); + }, + }); + const onSubmit: SubmitHandler = (values) => { + mutation.mutate({ + name: values.name, + domain: values.withSecret ? undefined : values.domain, + organizationId, + projectId, + }); + }; + + const watch = useWatch({ + control: form.control, + name: 'withSecret', + }); + + return ( + + {mutation.isSuccess ? ( + <> + setOpen(false)} + > + + Success + + {mutation.data.clientSecret + ? 'Use your client id and secret with our SDK to send events to us. ' + : 'Use your client id with our SDK to send events to us. '} + See our{' '} + + documentation + + + +
+ + {mutation.data.clientSecret ? ( + + ) : ( +
+ +
+ {mutation.data.cors} +
+
+ You can update cors settings{' '} + + here + +
+
+ )} +
+ + + +
+ + ) : ( + <> + setOpen(false)} + > + + Let's connect + + Create a client so you can start send events to us 🚀 + + +
+
+
+ + +
+ ( + { + field.onChange(!checked); + }} + > + This is a website + + )} + /> +
+ + +
+
+ + + + +
+
+ + )} +
+ ); +} + +//
+//
+// Select your framework and we'll generate a client for you. +//
+//
+// +// +// +// +// +// +//
+//
diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx index f9490c5e..c2d23309 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx @@ -9,10 +9,12 @@ import OverviewTopEvents from '@/components/overview/overview-top-events'; import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopSources from '@/components/overview/overview-top-sources'; +import { Dialog } from '@/components/ui/dialog'; import { getExists } from '@/server/pageExists'; import { db } from '@mixan/db'; +import { CreateClient } from './create-client'; import { StickyBelowHeader } from './layout-sticky-below-header'; import OverviewMetrics from './overview-metrics'; import { OverviewReportRange } from './overview-sticky-header'; @@ -38,6 +40,7 @@ export default async function Page({ return ( +
diff --git a/apps/web/src/app/(app)/[organizationId]/create-project.tsx b/apps/web/src/app/(app)/[organizationId]/create-project.tsx new file mode 100644 index 00000000..59c132a8 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/create-project.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { LogoSquare } from '@/components/Logo'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAppParams } from '@/hooks/useAppParams'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { SaveIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { api, handleError } from '../../_trpc/client'; + +const validation = z.object({ + name: z.string().min(1), +}); + +type IForm = z.infer; + +export function CreateProject() { + const params = useAppParams(); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(validation), + }); + const mutation = api.project.create.useMutation({ + onError: handleError, + onSuccess() { + toast.success('Project created'); + router.refresh(); + }, + }); + const onSubmit: SubmitHandler = (values) => { + mutation.mutate({ + name: values.name, + organizationId: params.organizationId, + }); + }; + + return ( + <> +
+ +

Create your first project

+
+ A project is just a container for your events. You can create as many + as you want. +
+
+
+ + +
+
+ +
+
+
+ + ); +} + +//
+//
+// Select your framework and we'll generate a client for you. +//
+//
+// +// +// +// +// +// +//
+//
diff --git a/apps/web/src/app/(app)/[organizationId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/page.tsx index 465b8cff..b94ffb8e 100644 --- a/apps/web/src/app/(app)/[organizationId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/page.tsx @@ -1,8 +1,9 @@ -import { getOrganizationBySlug } from '@mixan/db'; -import { getProjectWithMostEvents } from '@mixan/db'; +import { LogoSquare } from '@/components/Logo'; import { notFound, redirect } from 'next/navigation'; -import PageLayout from './[projectId]/page-layout'; +import { getOrganizationBySlug, getProjectWithMostEvents } from '@mixan/db'; + +import { CreateProject } from './create-project'; interface PageProps { params: { @@ -20,15 +21,30 @@ export default async function Page({ params: { organizationId } }: PageProps) { return notFound(); } + if (process.env.BLOCK) { + return ( +
+
+ +

Not quite there yet

+
+ We're still working on Openpanel, but we're not quite there yet. + We'll let you know when we're ready to go! +
+
+
+ ); + } + if (project) { return redirect(`/${organizationId}/${project.id}`); } return ( - -
-

Create your first project

+
+
+
- +
); } diff --git a/apps/web/src/app/(app)/create-organization.tsx b/apps/web/src/app/(app)/create-organization.tsx new file mode 100644 index 00000000..75503829 --- /dev/null +++ b/apps/web/src/app/(app)/create-organization.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { LogoSquare } from '@/components/Logo'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { SaveIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { api, handleError } from '../_trpc/client'; + +const validation = z.object({ + organization: z.string().min(4), + project: z.string().optional(), +}); + +type IForm = z.infer; + +export function CreateOrganization() { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(validation), + }); + const mutation = api.onboarding.organziation.useMutation({ + onError: handleError, + onSuccess({ organization, project }) { + let url = `/${organization.slug}`; + if (project) { + url += `/${project.id}`; + } + router.replace(url); + }, + }); + const onSubmit: SubmitHandler = (values) => { + mutation.mutate(values); + }; + return ( + <> +
+ +

Welcome to Openpanel

+
+ Create your organization below (can be personal or a company) and + optionally your first project 🤠 +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + ); +} diff --git a/apps/web/src/app/(app)/page.tsx b/apps/web/src/app/(app)/page.tsx index dd5b1034..5a0adc47 100644 --- a/apps/web/src/app/(app)/page.tsx +++ b/apps/web/src/app/(app)/page.tsx @@ -1,14 +1,39 @@ -import { CreateOrganization } from '@clerk/nextjs'; +// import { CreateOrganization } from '@clerk/nextjs'; + +import { LogoSquare } from '@/components/Logo'; import { redirect } from 'next/navigation'; import { getCurrentOrganizations } from '@mixan/db'; +import { CreateOrganization } from './create-organization'; + export default async function Page() { const organizations = await getCurrentOrganizations(); - if (organizations.length === 0) { - return ; + if (process.env.BLOCK) { + return ( +
+
+ +

Not quite there yet

+
+ We're still working on Openpanel, but we're not quite there yet. + We'll let you know when we're ready to go! +
+
+
+ ); } - return redirect(`/${organizations[0]?.slug}`); + if (organizations.length > 0) { + return redirect(`/${organizations[0]?.slug}`); + } + + return ( +
+
+ +
+
+ ); } diff --git a/apps/web/src/components/Logo.tsx b/apps/web/src/components/Logo.tsx index 02441844..590ee1e3 100644 --- a/apps/web/src/components/Logo.tsx +++ b/apps/web/src/components/Logo.tsx @@ -4,16 +4,22 @@ interface LogoProps { className?: string; } +export function LogoSquare({ className }: LogoProps) { + return ( + Openpanel logo + ); +} + export function Logo({ className }: LogoProps) { return (
- Openpanel logo + openpanel.dev
); diff --git a/apps/web/src/components/report/ReportRange.tsx b/apps/web/src/components/report/ReportRange.tsx index 0a8df92a..7ac9e48a 100644 --- a/apps/web/src/components/report/ReportRange.tsx +++ b/apps/web/src/components/report/ReportRange.tsx @@ -1,21 +1,115 @@ -import { CalendarIcon } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { pushModal } from '@/modals'; +import { useDispatch, useSelector } from '@/redux'; +import { cn } from '@/utils/cn'; +import { addDays, endOfDay, format, startOfDay, subDays } from 'date-fns'; +import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react'; +import type { DateRange, SelectRangeEventHandler } from 'react-day-picker'; import { timeRanges } from '@mixan/constants'; import type { IChartRange } from '@mixan/validation'; import type { ExtendedComboboxProps } from '../ui/combobox'; import { Combobox } from '../ui/combobox'; +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'; +import { changeDates, changeEndDate, changeStartDate } from './reportSlice'; + +export function ReportRange({ + onChange, + value, + className, + ...props +}: ExtendedComboboxProps) { + const dispatch = useDispatch(); + const startDate = useSelector((state) => state.report.startDate); + const endDate = useSelector((state) => state.report.endDate); + + const setDate: SelectRangeEventHandler = (val) => { + if (!val) return; + + if (val.from && val.to) { + dispatch( + changeDates({ + startDate: startOfDay(val.from).toISOString(), + endDate: endOfDay(val.to).toISOString(), + }) + ); + } else if (val.from) { + dispatch(changeStartDate(startOfDay(val.from).toISOString())); + } else if (val.to) { + dispatch(changeEndDate(endOfDay(val.to).toISOString())); + } + }; + + const { isBelowSm } = useBreakpoint('sm'); -export function ReportRange(props: ExtendedComboboxProps) { return ( - ({ - label: key, - value: key, - }))} - {...props} - /> + <> + + + + + +
+ { + if (value) onChange(value); + }} + type="single" + variant="outline" + className="flex-wrap max-sm:max-w-xs" + > + {Object.values(timeRanges).map((key) => ( + + {key} + + ))} + +
+ +
+
+ ); } diff --git a/apps/web/src/components/report/chart/Chart.tsx b/apps/web/src/components/report/chart/Chart.tsx index 4049acf2..1b1290de 100644 --- a/apps/web/src/components/report/chart/Chart.tsx +++ b/apps/web/src/components/report/chart/Chart.tsx @@ -28,6 +28,8 @@ export function Chart({ unit, metric, projectId, + startDate, + endDate, }: ReportChartProps) { const [data] = api.chart.chart.useSuspenseQuery( { @@ -39,8 +41,8 @@ export function Chart({ breakdowns, name, range, - startDate: null, - endDate: null, + startDate, + endDate, projectId, previous, formula, diff --git a/apps/web/src/components/report/funnel/Funnel.tsx b/apps/web/src/components/report/funnel/Funnel.tsx index 7ff99eec..15782e95 100644 --- a/apps/web/src/components/report/funnel/Funnel.tsx +++ b/apps/web/src/components/report/funnel/Funnel.tsx @@ -12,6 +12,8 @@ import { cn } from '@/utils/cn'; import { round } from '@/utils/math'; import { ArrowRight, ArrowRightIcon } from 'lucide-react'; +import { useChartContext } from '../chart/ChartProvider'; + function FunnelChart({ from, to }: { from: number; to: number }) { const fromY = 100 - from; const toY = 100 - to; @@ -82,15 +84,19 @@ export function FunnelSteps({ steps, totalSessions, }: RouterOutputs['chart']['funnel']) { + const { editMode } = useChartContext(); return ( - + - + {steps.map((step, index, list) => { const finalStep = index === list.length - 1; return (
diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 78d2d0fa..1a9bb33d 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -1,5 +1,7 @@ +import { start } from 'repl'; import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; +import { isSameDay, isSameMonth } from 'date-fns'; import { alphabetIds, @@ -37,8 +39,8 @@ const initialState: InitialState = { breakdowns: [], events: [], range: '1m', - startDate: null, - endDate: null, + startDate: new Date('2024-02-24 00:00:00').toISOString(), + endDate: new Date('2024-02-24 23:59:59').toISOString(), previous: false, formula: undefined, unit: undefined, @@ -66,6 +68,7 @@ export const reportSlice = createSlice({ }, setReport(state, action: PayloadAction) { return { + ...state, ...action.payload, startDate: null, endDate: null, @@ -176,21 +179,65 @@ export const reportSlice = createSlice({ state.lineType = action.payload; }, + // Custom start and end date + changeDates: ( + state, + action: PayloadAction<{ + startDate: string; + endDate: string; + }> + ) => { + state.dirty = true; + state.startDate = action.payload.startDate; + state.endDate = action.payload.endDate; + + if (isSameDay(state.startDate, state.endDate)) { + state.interval = 'hour'; + } else if (isSameMonth(state.startDate, state.endDate)) { + state.interval = 'day'; + } else { + state.interval = 'month'; + } + }, + // Date range changeStartDate: (state, action: PayloadAction) => { state.dirty = true; state.startDate = action.payload; + + if (state.startDate && state.endDate) { + if (isSameDay(state.startDate, state.endDate)) { + state.interval = 'hour'; + } else if (isSameMonth(state.startDate, state.endDate)) { + state.interval = 'day'; + } else { + state.interval = 'month'; + } + } }, // Date range changeEndDate: (state, action: PayloadAction) => { state.dirty = true; state.endDate = action.payload; + + if (state.startDate && state.endDate) { + if (isSameDay(state.startDate, state.endDate)) { + state.interval = 'hour'; + } else if (isSameMonth(state.startDate, state.endDate)) { + state.interval = 'day'; + } else { + state.interval = 'month'; + } + } }, changeDateRanges: (state, action: PayloadAction) => { state.dirty = true; state.range = action.payload; + state.startDate = null; + state.endDate = null; + state.interval = getDefaultIntervalByRange(action.payload); }, @@ -215,6 +262,9 @@ export const { removeBreakdown, changeBreakdown, changeInterval, + changeDates, + changeStartDate, + changeEndDate, changeDateRanges, changeChartType, changeLineType, diff --git a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx index 27e21b35..18b39930 100644 --- a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx +++ b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx @@ -43,7 +43,7 @@ export function ReportBreakdowns() {
{selectedBreakdowns.map((item, index) => { return ( -
+
{index} {selectedEvents.map((event) => { return ( -
+
{event.id} - diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx new file mode 100644 index 00000000..71398b45 --- /dev/null +++ b/apps/web/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/utils/cn" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 8e56d7cb..ca40c6f1 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -75,7 +75,7 @@ const Button = React.forwardRef( {Icon && ( + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index f5253de3..0e02bc1a 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -26,4 +26,15 @@ const Checkbox = React.forwardRef< )); Checkbox.displayName = CheckboxPrimitive.Root.displayName; -export { Checkbox }; +const CheckboxInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +CheckboxInput.displayName = 'CheckboxInput'; + +export { Checkbox, CheckboxInput }; diff --git a/apps/web/src/components/ui/combobox-advanced.tsx b/apps/web/src/components/ui/combobox-advanced.tsx index 35d8618f..91251087 100644 --- a/apps/web/src/components/ui/combobox-advanced.tsx +++ b/apps/web/src/components/ui/combobox-advanced.tsx @@ -1,20 +1,17 @@ -'use client'; - import * as React from 'react'; import { Badge } from '@/components/ui/badge'; import { Command, - CommandEmpty, CommandGroup, CommandInput, CommandItem, + CommandList, } from '@/components/ui/command'; import { ChevronsUpDownIcon } from 'lucide-react'; import { useOnClickOutside } from 'usehooks-ts'; import { Button } from './button'; import { Checkbox } from './checkbox'; -import { Input } from './input'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; type IValue = any; @@ -90,34 +87,33 @@ export function ComboboxAdvanced({ >
{value.length === 0 && placeholder} - {value.slice(0, 2).map((value) => { + {value.map((value) => { const item = items.find((item) => item.value === value) ?? { value, label: value, }; return {item.label}; })} - {value.length > 2 && +{value.length - 2} more}
- + - - {inputValue === '' - ? value.map(renderUnknownItem) - : renderItem({ - value: inputValue, - label: `Pick "${inputValue}"`, - })} + + {inputValue !== '' && + renderItem({ + value: inputValue, + label: `Pick '${inputValue}'`, + })} + {value.map(renderUnknownItem)} {selectables.map(renderItem)} - + diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index a45304a6..4db8263a 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -77,8 +77,8 @@ export function Combobox({ aria-expanded={open} className={cn('justify-between', className)} > -
- {Icon ? : null} +
+ {Icon ? : null} {value ? find(value)?.label ?? 'No match' : placeholder} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index fe85da54..0465e161 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -30,8 +30,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + onClose?: () => void; + } +>(({ className, children, onClose, ...props }, ref) => ( {children} - + Close @@ -72,7 +77,7 @@ const DialogFooter = ({ }: React.HTMLAttributes) => (
& { - error?: string | undefined; -}; +const inputVariant = cva( + 'flex w-full rounded-md border border-input bg-background ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + size: { + default: 'h-8 px-3 py-2 text-sm', + large: 'h-12 px-4 py-3 text-lg', + }, + }, + defaultVariants: { + size: 'default', + }, + } +); + +export type InputProps = VariantProps & + Omit, 'size'> & { + error?: string | undefined; + }; const Input = React.forwardRef( - ({ className, error, type, ...props }, ref) => { + ({ className, error, type, size, ...props }, ref) => { return ( , React.ComponentPropsWithoutRef >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - <> + - + )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx new file mode 100644 index 00000000..87cb456b --- /dev/null +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/cn" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx new file mode 100644 index 00000000..0569a7bc --- /dev/null +++ b/apps/web/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/cn" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index e8d2c9c8..ea188c05 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -4,6 +4,7 @@ import { chartRouter } from './routers/chart'; import { clientRouter } from './routers/client'; import { dashboardRouter } from './routers/dashboard'; import { eventRouter } from './routers/event'; +import { onboardingRouter } from './routers/onboarding'; import { organizationRouter } from './routers/organization'; import { profileRouter } from './routers/profile'; import { projectRouter } from './routers/project'; @@ -29,6 +30,7 @@ export const appRouter = createTRPCRouter({ profile: profileRouter, ui: uiRouter, share: shareRouter, + onboarding: onboardingRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index e2ae525c..80d38228 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -5,7 +5,17 @@ import { } from '@/server/api/trpc'; import { getDaysOldDate } from '@/utils/date'; import { average, max, min, round, sum } from '@/utils/math'; -import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda'; +import { + flatten, + map, + pick, + pipe, + prop, + repeat, + reverse, + sort, + uniq, +} from 'ramda'; import { z } from 'zod'; import { chQuery, createSqlBuilder } from '@mixan/db'; @@ -260,7 +270,10 @@ export const chartRouter = createTRPCRouter({ // TODO: Make this private chart: publicProcedure.input(zChartInput).query(async ({ input }) => { - const current = getDatesFromRange(input.range); + const current = + input.startDate && input.endDate + ? { startDate: input.startDate, endDate: input.endDate } + : getDatesFromRange(input.range); let diff = 0; switch (input.range) { diff --git a/apps/web/src/server/api/routers/client.ts b/apps/web/src/server/api/routers/client.ts index 1abbe48a..3fda7d76 100644 --- a/apps/web/src/server/api/routers/client.ts +++ b/apps/web/src/server/api/routers/client.ts @@ -80,6 +80,33 @@ export const clientRouter = createTRPCRouter({ cors: client.cors, }; }), + create2: protectedProcedure + .input( + z.object({ + name: z.string(), + projectId: z.string(), + organizationId: z.string(), + domain: z.string().nullish(), + }) + ) + .mutation(async ({ input }) => { + const secret = randomUUID(); + const client = await db.client.create({ + data: { + organization_slug: input.organizationId, + project_id: input.projectId, + name: input.name, + secret: input.domain ? undefined : await hashPassword(secret), + cors: input.domain || undefined, + }, + }); + + return { + clientSecret: input.domain ? null : secret, + clientId: client.id, + cors: client.cors, + }; + }), remove: protectedProcedure .input( z.object({ diff --git a/apps/web/src/server/api/routers/onboarding.ts b/apps/web/src/server/api/routers/onboarding.ts new file mode 100644 index 00000000..1e0235a0 --- /dev/null +++ b/apps/web/src/server/api/routers/onboarding.ts @@ -0,0 +1,40 @@ +import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; +import { clerkClient } from '@clerk/nextjs'; +import { z } from 'zod'; + +import { db } from '@mixan/db'; + +export const onboardingRouter = createTRPCRouter({ + organziation: protectedProcedure + .input( + z.object({ + organization: z.string(), + project: z.string().optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const org = await clerkClient.organizations.createOrganization({ + name: input.organization, + createdBy: ctx.session.userId, + }); + + if (org.slug && input.project) { + const project = await db.project.create({ + data: { + name: input.project, + organization_slug: org.slug, + }, + }); + + return { + project, + organization: org, + }; + } + + return { + project: null, + organization: org, + }; + }), +}); diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 4bb6e9fa..9226c350 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -113,6 +113,8 @@ export function getEventFiltersWhereClause( const id = `f${index}`; const { name, value, operator } = filter; + if (value.length === 0) return; + if (name.startsWith('properties.')) { const whereFrom = `mapValues(mapExtractKeyLike(properties, '${name .replace(/^properties\./, '') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e66aff18..9b9a9ec7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,6 +338,9 @@ importers: '@mixan/validation': specifier: workspace:^ version: link:../../packages/validation + '@radix-ui/react-accordion': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-alert-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) @@ -374,6 +377,12 @@ importers: '@radix-ui/react-toast': specifier: ^1.1.5 version: 1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle-group': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tooltip': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) @@ -413,6 +422,9 @@ importers: cmdk: specifier: ^0.2.1 version: 0.2.1(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + date-fns: + specifier: ^3.3.1 + version: 3.3.1 embla-carousel-react: specifier: 8.0.0-rc22 version: 8.0.0-rc22(react@18.2.0) @@ -467,6 +479,9 @@ importers: react-animated-numbers: specifier: ^0.18.0 version: 0.18.0(react-dom@18.2.0)(react@18.2.0) + react-day-picker: + specifier: ^8.10.0 + version: 8.10.0(date-fns@3.3.1)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -4391,6 +4406,35 @@ packages: '@babel/runtime': 7.23.9 dev: false + /@radix-ui/react-accordion@1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@types/react': 18.2.56 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} peerDependencies: @@ -4511,6 +4555,34 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@types/react': 18.2.56 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: @@ -5171,6 +5243,56 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@types/react': 18.2.56 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0) + '@types/react': 18.2.56 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} peerDependencies: @@ -7823,6 +7945,10 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: false + /date-fns@3.3.1: + resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==} + dev: false + /dayjs@1.11.10: resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} dev: false @@ -12390,6 +12516,16 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-day-picker@8.10.0(date-fns@3.3.1)(react@18.2.0): + resolution: {integrity: sha512-mz+qeyrOM7++1NCb1ARXmkjMkzWVh2GL9YiPbRjKe0zHccvekk4HE+0MPOZOrosn8r8zTHIIeOUXTmXRqmkRmg==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + date-fns: 3.3.1 + react: 18.2.0 + dev: false + /react-devtools-core@4.28.5: resolution: {integrity: sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==} dependencies: