Files
heygo/src/components/LandingHero.tsx
T

416 lines
14 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { ArrowRight, Plus, Search, X } from 'lucide-react';
import { type Link, listPublicLinks } from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url';
import { useCurrentUser } from '../lib/auth';
import { useTranslation, Trans } from 'react-i18next';
const DEBOUNCE_MS = 220;
const MAX_RESULTS = 8;
const MIN_QUERY_LENGTH = 1;
const SUGGESTIONS = ['canva', 'piano', 'claude'] as const;
interface SearchState {
readonly links: Link[];
readonly loading: boolean;
readonly error: string | null;
readonly query: string;
}
const INITIAL_STATE: SearchState = { links: [], loading: false, error: null, query: '' };
function isExactMatch(link: Link, query: string): boolean {
return link.alias.toLowerCase() === query.trim().toLowerCase();
}
export default function LandingHero() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const [inputValue, setInputValue] = useState('');
const [state, setState] = useState<SearchState>(INITIAL_STATE);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestIdRef = useRef(0);
const trimmedQuery = inputValue.trim();
const fetchResults = useCallback(async (query: string) => {
const requestId = ++requestIdRef.current;
setState((prev) => ({ ...prev, loading: true, error: null, query }));
try {
const result = await listPublicLinks(query);
if (requestId !== requestIdRef.current) return;
const links = (result.links ?? []).slice(0, MAX_RESULTS);
setState({ links, loading: false, error: null, query });
setOpen(true);
setActiveIndex(-1);
} catch (err) {
if (requestId !== requestIdRef.current) return;
setState({
links: [],
loading: false,
error: err instanceof Error ? err.message : t('landing.searchFail'),
query,
});
setOpen(true);
setActiveIndex(-1);
}
}, [t]);
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
setState(INITIAL_STATE);
setOpen(false);
setActiveIndex(-1);
return;
}
debounceRef.current = setTimeout(() => {
void fetchResults(trimmedQuery);
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
}, [trimmedQuery, fetchResults]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
requestIdRef.current++;
};
}, []);
useEffect(() => {
function handleClickOutside(event: Event) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, []);
const results = useMemo(() => state.links, [state.links]);
const exactMatch = useMemo(
() => results.find((link) => isExactMatch(link, trimmedQuery)) ?? null,
[results, trimmedQuery],
);
const showDropdown = open && trimmedQuery.length >= MIN_QUERY_LENGTH;
const showLoading = showDropdown && state.loading;
const showError = showDropdown && !!state.error;
const showNoResults = showDropdown && !state.loading && !state.error && results.length === 0;
const showResults = showDropdown && !state.loading && !state.error && results.length > 0;
function goToShortlink(alias: string) {
window.location.href = `/${alias}`;
}
function handleCreate() {
// 未登录时 #/my-links 自动渲染登录页;登录后路由停留在 my-links(即"我的链接/创建链接"页)
// 携带 create 提示参数,供未来扩展自动展开创建表单
const sep = window.location.hash.includes('?') ? '&' : '?';
window.location.hash = `#/my-links${sep}create=1`;
}
function handlePrimaryAction() {
if (exactMatch) {
goToShortlink(exactMatch.alias);
} else if (user) {
// 已登录但当前在公开着陆页 → 直接进入我的链接页
window.location.hash = '#/my-links';
} else if (trimmedQuery) {
handleCreate();
}
}
function handleSelect(link: Link) {
goToShortlink(link.alias);
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault();
if (showDropdown && activeIndex >= 0 && activeIndex < results.length) {
handleSelect(results[activeIndex]);
} else {
handlePrimaryAction();
}
return;
}
if (!showDropdown || results.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setOpen(true);
setActiveIndex((prev) => (prev + 1) % results.length);
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1));
break;
case 'Escape':
event.preventDefault();
setOpen(false);
setActiveIndex(-1);
inputRef.current?.blur();
break;
case 'Tab':
setOpen(false);
break;
}
}
function clearSearch() {
setInputValue('');
setState(INITIAL_STATE);
setOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
}
function pickSuggestion(alias: string) {
setInputValue(alias);
inputRef.current?.focus();
}
const hasQuery = trimmedQuery.length > 0;
const canCreate = hasQuery && !exactMatch && !state.loading;
const primaryLabel = exactMatch ? t('landing.goLabel') : canCreate ? t('landing.createPersonalLink') : t('landing.goLabel');
const primaryDisabled = !exactMatch && !canCreate;
const primaryVariant = exactMatch ? 'is-go' : canCreate ? 'is-create' : 'is-idle';
return (
<section className="landing-hero" ref={containerRef}>
<div className="hero-aurora" aria-hidden="true" />
<div className="hero-grid" aria-hidden="true" />
<div className="hero-content">
<div className="hero-eyebrow">
<span className="hero-eyebrow__dot" aria-hidden="true" />
<span>{t('landing.eyebrow')}</span>
</div>
<h1 className="hero-title">
<span className="hero-brand">Heygo</span>
<span className="hero-suffix">{t('landing.brandSuffix')}</span>
</h1>
<p className="hero-description">
<Trans i18nKey="landing.description" components={{ 1: <br /> }} />
</p>
<div className="hero-features" aria-hidden="true">
<div className="feature-pill"><span className="feature-dot" />{t('landing.featurePublic')}</div>
<div className="feature-pill"><span className="feature-dot" />{t('landing.featurePrivate')}</div>
<div className="feature-pill"><span className="feature-dot" />{t('landing.featureShared')}</div>
<div className="feature-pill"><span className="feature-dot" />{t('landing.featureCrossPlatform')}</div>
</div>
</div>
<div className={`hero-search${showDropdown ? ' is-open' : ''}`}>
<div className="hero-search__shell">
<div className="hero-search__prefix">
<Search className="hero-search__prefix-icon" size={16} strokeWidth={2.4} aria-hidden="true" />
<span className="hero-search__prefix-text">{t('landing.searchPrefix')}</span>
</div>
<input
ref={inputRef}
type="text"
className="hero-search__field"
placeholder={t('landing.searchPlaceholder')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (results.length > 0 && trimmedQuery.length >= MIN_QUERY_LENGTH) setOpen(true);
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="go"
aria-label={t('landing.searchLabel')}
aria-expanded={showDropdown}
aria-autocomplete="list"
aria-controls="hero-search-results"
role="combobox"
/>
{hasQuery ? (
<button
type="button"
className="hero-search__clear"
onClick={clearSearch}
aria-label={t('landing.clearLabel')}
tabIndex={-1}
>
<X size={16} strokeWidth={2.5} aria-hidden="true" />
</button>
) : null}
<button
type="button"
className={`hero-search__action ${primaryVariant}`}
onClick={handlePrimaryAction}
disabled={primaryDisabled}
aria-label={exactMatch ? t('landing.goToAlias', { alias: exactMatch.alias }) : primaryLabel}
>
{exactMatch ? (
<>
<span className="hero-search__action-label">{primaryLabel}</span>
<ArrowRight size={18} strokeWidth={2.5} aria-hidden="true" />
</>
) : canCreate ? (
<>
<Plus size={16} strokeWidth={2.6} aria-hidden="true" />
<span className="hero-search__action-label">{primaryLabel}</span>
</>
) : (
<>
<span className="hero-search__action-label">{primaryLabel}</span>
<ArrowRight size={18} strokeWidth={2.5} aria-hidden="true" />
</>
)}
</button>
</div>
{showDropdown ? (
<div id="hero-search-results" className="hero-search__dropdown" role="listbox">
{showLoading ? (
<div className="hero-search__status">
<span className="hero-search__spinner" aria-hidden="true" />
<span>{t('landing.searching')}</span>
</div>
) : null}
{showError ? (
<div className="hero-search__status hero-search__status--error" role="alert">
{state.error}
</div>
) : null}
{showNoResults ? (
<div className="hero-search__empty">
<div className="hero-search__empty-title">
<Trans
i18nKey="landing.noResultsTitle"
values={{ query: trimmedQuery }}
components={{ 1: <code /> }}
/>
</div>
<div className="hero-search__empty-hint">
{t('landing.noResultsHint')}
</div>
<button
type="button"
className="hero-search__empty-cta"
onClick={handleCreate}
>
<Plus size={15} strokeWidth={2.6} aria-hidden="true" />
{t('landing.createPersonalLink')}
</button>
</div>
) : null}
{showResults
? results.map((link, index) => {
const exact = isExactMatch(link, trimmedQuery);
const isActive = index === activeIndex;
const safeUrl =
link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
return (
<div
key={link.id}
role="option"
aria-selected={isActive}
className={`hero-search__result${isActive ? ' is-active' : ''}${
exact ? ' is-exact' : ''
}`}
onClick={() => handleSelect(link)}
onMouseEnter={() => setActiveIndex(index)}
>
<div className="hero-search__result-main">
<span className="hero-search__result-alias">
<span className="hero-search__result-slash">/</span>
{link.alias}
</span>
{exact ? (
<span className="hero-search__exact-badge">{t('landing.exactMatch')}</span>
) : null}
</div>
{link.linkType === 'redirect' && link.targetUrl ? (
<p className="hero-search__result-url">
{safeUrl ? (
<span className="truncate" title={link.targetUrl}>
{link.targetUrl}
</span>
) : (
<span className="muted">{t('landing.invalidLink')}</span>
)}
</p>
) : link.linkType === 'custom' ? (
<p className="hero-search__result-url">
<span className="muted">{t('landing.customPage')}</span>
</p>
) : null}
<div className="hero-search__result-meta">
<span className="hero-search__clicks">
{t('landing.clicksCount', { count: link.clickCount.toLocaleString() })}
</span>
{link.description ? (
<span className="hero-search__result-desc">{link.description}</span>
) : null}
</div>
</div>
);
})
: null}
</div>
) : null}
</div>
<div className="hero-suggestions">
<span className="hero-suggestions__label">{t('landing.suggestionsLabel')}</span>
{SUGGESTIONS.map((alias) => (
<button
key={alias}
type="button"
className="hero-suggestion-chip"
onClick={() => pickSuggestion(alias)}
>
{alias}
</button>
))}
</div>
</section>
);
}