response rate by source chart (#207)
* response rate by source * docs * add gpt improvements * mobile resp * UX * chartkpi
This commit is contained in:
parent
eed5c2adba
commit
1573d8dfbc
@ -1,7 +1,7 @@
|
||||
---
|
||||
id: overview
|
||||
title: Overview
|
||||
description: Dashboard analytics for application volume and conversion over selectable time windows.
|
||||
description: Dashboard analytics for application volume, conversion, and response rate by source over selectable time windows.
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
@ -16,6 +16,7 @@ It visualizes:
|
||||
- Applications per day
|
||||
- Application-to-response conversion
|
||||
- Funnel progression (Applied, Screening, Interview, Offer, Rejected)
|
||||
- Response rate by source
|
||||
|
||||
### Graph-level views
|
||||
|
||||
@ -32,6 +33,7 @@ Use it to quickly answer:
|
||||
- Are application volumes increasing or dropping?
|
||||
- Is response conversion improving?
|
||||
- Where are applications stalling in the funnel?
|
||||
- Which job boards are actually generating responses?
|
||||
|
||||
## How to use it
|
||||
|
||||
@ -40,6 +42,7 @@ Use it to quickly answer:
|
||||
3. Review:
|
||||
- **Applications per day** for volume trend
|
||||
- **Application → Response Conversion** for quality/outcome trend
|
||||
- **Response Rate by Source** to compare job board effectiveness
|
||||
4. Compare periods and adjust your sourcing terms, filters, or tailoring strategy.
|
||||
|
||||
### Data and calculation defaults
|
||||
@ -48,6 +51,20 @@ Use it to quickly answer:
|
||||
- Only jobs in statuses `applied` and `in_progress` are used as input.
|
||||
- Conversion counts any positive response-stage event (for example recruiter screen, assessment, interview stages, or offer).
|
||||
- Conversion trend chart uses a rolling window up to 7 days.
|
||||
- Response rate by source is calculated across all time (not scoped to the duration selector), since response events may arrive well after the application window.
|
||||
- Sources with fewer than 5 applications are hidden by default in the Response Rate by Source chart. Check **Include small samples** to show them.
|
||||
|
||||
### Response Rate by Source
|
||||
|
||||
The **Response Rate by Source** chart shows, for each job board (LinkedIn, Indeed, Gradcracker, etc.), what percentage of your applications received a non-rejection response.
|
||||
|
||||
**What counts as a response:** the application reached at least one of these stages — recruiter screen, assessment, hiring manager screen, technical interview, onsite, or offer. Ghosted applications (no stage events) and rejected outcomes are both excluded from the numerator.
|
||||
|
||||
Each bar is labelled `X% (n=Y)` where `n` is the number of applications from that source, so you can immediately tell whether a high rate comes from a meaningful sample or a single lucky application. The full breakdown (response rate, responded, applied) is also shown in the tooltip.
|
||||
|
||||
Sources are sorted by response rate descending. Sources with fewer than 5 applications are hidden by default to avoid misleading percentages from tiny samples. Check **Include small samples** to show them.
|
||||
|
||||
Use this chart to identify which sources produce genuine engagement versus silence, and concentrate future sourcing effort accordingly.
|
||||
|
||||
## Common problems
|
||||
|
||||
@ -66,6 +83,16 @@ Use it to quickly answer:
|
||||
- Volume trend compares first-half vs second-half averages in the selected window.
|
||||
- Changing the time window can materially change trend direction.
|
||||
|
||||
### Response Rate by Source shows only one source
|
||||
|
||||
- Only sources with at least one applied job appear in the chart.
|
||||
- If all your applications come from a single board, only that board will be shown.
|
||||
|
||||
### Response rate for a source looks too high or too low
|
||||
|
||||
- Check the `n=` value in the bar label or tooltip. A small sample (e.g. n=2) will produce an unreliable rate.
|
||||
- Sources with fewer than 5 applications are hidden by default. If you see a suspiciously high rate, you may be looking at a small-sample source — check the n.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Orchestrator](/docs/next/features/orchestrator)
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
43
orchestrator/src/client/components/charts/ChartKpiPanel.tsx
Normal file
43
orchestrator/src/client/components/charts/ChartKpiPanel.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* ChartKpiPanel
|
||||
* Reusable stat block used in card headers: label, bold rate, trend icon, subtext.
|
||||
*/
|
||||
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
|
||||
interface ChartKpiPanelProps {
|
||||
label: string;
|
||||
rate: number;
|
||||
subtext: string;
|
||||
/** Rate below this threshold shows TrendingDown. Default 10. */
|
||||
lowThreshold?: number;
|
||||
/** Rate above this threshold shows TrendingUp. Default 25. */
|
||||
highThreshold?: number;
|
||||
}
|
||||
|
||||
export function ChartKpiPanel({
|
||||
label,
|
||||
rate,
|
||||
subtext,
|
||||
lowThreshold = 10,
|
||||
highThreshold = 25,
|
||||
}: ChartKpiPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold leading-none sm:text-3xl">
|
||||
{rate.toFixed(1)}%
|
||||
</span>
|
||||
{rate < lowThreshold ? (
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
) : rate > highThreshold ? (
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{subtext}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import type { StageEvent } from "@shared/types.js";
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
@ -27,6 +26,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||
import { ChartKpiPanel } from "./ChartKpiPanel";
|
||||
|
||||
type FunnelStage = {
|
||||
name: string;
|
||||
@ -293,27 +293,11 @@ export function ConversionAnalytics({
|
||||
How many applications received a positive response from the company.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Conversion Rate
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold leading-none sm:text-3xl">
|
||||
{overallConversion.rate.toFixed(1)}%
|
||||
</span>
|
||||
{overallConversion.rate < 10 ? (
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
) : overallConversion.rate > 25 ? (
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{overallConversion.converted} of {overallConversion.total}{" "}
|
||||
applications
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChartKpiPanel
|
||||
label="Conversion Rate"
|
||||
rate={overallConversion.rate}
|
||||
subtext={`${overallConversion.converted} of ${overallConversion.total} applications`}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{error ? (
|
||||
|
||||
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Response Rate by Source Chart
|
||||
* For each job source, shows the percentage of applications that received
|
||||
* a non-rejection response — defined as reaching screening, interview, or offer.
|
||||
* Ghosted/no-reply and rejected outcomes are both excluded from the numerator.
|
||||
*/
|
||||
|
||||
import type { JobSource, StageEvent } from "@shared/types.js";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ChartContainer } from "@/components/ui/chart";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ChartKpiPanel } from "./ChartKpiPanel";
|
||||
|
||||
type JobForSourceChart = {
|
||||
id: string;
|
||||
source: JobSource;
|
||||
appliedAt: string | null;
|
||||
events: StageEvent[];
|
||||
};
|
||||
|
||||
type SourceRateDataPoint = {
|
||||
source: string;
|
||||
applied: number;
|
||||
responded: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
rate: {
|
||||
label: "Response Rate",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-rejection response: the application reached screening, interview, or offer.
|
||||
* Ghosted (no events) and rejected (outcome=rejected) are both excluded.
|
||||
*/
|
||||
const RESPONSE_STAGES = new Set([
|
||||
"recruiter_screen",
|
||||
"assessment",
|
||||
"hiring_manager_screen",
|
||||
"technical_interview",
|
||||
"onsite",
|
||||
"offer",
|
||||
]);
|
||||
|
||||
const SOURCE_LABELS: Record<JobSource, string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
glassdoor: "Glassdoor",
|
||||
ukvisajobs: "UKVisaJobs",
|
||||
adzuna: "Adzuna",
|
||||
hiringcafe: "HiringCafe",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const BAR_COLORS = [
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
/** Minimum applications required for a source to appear by default. */
|
||||
const MIN_SAMPLE_DEFAULT = 5;
|
||||
|
||||
const buildResponseRateBySource = (
|
||||
jobs: JobForSourceChart[],
|
||||
): SourceRateDataPoint[] => {
|
||||
const bySource = new Map<JobSource, { applied: number; responded: number }>();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!job.appliedAt) continue;
|
||||
|
||||
const existing = bySource.get(job.source) ?? { applied: 0, responded: 0 };
|
||||
existing.applied++;
|
||||
|
||||
const hasResponse = job.events.some((e) => RESPONSE_STAGES.has(e.toStage));
|
||||
if (hasResponse) {
|
||||
existing.responded++;
|
||||
}
|
||||
|
||||
bySource.set(job.source, existing);
|
||||
}
|
||||
|
||||
return Array.from(bySource.entries())
|
||||
.map(([source, { applied, responded }]) => ({
|
||||
source: `${SOURCE_LABELS[source] ?? source} (${applied})`,
|
||||
applied,
|
||||
responded,
|
||||
rate: applied > 0 ? (responded / applied) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate || b.applied - a.applied);
|
||||
};
|
||||
|
||||
interface ResponseRateBySourceChartProps {
|
||||
jobs: JobForSourceChart[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function ResponseRateBySourceChart({
|
||||
jobs,
|
||||
error,
|
||||
}: ResponseRateBySourceChartProps) {
|
||||
const [includeSmall, setIncludeSmall] = useState(false);
|
||||
|
||||
const allData = useMemo(() => buildResponseRateBySource(jobs), [jobs]);
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
includeSmall
|
||||
? allData
|
||||
: allData.filter((d) => d.applied >= MIN_SAMPLE_DEFAULT),
|
||||
[allData, includeSmall],
|
||||
);
|
||||
|
||||
const hiddenCount = allData.length - data.length;
|
||||
|
||||
const totalApplied = useMemo(
|
||||
() => allData.reduce((sum, d) => sum + d.applied, 0),
|
||||
[allData],
|
||||
);
|
||||
const totalResponded = useMemo(
|
||||
() => allData.reduce((sum, d) => sum + d.responded, 0),
|
||||
[allData],
|
||||
);
|
||||
const overallRate =
|
||||
totalApplied > 0 ? (totalResponded / totalApplied) * 100 : 0;
|
||||
|
||||
const chartHeight = Math.max(80, data.length * 52);
|
||||
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
|
||||
<CardTitle>Response Rate by Source</CardTitle>
|
||||
<CardDescription>
|
||||
% of applications that got a response (not rejected or ghosted).
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ChartKpiPanel
|
||||
label="Response Rate"
|
||||
rate={overallRate}
|
||||
subtext={`${totalResponded} of ${totalApplied} applications`}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{error ? (
|
||||
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
|
||||
) : allData.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||
No application data available.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Minimum sample toggle */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Label
|
||||
htmlFor="include-small-toggle"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{includeSmall ? (
|
||||
"All sources"
|
||||
) : (
|
||||
<>
|
||||
n < {MIN_SAMPLE_DEFAULT} hidden
|
||||
{hiddenCount > 0 && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
({hiddenCount})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
<Switch
|
||||
id="include-small-toggle"
|
||||
checked={includeSmall}
|
||||
onCheckedChange={setIncludeSmall}
|
||||
aria-label="Include small samples"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="px-4 py-4 text-sm text-muted-foreground">
|
||||
All sources have fewer than {MIN_SAMPLE_DEFAULT} applications.
|
||||
Enable the toggle above to show them.
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ left: 4, right: 36, top: 4, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="source"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={108}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--chart-2)", opacity: 0.15 }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as SourceRateDataPoint;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
|
||||
<div className="mb-1.5 font-medium">{d.source}</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>Response rate</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{d.rate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>Responded</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{d.responded}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-1.5 text-[10px] text-muted-foreground/70">
|
||||
Screening, interview, or offer only
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="rate" radius={[0, 4, 4, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.source}
|
||||
fill={BAR_COLORS[index % BAR_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="rate"
|
||||
position="right"
|
||||
formatter={(v: number) => `${v.toFixed(0)}%`}
|
||||
className="text-xs fill-foreground"
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -2,3 +2,4 @@ export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
|
||||
export { ConversionAnalytics } from "./ConversionAnalytics";
|
||||
export type { DurationValue } from "./DurationSelector";
|
||||
export { DurationSelector } from "./DurationSelector";
|
||||
export { ResponseRateBySourceChart } from "./ResponseRateBySourceChart";
|
||||
|
||||
@ -4,9 +4,10 @@ import {
|
||||
ConversionAnalytics,
|
||||
DurationSelector,
|
||||
type DurationValue,
|
||||
ResponseRateBySourceChart,
|
||||
} from "@client/components/charts";
|
||||
import { PageHeader, PageMain } from "@client/components/layout";
|
||||
import type { StageEvent } from "@shared/types.js";
|
||||
import type { JobSource, StageEvent } from "@shared/types.js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChartColumn } from "lucide-react";
|
||||
import type React from "react";
|
||||
@ -16,6 +17,7 @@ import { queryKeys } from "@/client/lib/queryKeys";
|
||||
|
||||
type JobWithEvents = {
|
||||
id: string;
|
||||
source: JobSource;
|
||||
datePosted: string | null;
|
||||
discoveredAt: string;
|
||||
appliedAt: string | null;
|
||||
@ -54,10 +56,10 @@ export const HomePage: React.FC = () => {
|
||||
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
||||
const jobSummaries = response.jobs.map((job) => ({
|
||||
id: job.id,
|
||||
source: job.source,
|
||||
datePosted: job.datePosted,
|
||||
discoveredAt: job.discoveredAt,
|
||||
appliedAt: job.appliedAt,
|
||||
positiveResponse: false,
|
||||
}));
|
||||
|
||||
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
||||
@ -151,6 +153,8 @@ export const HomePage: React.FC = () => {
|
||||
error={error}
|
||||
daysToShow={duration}
|
||||
/>
|
||||
|
||||
<ResponseRateBySourceChart jobs={jobsWithEvents} error={error} />
|
||||
</PageMain>
|
||||
</>
|
||||
);
|
||||
|
||||
27
orchestrator/src/components/ui/switch.tsx
Normal file
27
orchestrator/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@ -7118,6 +7118,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@ -24652,6 +24681,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user