changed reward on button
This commit is contained in:
28
src/components/feed/FeedBlurMask.jsx
Normal file
28
src/components/feed/FeedBlurMask.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Отдельный блюр-слой поверх ленты, независим от FeedTopBar.
|
||||
* Прижат к верху общего контейнера, высота подстраивается под
|
||||
* реальную высоту topBar (включая safe-area на iOS).
|
||||
*/
|
||||
export default function FeedBlurMask({ topBarRef }) {
|
||||
const [height, setHeight] = useState(56);
|
||||
|
||||
useEffect(() => {
|
||||
const el = topBarRef?.current;
|
||||
if (!el) return;
|
||||
const update = () => setHeight(el.getBoundingClientRect().height || 56);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [topBarRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="f-feed-blur-mask"
|
||||
style={{ height }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
150
src/components/feed/FeedTopBar.tsx
Normal file
150
src/components/feed/FeedTopBar.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MenuIconBase from "../icons/MenuIcon";
|
||||
import NahodkaLogoBase from "../NahodkaLogo";
|
||||
import type { Ref, ComponentType } from "react";
|
||||
|
||||
// Legacy .jsx-компоненты: TS инферирует их сигнатуры из JS неточно —
|
||||
// приводим к нормальному виду для корректной проверки пропсов.
|
||||
const NahodkaLogo = NahodkaLogoBase as unknown as ComponentType<{
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>;
|
||||
const MenuIcon = MenuIconBase as unknown as ComponentType<{ className?: string }>;
|
||||
|
||||
interface FeedTopBarProps {
|
||||
onMenuClick: () => void;
|
||||
typeFilter: "loss" | "found" | null;
|
||||
onTypeFilterChange: (key: "loss" | "found" | null) => void;
|
||||
hidden?: boolean;
|
||||
/** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */
|
||||
topBarRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FeedTopBar — «стеклянная» шапка ленты (Telegram-style).
|
||||
*
|
||||
* Эффекты (разделены, чтобы было понятно что за что отвечает):
|
||||
* - стекло: backdrop-filter blur+saturate + полупрозрачный фон (см. .f-topbar в layout.css);
|
||||
* - плавное скрытие/появление при скролле: родитель передаёт `hidden`
|
||||
* (значение из useScrollDirection), CSS делает transform: translateY(-100%)
|
||||
* — скрывается вверх, контент ленты проплывает под ним;
|
||||
* - self-contained: вся логика дропдауна фильтра типа осталась здесь.
|
||||
*/
|
||||
export default function FeedTopBar({
|
||||
onMenuClick,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
hidden = false,
|
||||
topBarRef,
|
||||
}: FeedTopBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const options: { key: "loss" | "found" | null; label: string }[] = [
|
||||
{ key: "loss", label: t("feed.filterLoss") },
|
||||
{ key: null, label: t("feed.filterAll") },
|
||||
{ key: "found", label: t("feed.filterFound") },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||
btnRef.current && !btnRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (key: "loss" | "found" | null) => {
|
||||
onTypeFilterChange(key);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="f-topbar-dropdown-wrap">
|
||||
<div className={`f-topbar${hidden ? " f-topbar-hidden" : ""}`} ref={topBarRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="f-menu-btn"
|
||||
onClick={onMenuClick}
|
||||
aria-label={String(t("feed.openMenu"))}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className="f-logo-btn"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={String(t("feed.openFilter"))}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<NahodkaLogo className="f-topbar-logo" />
|
||||
<svg
|
||||
className={`f-topbar-chevron${open ? " open" : ""}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span className="f-topbar-spacer" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="f-topbar-dropdown-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
className="f-topbar-dropdown-menu"
|
||||
initial={{ opacity: 0, scale: 0.95, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -8 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.key ?? "all"}
|
||||
type="button"
|
||||
className={`f-topbar-dropdown-item${typeFilter === opt.key ? " active" : ""}`}
|
||||
onClick={() => handleSelect(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/feed/PullToRefresh.jsx
Normal file
111
src/components/feed/PullToRefresh.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const THRESHOLD = 64;
|
||||
const MAX_PULL = 120;
|
||||
|
||||
/**
|
||||
* Pull-to-refresh для ленты: тянем ленту вниз, пока она наверху, —
|
||||
* появляется круговой индикатор, отпускаем — данные обновляются.
|
||||
*
|
||||
* containerRef — скролл-контейнер ленты (.f-feed-scroll).
|
||||
* onRefresh — асинхронный колбэк; индикатор крутится, пока его промис
|
||||
* не разрешится, затем прячется.
|
||||
*/
|
||||
export default function PullToRefresh({ containerRef, onRefresh, disabled = false }) {
|
||||
const [pull, setPullState] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const pullRef = useRef(0);
|
||||
const startY = useRef(null);
|
||||
const active = useRef(false);
|
||||
const refreshRef = useRef(onRefresh);
|
||||
refreshRef.current = onRefresh;
|
||||
|
||||
const setPull = (value) => {
|
||||
pullRef.current = value;
|
||||
setPullState(value);
|
||||
};
|
||||
|
||||
const visible = pull > 0 || refreshing;
|
||||
const rotate = refreshing ? 0 : pull * 2;
|
||||
|
||||
// Пока тянем или обновляемся — снимаем маску затухания с контейнера,
|
||||
// иначе индикатор (он первый элемент внутри скролла) растворится в маске.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.classList.toggle("f-ptr-active", visible);
|
||||
}, [visible, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setPull(0);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onStart = (e) => {
|
||||
if (el.scrollTop <= 0 && !refreshing) {
|
||||
startY.current = e.touches[0].clientY;
|
||||
active.current = true;
|
||||
setDragging(true);
|
||||
} else {
|
||||
startY.current = null;
|
||||
active.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!active.current || startY.current == null) return;
|
||||
const dy = (e.touches[0].clientY - startY.current) * 0.5;
|
||||
if (dy > 0 && el.scrollTop <= 0) {
|
||||
setPull(Math.min(dy, MAX_PULL));
|
||||
} else {
|
||||
setPull(0);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = async () => {
|
||||
if (!active.current) return;
|
||||
active.current = false;
|
||||
startY.current = null;
|
||||
setDragging(false);
|
||||
if (pullRef.current >= THRESHOLD && !refreshing) {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refreshRef.current?.();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
setPull(0);
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onStart, { passive: true });
|
||||
el.addEventListener("touchmove", onMove, { passive: true });
|
||||
el.addEventListener("touchend", onEnd, { passive: true });
|
||||
el.addEventListener("touchcancel", onEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onStart);
|
||||
el.removeEventListener("touchmove", onMove);
|
||||
el.removeEventListener("touchend", onEnd);
|
||||
el.removeEventListener("touchcancel", onEnd);
|
||||
};
|
||||
}, [containerRef, disabled, refreshing]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`f-ptr${visible ? " f-ptr-visible" : ""}${refreshing ? " f-ptr-refreshing" : ""}${dragging ? " f-ptr-dragging" : ""}`}
|
||||
style={{ height: refreshing ? 44 : pull }}
|
||||
>
|
||||
<div className="f-ptr-scale">
|
||||
<div className="f-ptr-spinner" style={{ transform: `rotate(${rotate}deg)` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/feed/RewardPopup.jsx
Normal file
69
src/components/feed/RewardPopup.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useLayoutEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Всплывающее окно с текстом вознаграждения (rewardText).
|
||||
* Открывается под/рядом с кнопкой-якорем, закрывается по клику мимо или Esc.
|
||||
*/
|
||||
export default function RewardPopup({ text, anchorRef, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef(null);
|
||||
const [coords, setCoords] = useState(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchorRef?.current || !ref.current) return;
|
||||
const anchorRect = anchorRef.current.getBoundingClientRect();
|
||||
const popupRect = ref.current.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const margin = 10;
|
||||
|
||||
// Всплываем слева сверху от кнопки (как календарь): низ попапа — у верха якоря,
|
||||
// правая грань попапа выравнивается по правой грани кнопки.
|
||||
let top = anchorRect.top + window.scrollY - popupRect.height - gap;
|
||||
const minTop = window.scrollY + margin;
|
||||
if (top < minTop) top = minTop;
|
||||
|
||||
let left = anchorRect.right + window.scrollX - popupRect.width;
|
||||
const minLeft = window.scrollX + margin;
|
||||
if (left < minLeft) left = minLeft;
|
||||
|
||||
setCoords({ top, left });
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e) => {
|
||||
const inAnchor = anchorRef?.current?.contains(e.target);
|
||||
const inPopup = ref.current?.contains(e.target);
|
||||
if (!inAnchor && !inPopup) onClose();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="f-reward-popup"
|
||||
ref={ref}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: coords?.top ?? 0,
|
||||
left: coords?.left ?? 0,
|
||||
zIndex: 1000,
|
||||
visibility: coords ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="f-reward-popup-title">💛 {t("postCard.reward")}</div>
|
||||
<div className="f-reward-popup-text">{text}</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user