222 lines
6.4 KiB
TypeScript
222 lines
6.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useId, useMemo, useState } from "react";
|
||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||
import {
|
||
searchMeshkeeCategories,
|
||
type MeshkeeCategory,
|
||
} from "@/services/meshkee-categories";
|
||
import styles from "./WebsiteLinkGenerator.module.css";
|
||
|
||
export type WebsiteLinkKind = "businesses" | "blog";
|
||
|
||
function buildCategoryLink(kind: WebsiteLinkKind, category: MeshkeeCategory) {
|
||
const path = kind === "blog" ? "/blog" : "/businesses";
|
||
const query = category.slug
|
||
? `category=${encodeURIComponent(category.slug)}`
|
||
: `categoryId=${encodeURIComponent(category.id)}`;
|
||
return `${path}?${query}`;
|
||
}
|
||
|
||
function LinkIcon() {
|
||
return (
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.75"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden
|
||
>
|
||
<path d="M10 13a5 5 0 0 0 7.54.54l1.92-1.92a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||
<path d="M14 11a5 5 0 0 0-7.54-.54L4.54 12.38a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export default function WebsiteLinkGenerator({
|
||
onPick,
|
||
disabled = false,
|
||
}: {
|
||
onPick: (url: string) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
const titleId = useId();
|
||
const [open, setOpen] = useState(false);
|
||
const [kind, setKind] = useState<WebsiteLinkKind>("businesses");
|
||
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
|
||
const [categoryId, setCategoryId] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setKind("businesses");
|
||
setCategoryId("");
|
||
setError("");
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") setOpen(false);
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
window.removeEventListener("keydown", onKey);
|
||
};
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const controller = new AbortController();
|
||
setLoading(true);
|
||
setError("");
|
||
void searchMeshkeeCategories("", controller.signal)
|
||
.then((items) => {
|
||
if (controller.signal.aborted) return;
|
||
setCategories(items);
|
||
})
|
||
.catch((err) => {
|
||
if (controller.signal.aborted) return;
|
||
setCategories([]);
|
||
setError(
|
||
err instanceof Error ? err.message : "Could not load categories.",
|
||
);
|
||
})
|
||
.finally(() => {
|
||
if (!controller.signal.aborted) setLoading(false);
|
||
});
|
||
|
||
return () => controller.abort();
|
||
}, [open]);
|
||
|
||
const selected = useMemo(
|
||
() => categories.find((item) => item.id === categoryId) ?? null,
|
||
[categories, categoryId],
|
||
);
|
||
const preview = selected ? buildCategoryLink(kind, selected) : "";
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={styles.trigger}
|
||
aria-label="Generate link"
|
||
title="Generate link"
|
||
disabled={disabled}
|
||
onClick={() => setOpen(true)}
|
||
>
|
||
<LinkIcon />
|
||
</button>
|
||
|
||
{open ? (
|
||
<div
|
||
className={styles.overlay}
|
||
role="presentation"
|
||
onClick={() => setOpen(false)}
|
||
>
|
||
<div
|
||
className={styles.modal}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className={styles.head}>
|
||
<div>
|
||
<p className={styles.kicker}>Link generator</p>
|
||
<h3 id={titleId} className={styles.title}>
|
||
Build category link
|
||
</h3>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.closeBtn}
|
||
aria-label="Close"
|
||
onClick={() => setOpen(false)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.kindSwitch} role="tablist" aria-label="Link type">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={kind === "businesses"}
|
||
className={`${styles.kindBtn} ${
|
||
kind === "businesses" ? styles.kindBtnActive : ""
|
||
}`}
|
||
onClick={() => setKind("businesses")}
|
||
>
|
||
Businesses category
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={kind === "blog"}
|
||
className={`${styles.kindBtn} ${
|
||
kind === "blog" ? styles.kindBtnActive : ""
|
||
}`}
|
||
onClick={() => setKind("blog")}
|
||
>
|
||
Blog category
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<p className={styles.status}>Loading categories…</p>
|
||
) : error ? (
|
||
<p className={styles.error}>{error}</p>
|
||
) : (
|
||
<CategoryTreeSelect
|
||
categories={categories}
|
||
value={categoryId ? [categoryId] : []}
|
||
onChange={(ids) => setCategoryId(ids[0] ?? "")}
|
||
selection="single"
|
||
dropdownVariant="inFlow"
|
||
label="Category"
|
||
placeholder="Search categories…"
|
||
/>
|
||
)}
|
||
|
||
<div className={styles.preview}>
|
||
<span>Preview</span>
|
||
<code dir="ltr">{preview || "Select a category"}</code>
|
||
</div>
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.secondaryBtn}`}
|
||
onClick={() => setOpen(false)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.primaryBtn}`}
|
||
disabled={!preview}
|
||
onClick={() => {
|
||
if (!preview) return;
|
||
onPick(preview);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
Use link
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|