feat: improve mobile UX (#155)
This commit is contained in:
parent
b9ad31704f
commit
ca260f544b
@ -67,6 +67,7 @@ export function makeSelectionColumn<TData>(
|
|||||||
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
||||||
checked={table.getIsAllRowsSelected()}
|
checked={table.getIsAllRowsSelected()}
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
aria-label="Select all rows"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row, table }) => (
|
cell: ({ row, table }) => (
|
||||||
@ -90,6 +91,7 @@ function SelectionCheckbox<TData>({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
|
aria-label="Select row"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
rangeHandledRef.current = applyShiftRangeSelection(
|
rangeHandledRef.current = applyShiftRangeSelection(
|
||||||
|
|||||||
@ -34,12 +34,12 @@ export function DomainSearchCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`input input-bordered flex flex-1 items-center gap-2 ${domainError ? "input-error" : ""}`}
|
className={`input input-bordered flex items-center gap-2 w-full lg:flex-1 lg:min-w-0 lg:max-w-md ${domainError ? "input-error" : ""}`}
|
||||||
>
|
>
|
||||||
<Search className="size-4 text-base-content/60" />
|
<Search className="size-4 text-base-content/60" />
|
||||||
<input
|
<input
|
||||||
className="grow"
|
className="grow min-w-0"
|
||||||
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
|
placeholder="Enter a domain"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
aria-invalid={domainError ? true : undefined}
|
aria-invalid={domainError ? true : undefined}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export function getDomainSearchValidationErrors(value: DomainControlsValues) {
|
|||||||
if (!normalizeDomainTarget(value.domain)) {
|
if (!normalizeDomainTarget(value.domain)) {
|
||||||
return createFormValidationErrors({
|
return createFormValidationErrors({
|
||||||
fields: {
|
fields: {
|
||||||
domain: "Please enter a valid URL or domain (e.g. browserbase.com)",
|
domain: "Please enter a valid URL or domain (e.g. example.com)",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,71 +47,6 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeywordCard({
|
|
||||||
row,
|
|
||||||
isSelected,
|
|
||||||
isActive,
|
|
||||||
onToggle,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
row: KeywordResearchRow;
|
|
||||||
isSelected: boolean;
|
|
||||||
isActive: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bg-base-100 border border-base-300 rounded-lg p-3 space-y-2 cursor-pointer transition-colors ${
|
|
||||||
isActive ? "border-primary bg-primary/5" : "hover:bg-base-200/50"
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-sm shrink-0 mt-0.5"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggle();
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 font-semibold text-sm capitalize leading-tight">
|
|
||||||
{row.keyword}
|
|
||||||
</span>
|
|
||||||
<ScoreBadge value={row.keywordDifficulty} size="sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base-content/50">Volume</p>
|
|
||||||
<p className="font-medium tabular-nums">
|
|
||||||
{formatNumber(row.searchVolume)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base-content/50">CPC</p>
|
|
||||||
<p className="font-medium tabular-nums">
|
|
||||||
{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base-content/50">Comp.</p>
|
|
||||||
<p className="font-medium tabular-nums">
|
|
||||||
{row.competition == null ? "-" : row.competition.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-1">
|
|
||||||
<IntentBadge intent={row.intent} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScoreBadge({
|
function ScoreBadge({
|
||||||
value,
|
value,
|
||||||
size = "sm",
|
size = "sm",
|
||||||
|
|||||||
@ -4,7 +4,11 @@ function getNextSelectionSet(
|
|||||||
current: Set<string>,
|
current: Set<string>,
|
||||||
allVisibleKeywords: string[],
|
allVisibleKeywords: string[],
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
if (current.size === allVisibleKeywords.length) {
|
const allVisibleSelected =
|
||||||
|
allVisibleKeywords.length > 0 &&
|
||||||
|
allVisibleKeywords.every((keyword) => current.has(keyword));
|
||||||
|
|
||||||
|
if (allVisibleSelected) {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
type SortDir,
|
type SortDir,
|
||||||
type SortField,
|
type SortField,
|
||||||
} from "@/client/features/keywords/components";
|
} from "@/client/features/keywords/components";
|
||||||
import { formatNumber } from "@/client/features/keywords/utils";
|
import { formatNumber, scoreTierClass } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
||||||
|
|
||||||
@ -66,18 +66,21 @@ export function KeywordResearchDesktopTable({
|
|||||||
current={sortField}
|
current={sortField}
|
||||||
dir={sortDir}
|
dir={sortDir}
|
||||||
onToggle={toggleSort}
|
onToggle={toggleSort}
|
||||||
className="min-w-0"
|
className="min-w-48 md:min-w-0"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<span
|
||||||
className="block truncate font-medium capitalize"
|
className="block min-w-48 whitespace-normal break-words font-medium capitalize md:min-w-0 md:truncate"
|
||||||
title={row.original.keyword}
|
title={row.original.keyword}
|
||||||
>
|
>
|
||||||
{row.original.keyword}
|
{row.original.keyword}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { cellClassName: "min-w-0" },
|
meta: {
|
||||||
|
headerClassName: "min-w-48 md:min-w-0",
|
||||||
|
cellClassName: "min-w-48 md:min-w-0",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
keywordColumnHelper.accessor("searchVolume", {
|
keywordColumnHelper.accessor("searchVolume", {
|
||||||
header: () => (
|
header: () => (
|
||||||
@ -93,7 +96,8 @@ export function KeywordResearchDesktopTable({
|
|||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "text-right",
|
headerClassName: "text-right",
|
||||||
cellClassName: "text-right tabular-nums text-base-content/70",
|
cellClassName:
|
||||||
|
"whitespace-nowrap text-right tabular-nums text-base-content/70",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
keywordColumnHelper.accessor("cpc", {
|
keywordColumnHelper.accessor("cpc", {
|
||||||
@ -114,7 +118,8 @@ export function KeywordResearchDesktopTable({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "text-right",
|
headerClassName: "text-right",
|
||||||
cellClassName: "text-right tabular-nums text-base-content/70",
|
cellClassName:
|
||||||
|
"whitespace-nowrap text-right tabular-nums text-base-content/70",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
keywordColumnHelper.accessor("competition", {
|
keywordColumnHelper.accessor("competition", {
|
||||||
@ -135,7 +140,8 @@ export function KeywordResearchDesktopTable({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "text-right",
|
headerClassName: "text-right",
|
||||||
cellClassName: "text-right tabular-nums text-base-content/70",
|
cellClassName:
|
||||||
|
"whitespace-nowrap text-right tabular-nums text-base-content/70",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
keywordColumnHelper.accessor("keywordDifficulty", {
|
keywordColumnHelper.accessor("keywordDifficulty", {
|
||||||
@ -158,7 +164,7 @@ export function KeywordResearchDesktopTable({
|
|||||||
cell: ({ getValue }) => <IntentBadge intent={getValue()} />,
|
cell: ({ getValue }) => <IntentBadge intent={getValue()} />,
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "text-center",
|
headerClassName: "text-center",
|
||||||
cellClassName: "text-center",
|
cellClassName: "whitespace-nowrap text-center",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@ -184,7 +190,7 @@ export function KeywordResearchDesktopTable({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 min-h-0">
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<EmptyFilterResults
|
<EmptyFilterResults
|
||||||
activeFilterCount={activeFilterCount}
|
activeFilterCount={activeFilterCount}
|
||||||
@ -193,8 +199,8 @@ export function KeywordResearchDesktopTable({
|
|||||||
) : (
|
) : (
|
||||||
<AppDataTable
|
<AppDataTable
|
||||||
table={table}
|
table={table}
|
||||||
className="table table-xs w-full"
|
className="table table-xs min-w-max md:w-full"
|
||||||
wrapperClassName="h-full overflow-y-auto"
|
wrapperClassName="h-full overflow-auto"
|
||||||
getRowProps={(row) => ({
|
getRowProps={(row) => ({
|
||||||
className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${
|
className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${
|
||||||
overviewKeyword?.keyword === row.original.keyword
|
overviewKeyword?.keyword === row.original.keyword
|
||||||
@ -211,12 +217,10 @@ export function KeywordResearchDesktopTable({
|
|||||||
|
|
||||||
function ScoreCell({ value }: { value: number | null }) {
|
function ScoreCell({ value }: { value: number | null }) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
let tierClass = "bg-success/20 text-success";
|
const tierClass = scoreTierClass(value);
|
||||||
if (value > 60) tierClass = "bg-error/20 text-error";
|
|
||||||
else if (value > 30) tierClass = "bg-warning/20 text-warning";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold ${tierClass}`}
|
className={`score-badge ${tierClass} inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -9,10 +9,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
import {
|
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
||||||
KeywordCard,
|
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||||
SerpAnalysisCard,
|
|
||||||
} from "@/client/features/keywords/components";
|
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -48,7 +46,7 @@ export function KeywordResearchMobileResults({ controller }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mobileTab === "keywords" ? (
|
{mobileTab === "keywords" ? (
|
||||||
<MobileKeywordCards controller={controller} />
|
<MobileKeywordResults controller={controller} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<SerpAnalysisCard
|
<SerpAnalysisCard
|
||||||
@ -67,7 +65,7 @@ export function KeywordResearchMobileResults({ controller }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileKeywordCards({ controller }: Props) {
|
function MobileKeywordResults({ controller }: Props) {
|
||||||
const {
|
const {
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
filteredRows,
|
filteredRows,
|
||||||
@ -162,34 +160,18 @@ function MobileKeywordCards({ controller }: Props) {
|
|||||||
|
|
||||||
{showFilters ? <MobileFilters controller={controller} /> : null}
|
{showFilters ? <MobileFilters controller={controller} /> : null}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<KeywordResearchDesktopTable
|
||||||
{filteredRows.length === 0 ? (
|
activeFilterCount={controller.activeFilterCount}
|
||||||
<div className="h-full min-h-48 flex flex-col items-center justify-center text-center px-4 text-base-content/50 gap-3">
|
filteredRows={controller.filteredRows}
|
||||||
<p className="text-sm font-medium">
|
overviewKeyword={controller.overviewKeyword}
|
||||||
No keywords match your current filters.
|
selectedRows={controller.selectedRows}
|
||||||
</p>
|
setSelectedRows={controller.setSelectedRows}
|
||||||
{activeFilterCount > 0 ? (
|
sortDir={controller.sortDir}
|
||||||
<button
|
sortField={controller.sortField}
|
||||||
className="btn btn-ghost btn-sm"
|
toggleSort={controller.toggleSort}
|
||||||
onClick={controller.resetFilters}
|
resetFilters={controller.resetFilters}
|
||||||
>
|
handleRowClick={controller.handleRowClick}
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredRows.map((row) => (
|
|
||||||
<KeywordCard
|
|
||||||
key={row.keyword}
|
|
||||||
row={row}
|
|
||||||
isSelected={selectedRows.has(row.keyword)}
|
|
||||||
isActive={controller.overviewKeyword?.keyword === row.keyword}
|
|
||||||
onToggle={() => controller.toggleRowSelection(row.keyword)}
|
|
||||||
onClick={() => controller.handleRowClick(row)}
|
|
||||||
/>
|
/>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
<div className="card border border-base-300 bg-base-100">
|
<div className="card border border-base-300 bg-base-100">
|
||||||
<div className="card-body gap-2">
|
<div className="card-body gap-2">
|
||||||
<form
|
<form
|
||||||
className="w-full flex flex-wrap items-center gap-2"
|
className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center lg:gap-2"
|
||||||
onSubmit={handleSearchSubmit}
|
onSubmit={handleSearchSubmit}
|
||||||
>
|
>
|
||||||
<controlsForm.Field name="keyword">
|
<controlsForm.Field name="keyword">
|
||||||
@ -28,12 +28,12 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`input input-bordered flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`}
|
className={`input input-bordered flex items-center gap-2 w-full lg:flex-1 lg:min-w-0 lg:max-w-md ${keywordError ? "input-error" : ""}`}
|
||||||
>
|
>
|
||||||
<Search className="size-4 shrink-0 text-base-content/60" />
|
<Search className="size-4 shrink-0 text-base-content/60" />
|
||||||
<input
|
<input
|
||||||
className="grow min-w-0"
|
className="grow min-w-0"
|
||||||
placeholder="Enter Keyword"
|
placeholder="Enter keyword"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -42,10 +42,11 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
}}
|
}}
|
||||||
</controlsForm.Field>
|
</controlsForm.Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:contents">
|
||||||
<controlsForm.Field name="locationCode">
|
<controlsForm.Field name="locationCode">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-sm w-auto"
|
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
field.handleChange(Number(event.target.value))
|
field.handleChange(Number(event.target.value))
|
||||||
@ -63,7 +64,7 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
<controlsForm.Field name="resultLimit">
|
<controlsForm.Field name="resultLimit">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-sm w-auto"
|
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = Number(event.target.value);
|
const next = Number(event.target.value);
|
||||||
@ -82,7 +83,7 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
<controlsForm.Field name="mode">
|
<controlsForm.Field name="mode">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-sm w-auto"
|
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
field.handleChange(normalizeKeywordMode(event.target.value))
|
field.handleChange(normalizeKeywordMode(event.target.value))
|
||||||
@ -98,11 +99,12 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary btn-sm px-6 font-semibold"
|
className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? "Searching..." : "Search"}
|
{isLoading ? "Searching..." : "Search"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<controlsForm.Field name="keyword">
|
<controlsForm.Field name="keyword">
|
||||||
{(field) => {
|
{(field) => {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ describe("safeHttpUrl", () => {
|
|||||||
"https://example.com",
|
"https://example.com",
|
||||||
"http://example.com",
|
"http://example.com",
|
||||||
"https://example.com/path?q=1#frag",
|
"https://example.com/path?q=1#frag",
|
||||||
"https://sub.example.io",
|
"https://sub.example.com",
|
||||||
])("accepts %s", (input) => {
|
])("accepts %s", (input) => {
|
||||||
expect(safeHttpUrl(input)).toBe(input);
|
expect(safeHttpUrl(input)).toBe(input);
|
||||||
});
|
});
|
||||||
@ -35,7 +35,7 @@ describe("safeHttpUrl", () => {
|
|||||||
describe("safeHostname", () => {
|
describe("safeHostname", () => {
|
||||||
it("strips protocol and www prefix", () => {
|
it("strips protocol and www prefix", () => {
|
||||||
expect(safeHostname("https://www.example.com/path")).toBe("example.com");
|
expect(safeHostname("https://www.example.com/path")).toBe("example.com");
|
||||||
expect(safeHostname("http://sub.example.io")).toBe("sub.example.io");
|
expect(safeHostname("http://sub.example.com")).toBe("sub.example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for unsafe schemes", () => {
|
it("returns null for unsafe schemes", () => {
|
||||||
|
|||||||
@ -3,17 +3,17 @@ import { detectTarget } from "./targetDetection";
|
|||||||
|
|
||||||
describe("detectTarget", () => {
|
describe("detectTarget", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["opus.pro", "opus.pro"],
|
["example.com", "example.com"],
|
||||||
["https://opus.pro", "opus.pro"],
|
["https://example.com", "example.com"],
|
||||||
["https://www.opus.pro/features", "opus.pro"],
|
["https://www.example.com/features", "example.com"],
|
||||||
["WWW.Example.COM", "example.com"],
|
["WWW.Example.COM", "example.com"],
|
||||||
["sub.example.io", "sub.example.io"],
|
["sub.example.com", "sub.example.com"],
|
||||||
])("treats %s as a domain", (input, expected) => {
|
])("treats %s as a domain", (input, expected) => {
|
||||||
expect(detectTarget(input)).toEqual({ type: "domain", value: expected });
|
expect(detectTarget(input)).toEqual({ type: "domain", value: expected });
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
"Opus Clip",
|
"Example Brand",
|
||||||
"best ai video clipper",
|
"best ai video clipper",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"GPT-5",
|
"GPT-5",
|
||||||
@ -40,9 +40,9 @@ describe("detectTarget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("trims whitespace before classification", () => {
|
it("trims whitespace before classification", () => {
|
||||||
expect(detectTarget(" opus.pro ")).toEqual({
|
expect(detectTarget(" example.com ")).toEqual({
|
||||||
type: "domain",
|
type: "domain",
|
||||||
value: "opus.pro",
|
value: "example.com",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,8 +6,8 @@ type DetectedTarget = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether free-text input is a domain (e.g. "opus.pro") or a brand
|
* Decide whether free-text input is a domain (e.g. "example.com") or a brand
|
||||||
* keyword (e.g. "Opus Clip"). Heuristic: no whitespace + contains a dot +
|
* keyword (e.g. "Example Brand"). Heuristic: no whitespace + contains a dot +
|
||||||
* `normalizeDomain` produces a valid hostname.
|
* `normalizeDomain` produces a valid hostname.
|
||||||
*/
|
*/
|
||||||
export function detectTarget(rawInput: string): DetectedTarget {
|
export function detectTarget(rawInput: string): DetectedTarget {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user