diff --git a/app/components/ui/chart-tooltip.tsx b/app/components/ui/chart-tooltip.tsx
new file mode 100644
index 0000000..1c52c5e
--- /dev/null
+++ b/app/components/ui/chart-tooltip.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import { Color, colorPalette, getColorClassNames, tremorTwMerge } from '~/lib/tremor'
+import { CustomTooltipProps } from '@tremor/react'
+import { GroupByPeriod } from '~/services/vacancies/vacancies.types'
+import { endOfMonth, endOfWeek, endOfYear, isSameDay, setDefaultOptions } from 'date-fns'
+setDefaultOptions({ weekStartsOn: 1 })
+
+export const ChartTooltipFrame = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+export interface ChartTooltipRowProps {
+ value: string
+ name: string
+ color: Color | string
+}
+
+export const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => (
+
+)
+
+const ChartTooltip = ({
+ active,
+ payload,
+ label,
+ groupBy,
+}: CustomTooltipProps & {
+ rawDate: string
+ groupBy: GroupByPeriod
+}) => {
+ if (active && payload) {
+ const rawDate = payload[0]?.payload?.rawDate
+ console.log(payload, rawDate)
+ const filteredPayload = payload.filter((item: any) => item.type !== 'none')
+
+ return (
+
+
+
+ {formatDate(rawDate ?? '', groupBy)}
+
+
+
+
+ {filteredPayload.map(({ value, name, color }, idx: number) => (
+
+ ))}
+
+
+ )
+ }
+ return null
+}
+
+export { ChartTooltip }
+function formatDate(date: string | number, groupBy: GroupByPeriod): string {
+ let endDate: Date
+ const startDate = new Date(date)
+ switch (groupBy) {
+ case 'day':
+ endDate = new Date(startDate)
+ break
+ case 'week':
+ endDate = endOfWeek(startDate)
+ break
+ case 'month':
+ endDate = endOfMonth(startDate)
+ break
+ case 'year':
+ endDate = new Date(endOfYear(startDate))
+ break
+ default:
+ endDate = new Date(startDate)
+ }
+ return isSameDay(startDate, endDate)
+ ? startDate.toLocaleDateString('ru')
+ : `${startDate.toLocaleDateString('ru')} - ${endDate.toLocaleDateString('ru')}`
+}
diff --git a/app/components/vacancies-chart.tsx b/app/components/vacancies-chart.tsx
index d23e6d0..e1fb4fc 100644
--- a/app/components/vacancies-chart.tsx
+++ b/app/components/vacancies-chart.tsx
@@ -1,7 +1,9 @@
import { useCallback, useMemo } from 'react'
-import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
-import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react'
+import { GroupByPeriod, KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
+import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem, Switch } from '@tremor/react'
import { useSearchParams } from '@remix-run/react'
+import { capitalize } from 'remeda'
+import { ChartTooltip } from '~/components/ui/chart-tooltip'
type Props = {
data?: VacancyData
@@ -32,6 +34,11 @@ export function VacanciesChart({ data, keywords }: Props) {
return searchParams.get('preset') ?? 'None'
}, [searchParams])
+ const showTotal = useMemo(() => searchParams.get('showTotal') === 'true', [searchParams])
+ const groupBy: GroupByPeriod = useMemo(() => {
+ return (searchParams.get('groupBy') ?? 'day') as GroupByPeriod
+ }, [searchParams])
+
const setPreset = useCallback(
(value: string | null) => {
if (!value || value === 'None') {
@@ -44,10 +51,27 @@ export function VacanciesChart({ data, keywords }: Props) {
},
[searchParams, setSearchParams]
)
+ const setShowTotal = useCallback(
+ (value: boolean) => {
+ if (value) {
+ searchParams.set('showTotal', 'true')
+ } else {
+ searchParams.delete('showTotal')
+ }
+ setSearchParams(searchParams)
+ },
+ [searchParams, setSearchParams]
+ )
const categoriesForChart = useMemo(() => {
- return data?.categories.filter(category => selectedCategories.includes(category)) ?? []
- }, [selectedCategories, data?.categories])
+ const filtered = [
+ ...(data?.categories.filter(category => selectedCategories.includes(category)) ?? []),
+ ]
+ if (showTotal) {
+ filtered.unshift('total')
+ }
+ return filtered
+ }, [selectedCategories, data?.categories, showTotal])
const setSelectedCategories = useCallback(
(value: string[]) => {
@@ -62,17 +86,42 @@ export function VacanciesChart({ data, keywords }: Props) {
[searchParams, setSearchParams]
)
+ const setGroupBy = useCallback(
+ (value: string) => {
+ if (!value || value === 'day') {
+ searchParams.delete('groupBy')
+ } else {
+ searchParams.set('groupBy', value)
+ }
+
+ setSearchParams(searchParams)
+ },
+ [searchParams, setSearchParams]
+ )
+
const filteredData = useMemo(
() =>
- data?.data?.filter(row => {
- for (const category of selectedCategories) {
- const value = row[category]
- if (typeof value === 'number' && value > 0) {
- return true
+ data?.data
+ ?.filter(row => {
+ for (const category of selectedCategories) {
+ const value = row[category]
+ if (typeof value === 'number' && value > 0) {
+ return true
+ }
}
- }
- }) ?? [],
- [data?.data, selectedCategories]
+ if (showTotal) {
+ const value = row['total']
+ if (typeof value === 'number' && value > 0) {
+ return true
+ }
+ }
+ })
+ ?.map(({ date, ...rest }) => ({
+ ...rest,
+ date: new Date(date).toLocaleDateString('ru'),
+ rawDate: date,
+ })) ?? [],
+ [data?.data, selectedCategories, showTotal, groupBy]
)
const handlePresetChange = useCallback(
@@ -103,6 +152,16 @@ export function VacanciesChart({ data, keywords }: Props) {
))
}, [presetsForSelect])
+ const groupBySelectItems = useMemo(
+ () =>
+ Object.entries(GroupByPeriod).map(([label, value]) => (
+
+ {capitalize(value)}
+
+ )),
+ []
+ )
+
return (
<>
@@ -121,6 +180,7 @@ export function VacanciesChart({ data, keywords }: Props) {
{multiSelectItems}
+
+
+
+
+
+
(
+
+ )}
+ enableLegendSlider
className="h-full"
data={filteredData}
index="date"
diff --git a/app/lib/tremor/constants.ts b/app/lib/tremor/constants.ts
new file mode 100644
index 0000000..4853c97
--- /dev/null
+++ b/app/lib/tremor/constants.ts
@@ -0,0 +1,52 @@
+import { Color, DeltaType, HorizontalPosition, Size, VerticalPosition } from "./inputTypes";
+
+export const DeltaTypes: { [key: string]: DeltaType } = {
+ Increase: "increase",
+ ModerateIncrease: "moderateIncrease",
+ Decrease: "decrease",
+ ModerateDecrease: "moderateDecrease",
+ Unchanged: "unchanged",
+};
+
+export const BaseColors: { [key: string]: Color } = {
+ Slate: "slate",
+ Gray: "gray",
+ Zinc: "zinc",
+ Neutral: "neutral",
+ Stone: "stone",
+ Red: "red",
+ Orange: "orange",
+ Amber: "amber",
+ Yellow: "yellow",
+ Lime: "lime",
+ Green: "green",
+ Emerald: "emerald",
+ Teal: "teal",
+ Cyan: "cyan",
+ Sky: "sky",
+ Blue: "blue",
+ Indigo: "indigo",
+ Violet: "violet",
+ Purple: "purple",
+ Fuchsia: "fuchsia",
+ Pink: "pink",
+ Rose: "rose",
+};
+
+export const Sizes: { [key: string]: Size } = {
+ XS: "xs",
+ SM: "sm",
+ MD: "md",
+ LG: "lg",
+ XL: "xl",
+};
+
+export const HorizontalPositions: { [key: string]: HorizontalPosition } = {
+ Left: "left",
+ Right: "right",
+};
+
+export const VerticalPositions: { [key: string]: VerticalPosition } = {
+ Top: "top",
+ Bottom: "bottom",
+};
diff --git a/app/lib/tremor/index.ts b/app/lib/tremor/index.ts
new file mode 100644
index 0000000..4bccf16
--- /dev/null
+++ b/app/lib/tremor/index.ts
@@ -0,0 +1,5 @@
+export * from "./constants";
+export * from "./inputTypes";
+export * from "./theme";
+export * from "./tremorTwMerge";
+export * from "./utils";
diff --git a/app/lib/tremor/inputTypes.ts b/app/lib/tremor/inputTypes.ts
new file mode 100644
index 0000000..26e55ae
--- /dev/null
+++ b/app/lib/tremor/inputTypes.ts
@@ -0,0 +1,72 @@
+export type ValueFormatter = {
+ (value: number): string;
+};
+
+export type CurveType = "linear" | "natural" | "monotone" | "step";
+
+export type Interval = "preserveStartEnd" | "equidistantPreserveStart";
+
+export type IntervalType = "preserveStartEnd" | Interval;
+
+const iconVariantValues = ["simple", "light", "shadow", "solid", "outlined"] as const;
+
+export type IconVariant = (typeof iconVariantValues)[number];
+
+export type HorizontalPosition = "left" | "right";
+
+export type VerticalPosition = "top" | "bottom";
+
+export type ButtonVariant = "primary" | "secondary" | "light";
+
+const deltaTypeValues = [
+ "increase",
+ "moderateIncrease",
+ "decrease",
+ "moderateDecrease",
+ "unchanged",
+] as const;
+
+export type DeltaType = (typeof deltaTypeValues)[number];
+
+const sizeValues = ["xs", "sm", "md", "lg", "xl"] as const;
+
+export type Size = (typeof sizeValues)[number];
+
+const colorValues = [
+ "slate",
+ "gray",
+ "zinc",
+ "neutral",
+ "stone",
+ "red",
+ "orange",
+ "amber",
+ "yellow",
+ "lime",
+ "green",
+ "emerald",
+ "teal",
+ "cyan",
+ "sky",
+ "blue",
+ "indigo",
+ "violet",
+ "purple",
+ "fuchsia",
+ "pink",
+ "rose",
+] as const;
+
+export type Color = (typeof colorValues)[number];
+export type CustomColor = Color | string;
+export const getIsBaseColor = (color: Color | string) => colorValues.includes(color as Color);
+
+const justifyContentValues = ["start", "end", "center", "between", "around", "evenly"] as const;
+export type JustifyContent = (typeof justifyContentValues)[number];
+
+const alignItemsValues = ["start", "end", "center", "baseline", "stretch"] as const;
+export type AlignItems = (typeof alignItemsValues)[number];
+
+export type FlexDirection = "row" | "col" | "row-reverse" | "col-reverse";
+
+export type FunnelVariantType = "base" | "center";
diff --git a/app/lib/tremor/theme.ts b/app/lib/tremor/theme.ts
new file mode 100644
index 0000000..c40a83a
--- /dev/null
+++ b/app/lib/tremor/theme.ts
@@ -0,0 +1,51 @@
+import { BaseColors } from "./constants";
+import { Color } from "./inputTypes";
+
+export const DEFAULT_COLOR: Color = "gray";
+export const WHITE = "white";
+export const TRANSPARENT = "transparent";
+
+export const colorPalette = {
+ canvasBackground: 50,
+ lightBackground: 100,
+ background: 500,
+ darkBackground: 600,
+ darkestBackground: 800,
+ lightBorder: 200,
+ border: 500,
+ darkBorder: 700,
+ lightRing: 200,
+ ring: 300,
+ iconRing: 500,
+ lightText: 400,
+ text: 500,
+ iconText: 600,
+ darkText: 700,
+ darkestText: 900,
+ icon: 500,
+};
+
+export const themeColorRange: Color[] = [
+ BaseColors.Blue,
+ BaseColors.Cyan,
+ BaseColors.Sky,
+ BaseColors.Indigo,
+ BaseColors.Violet,
+ BaseColors.Purple,
+ BaseColors.Fuchsia,
+ BaseColors.Slate,
+ BaseColors.Gray,
+ BaseColors.Zinc,
+ BaseColors.Neutral,
+ BaseColors.Stone,
+ BaseColors.Red,
+ BaseColors.Orange,
+ BaseColors.Amber,
+ BaseColors.Yellow,
+ BaseColors.Lime,
+ BaseColors.Green,
+ BaseColors.Emerald,
+ BaseColors.Teal,
+ BaseColors.Pink,
+ BaseColors.Rose,
+];
diff --git a/app/lib/tremor/tremorTwMerge.ts b/app/lib/tremor/tremorTwMerge.ts
new file mode 100644
index 0000000..070ae4f
--- /dev/null
+++ b/app/lib/tremor/tremorTwMerge.ts
@@ -0,0 +1,36 @@
+import { extendTailwindMerge } from "tailwind-merge";
+
+export const tremorTwMerge = extendTailwindMerge({
+ classGroups: {
+ boxShadow: [
+ {
+ shadow: [
+ {
+ tremor: ["input", "card", "dropdown"],
+ "dark-tremor": ["input", "card", "dropdown"],
+ },
+ ],
+ },
+ ],
+ borderRadius: [
+ {
+ rounded: [
+ {
+ tremor: ["small", "default", "full"],
+ "dark-tremor": ["small", "default", "full"],
+ },
+ ],
+ },
+ ],
+ fontSize: [
+ {
+ text: [
+ {
+ tremor: ["default", "title", "metric"],
+ "dark-tremor": ["default", "title", "metric"],
+ },
+ ],
+ },
+ ],
+ },
+});
diff --git a/app/lib/tremor/utils.tsx b/app/lib/tremor/utils.tsx
new file mode 100644
index 0000000..63c5be2
--- /dev/null
+++ b/app/lib/tremor/utils.tsx
@@ -0,0 +1,114 @@
+import { DeltaTypes } from "./constants";
+import { Color, getIsBaseColor, ValueFormatter } from "./inputTypes";
+
+export const mapInputsToDeltaType = (deltaType: string, isIncreasePositive: boolean): string => {
+ if (isIncreasePositive || deltaType === DeltaTypes.Unchanged) {
+ return deltaType;
+ }
+ switch (deltaType) {
+ case DeltaTypes.Increase:
+ return DeltaTypes.Decrease;
+ case DeltaTypes.ModerateIncrease:
+ return DeltaTypes.ModerateDecrease;
+ case DeltaTypes.Decrease:
+ return DeltaTypes.Increase;
+ case DeltaTypes.ModerateDecrease:
+ return DeltaTypes.ModerateIncrease;
+ }
+ return "";
+};
+
+export const defaultValueFormatter: ValueFormatter = (value: number) => value.toString();
+
+export const currencyValueFormatter: ValueFormatter = (e: number) =>
+ `$ ${Intl.NumberFormat("en-US").format(e)}`;
+
+export const sumNumericArray = (arr: number[]) =>
+ arr.reduce((prefixSum, num) => prefixSum + num, 0);
+
+export const isValueInArray = (value: any, array: any[]): boolean => {
+ for (let i = 0; i < array.length; i++) {
+ if (array[i] === value) {
+ return true;
+ }
+ }
+ return false;
+};
+
+export function mergeRefs(
+ refs: Array | React.LegacyRef>,
+): React.RefCallback {
+ return (value) => {
+ refs.forEach((ref) => {
+ if (typeof ref === "function") {
+ ref(value);
+ } else if (ref != null) {
+ (ref as React.MutableRefObject).current = value;
+ }
+ });
+ };
+}
+
+export function makeClassName(componentName: string) {
+ return (className: string) => {
+ return `tremor-${componentName}-${className}`;
+ };
+}
+
+interface ColorClassNames {
+ bgColor: string;
+ hoverBgColor: string;
+ selectBgColor: string;
+ textColor: string;
+ selectTextColor: string;
+ hoverTextColor: string;
+ borderColor: string;
+ selectBorderColor: string;
+ hoverBorderColor: string;
+ ringColor: string;
+ strokeColor: string;
+ fillColor: string;
+}
+
+/**
+ * Returns boolean based on a determination that a color should be considered an "arbitrary"
+ * Tailwind CSS class.
+ * @see {@link https://tailwindcss.com/docs/background-color#arbitrary-values | Tailwind CSS docs}
+ */
+const getIsArbitraryColor = (color: Color | string) =>
+ color.includes("#") || color.includes("--") || color.includes("rgb");
+
+export function getColorClassNames(color: Color | string, shade?: number): ColorClassNames {
+ const isBaseColor = getIsBaseColor(color);
+ if (color === "white" || color === "black" || color === "transparent" || !shade || !isBaseColor) {
+ const unshadedColor = !getIsArbitraryColor(color) ? color : `[${color}]`;
+ return {
+ bgColor: `bg-${unshadedColor} dark:bg-${unshadedColor}`,
+ hoverBgColor: `hover:bg-${unshadedColor} dark:hover:bg-${unshadedColor}`,
+ selectBgColor: `ui-selected:bg-${unshadedColor} dark:ui-selected:bg-${unshadedColor}`,
+ textColor: `text-${unshadedColor} dark:text-${unshadedColor}`,
+ selectTextColor: `ui-selected:text-${unshadedColor} dark:ui-selected:text-${unshadedColor}`,
+ hoverTextColor: `hover:text-${unshadedColor} dark:hover:text-${unshadedColor}`,
+ borderColor: `border-${unshadedColor} dark:border-${unshadedColor}`,
+ selectBorderColor: `ui-selected:border-${unshadedColor} dark:ui-selected:border-${unshadedColor}`,
+ hoverBorderColor: `hover:border-${unshadedColor} dark:hover:border-${unshadedColor}`,
+ ringColor: `ring-${unshadedColor} dark:ring-${unshadedColor}`,
+ strokeColor: `stroke-${unshadedColor} dark:stroke-${unshadedColor}`,
+ fillColor: `fill-${unshadedColor} dark:fill-${unshadedColor}`,
+ };
+ }
+ return {
+ bgColor: `bg-${color}-${shade} dark:bg-${color}-${shade}`,
+ selectBgColor: `ui-selected:bg-${color}-${shade} dark:ui-selected:bg-${color}-${shade}`,
+ hoverBgColor: `hover:bg-${color}-${shade} dark:hover:bg-${color}-${shade}`,
+ textColor: `text-${color}-${shade} dark:text-${color}-${shade}`,
+ selectTextColor: `ui-selected:text-${color}-${shade} dark:ui-selected:text-${color}-${shade}`,
+ hoverTextColor: `hover:text-${color}-${shade} dark:hover:text-${color}-${shade}`,
+ borderColor: `border-${color}-${shade} dark:border-${color}-${shade}`,
+ selectBorderColor: `ui-selected:border-${color}-${shade} dark:ui-selected:border-${color}-${shade}`,
+ hoverBorderColor: `hover:border-${color}-${shade} dark:hover:border-${color}-${shade}`,
+ ringColor: `ring-${color}-${shade} dark:ring-${color}-${shade}`,
+ strokeColor: `stroke-${color}-${shade} dark:stroke-${color}-${shade}`,
+ fillColor: `fill-${color}-${shade} dark:fill-${color}-${shade}`,
+ };
+}
diff --git a/app/routes/trends/route.tsx b/app/routes/trends/route.tsx
index 8f8e844..0e58c39 100644
--- a/app/routes/trends/route.tsx
+++ b/app/routes/trends/route.tsx
@@ -1,11 +1,12 @@
-import { json, MetaFunction } from '@remix-run/node'
+import { json, LoaderFunctionArgs, MetaFunction } from '@remix-run/node'
import { vacanciesService } from '~/services/vacancies/vacancies.service'
import { useLoaderData, useSearchParams } from '@remix-run/react'
import { VacanciesChart } from '~/components/vacancies-chart'
import type { ShouldRevalidateFunction } from '@remix-run/react'
+import { GroupByPeriod } from '~/services/vacancies/vacancies.types'
-export const shouldRevalidate: ShouldRevalidateFunction = ({ nextParams }) => {
- return !nextParams
+export const shouldRevalidate: ShouldRevalidateFunction = ({ currentUrl, nextUrl }) => {
+ return currentUrl.searchParams.get('groupBy') !== nextUrl.searchParams.get('groupBy')
}
export const meta: MetaFunction = ({ location }) => {
const preset = new URLSearchParams(location.search).get('preset')
@@ -16,9 +17,13 @@ export const meta: MetaFunction = ({ location }) => {
]
}
-export const loader = async () => {
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url)
+ const groupByParam = url.searchParams.get('groupBy')
+ const groupBy = groupByParam ? (groupByParam as GroupByPeriod) : GroupByPeriod.DAY
+
const promises = [
- vacanciesService.getAggregateByCreatedAt(),
+ vacanciesService.getAggregateByCreatedAt({ groupBy }),
vacanciesService.getKeywords(),
] as const
const [vacancies, keywords] = await Promise.all(promises)
diff --git a/app/services/vacancies/vacancies.service.ts b/app/services/vacancies/vacancies.service.ts
index 8d73825..a9a7bee 100644
--- a/app/services/vacancies/vacancies.service.ts
+++ b/app/services/vacancies/vacancies.service.ts
@@ -1,16 +1,34 @@
-import { KeywordsResponse, Vacancies, VacancyData } from '~/services/vacancies/vacancies.types'
+import {
+ GroupByPeriod,
+ KeywordsResponse,
+ Vacancies,
+ VacancyData,
+} from '~/services/vacancies/vacancies.types'
export class VacanciesService {
- baseUrl = process.env.VACANCIES_API_URL ?? 'https://vacancies-trends-api.andrii.es'
+ baseUrl = process.env.VITE_VACANCIES_API_URL ?? 'https://vacancies-trends-api.andrii.es'
- async getAll(): Promise {
- return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json())
+ async getAll(args?: { groupBy?: GroupByPeriod }): Promise {
+ const groupBy = args?.groupBy ?? GroupByPeriod.DAY
+ const params = new URLSearchParams({
+ groupBy,
+ })
+
+ const url = new URL(`${this.baseUrl}/vacancies`)
+ url.search = params.toString()
+ return await fetch(url.toString()).then(res => res.json())
}
- async getAggregateByCreatedAt(): Promise {
- return await fetch(`${this.baseUrl}/vacancies/aggregated`)
- .then(res => res.json())
- .then(this.formatDateOnData)
+ async getAggregateByCreatedAt(args?: { groupBy?: GroupByPeriod }): Promise {
+ const groupBy = args?.groupBy ?? GroupByPeriod.DAY
+
+ const params = new URLSearchParams({
+ groupBy,
+ })
+ const url = new URL(`${this.baseUrl}/vacancies/aggregated`)
+ url.search = params.toString()
+
+ return await fetch(url).then(res => res.json())
}
async getKeywords(): Promise {
diff --git a/app/services/vacancies/vacancies.types.ts b/app/services/vacancies/vacancies.types.ts
index ab2617f..e5a84ff 100644
--- a/app/services/vacancies/vacancies.types.ts
+++ b/app/services/vacancies/vacancies.types.ts
@@ -18,3 +18,10 @@ export type KeywordsResponse = {
keywords: Record
presets: Record
}
+
+export enum GroupByPeriod {
+ DAY = 'day',
+ WEEK = 'week',
+ MONTH = 'month',
+ YEAR = 'year',
+}
diff --git a/package.json b/package.json
index 0b77159..3c0b89a 100644
--- a/package.json
+++ b/package.json
@@ -20,10 +20,12 @@
"@remixicon/react": "^4.2.0",
"@tremor/react": "^3.17.2",
"clsx": "^2.1.1",
+ "date-fns": "^3.6.0",
"isbot": "^4.1.0",
"posthog-js": "^1.148.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "remeda": "^2.11.0",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e36ca26..765b958 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ date-fns:
+ specifier: ^3.6.0
+ version: 3.6.0
isbot:
specifier: ^4.1.0
version: 4.4.0
@@ -47,6 +50,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
+ remeda:
+ specifier: ^2.11.0
+ version: 2.11.0
tailwind-merge:
specifier: ^2.3.0
version: 2.3.0
@@ -3301,6 +3307,9 @@ packages:
remark-rehype@10.1.0:
resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==}
+ remeda@2.11.0:
+ resolution: {integrity: sha512-rQO+zcuNvnTcB2vBctblBARZakY0+wMNtrFGqU1+h4jm5p2APcDKQxUZG2CmMPkSQxa2nauU55GBVS/3Fo83fA==}
+
require-like@0.1.2:
resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==}
@@ -3651,6 +3660,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
+ type-fest@4.26.0:
+ resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==}
+ engines: {node: '>=16'}
+
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@@ -7566,6 +7579,10 @@ snapshots:
mdast-util-to-hast: 12.3.0
unified: 10.1.2
+ remeda@2.11.0:
+ dependencies:
+ type-fest: 4.26.0
+
require-like@0.1.2: {}
resolve-from@4.0.0: {}
@@ -7983,6 +8000,8 @@ snapshots:
type-fest@0.20.2: {}
+ type-fest@4.26.0: {}
+
type-is@1.6.18:
dependencies:
media-typer: 0.3.0