changed reward on button

This commit is contained in:
SlimusMinus
2026-09-10 00:57:31 +03:00
parent 8839b9b2cc
commit c58a921d73
16 changed files with 1239 additions and 9 deletions

View 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"
/>
);
}

View 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>
);
}

View 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>
);
}

View 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
);
}