Merge pull request #68 from SlimusMinus/fix-reward
changed reward on button
This commit is contained in:
10
react_front/src/main.tsx
Normal file
10
react_front/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export const EMPTY_FORM = {
|
||||
date: "",
|
||||
address: "",
|
||||
rulesAccepted: false,
|
||||
reward: false
|
||||
reward: false,
|
||||
rewardText: ""
|
||||
};
|
||||
|
||||
export const MAX_PHOTO_SIZE_MB = 5;
|
||||
|
||||
49
src/hooks/useProfile.js
Normal file
49
src/hooks/useProfile.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
|
||||
/**
|
||||
* useProfile — хук для получения свежих данных профиля с бэка.
|
||||
* Делает GET /api/v1/users/{id} и возвращает актуальные данные пользователя
|
||||
* (включая аватар), которые могут устареть в локальном контексте, если
|
||||
* профиль менялся с другого устройства.
|
||||
*
|
||||
* fetchProfile возвращает { success, data, error }.
|
||||
*/
|
||||
export function useProfile() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchProfile = useCallback(async (userId) => {
|
||||
if (!userId) {
|
||||
const message = t('profile.fetchMissingUserId');
|
||||
setError(message);
|
||||
return { success: false, data: null, error: message };
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/api/v1/users/${userId}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message || t('profile.fetchErrorWithStatus', { status: res.status }));
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
return { success: true, data: user, error: null };
|
||||
} catch (err) {
|
||||
const message = err.message || t('profile.fetchError');
|
||||
setError(message);
|
||||
return { success: false, data: null, error: message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
return { fetchProfile, loading, error };
|
||||
}
|
||||
71
src/hooks/useScrollDirection.ts
Normal file
71
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
export type ScrollDirection = "up" | "down";
|
||||
|
||||
/**
|
||||
* useScrollDirection — отслеживает направление скролла контейнера `containerRef`
|
||||
* и сообщает, когда тулбар должен быть скрыт.
|
||||
*
|
||||
* Логика (как в Telegram Web):
|
||||
* - при скролле вниз тулбар прячется (hidden = true);
|
||||
* - при скролле вверх (или подъезде в самое начало списка) — показывается;
|
||||
* - есть порог `showThreshold` у вершины, чтобы тулбар появлялся не раньше,
|
||||
* чем контент вернулся достаточно близко к началу.
|
||||
*
|
||||
* requestAnimationFrame + ref'ы: обработчик скролла НЕ триггерит ре-рендер
|
||||
* на каждое событие — состояние меняется только когда скрытость реально
|
||||
* пересекает границу, всё остальное живёт в ref'ах.
|
||||
*/
|
||||
export default function useScrollDirection<T extends HTMLElement>(
|
||||
containerRef: RefObject<T | null>,
|
||||
{ hideThreshold = 12, showThreshold = 88 }: { hideThreshold?: number; showThreshold?: number } = {},
|
||||
): boolean {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
const hiddenRef = useRef(false);
|
||||
const lastYRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
lastYRef.current = el.scrollTop;
|
||||
|
||||
const onScroll = () => {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const y = el.scrollTop;
|
||||
|
||||
const maxScroll = el.scrollHeight - el.clientHeight;
|
||||
const isAtTop = y <= showThreshold;
|
||||
const isAtBottom = maxScroll > 0 && y >= maxScroll - showThreshold;
|
||||
// Без сенсорных/фрикционных возражений: крошечные рывки не считаем за жест
|
||||
const dy = Math.abs(y - lastYRef.current);
|
||||
|
||||
let next = hiddenRef.current;
|
||||
if (isAtTop || isAtBottom) {
|
||||
next = false;
|
||||
} else if (dy >= hideThreshold) {
|
||||
next = y > lastYRef.current;
|
||||
}
|
||||
lastYRef.current = y;
|
||||
|
||||
if (next !== hiddenRef.current) {
|
||||
hiddenRef.current = next;
|
||||
setHidden(next);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [containerRef, hideThreshold, showThreshold]);
|
||||
|
||||
return hidden;
|
||||
}
|
||||
@@ -115,6 +115,8 @@
|
||||
"addressPlaceholder": "E.g. 123 Main St, New York",
|
||||
"phoneLabel": "Phone number",
|
||||
"rewardCheckbox": "I am willing to offer a reward for the return",
|
||||
"rewardLabel": "Reward amount",
|
||||
"rewardDefault": "To be discussed",
|
||||
"phonePlaceholder": "+1 (___) ___-__-__",
|
||||
"saveChanges": "Save changes",
|
||||
"publish": "Publish ad",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"addressPlaceholder": "Например: Москва, ул. Ленина, 5",
|
||||
"phoneLabel": "Номер телефона",
|
||||
"rewardCheckbox": "Вознаграждение за возврат",
|
||||
"rewardLabel": "Размер вознаграждения",
|
||||
"rewardDefault": "Обсуждается",
|
||||
"phonePlaceholder": "+7 (___) ___-__-__",
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"publish": "Разместить объявление",
|
||||
|
||||
@@ -34,7 +34,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
phone: initialData.phone || "",
|
||||
address: initialData.city || "",
|
||||
rulesAccepted: true,
|
||||
reward: initialData.reward ?? false
|
||||
reward: initialData.reward ?? false,
|
||||
rewardText: initialData.rewardText
|
||||
|| (initialData.reward ? t('post.rewardDefault') : "")
|
||||
}
|
||||
: EMPTY_FORM
|
||||
);
|
||||
@@ -123,6 +125,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
data.append("category", form.category);
|
||||
data.append("phone", form.phone.trim());
|
||||
data.append("reward", form.reward ? "true" : "false");
|
||||
if (form.reward) data.append("rewardText", (form.rewardText || "").trim());
|
||||
if (form.date) data.append("date", form.date);
|
||||
|
||||
const files = getFilesForUpload();
|
||||
@@ -348,7 +351,13 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.reward}
|
||||
onChange={e => set("reward", e.target.checked)}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
set("reward", checked);
|
||||
if (checked && !(form.rewardText || "").trim()) {
|
||||
set("rewardText", t('post.rewardDefault'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="f-reward-checkmark"/>
|
||||
@@ -359,6 +368,21 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form.reward && (
|
||||
<div className="f-m-group">
|
||||
<label className="f-m-label">{t('post.rewardLabel')}</label>
|
||||
<ClearableField onClear={() => set("rewardText", "")} visible={!!form.rewardText}>
|
||||
<input
|
||||
className="f-m-field"
|
||||
type="text"
|
||||
placeholder={t('post.rewardDefault')}
|
||||
value={form.rewardText}
|
||||
onChange={e => set("rewardText", e.target.value)}
|
||||
/>
|
||||
</ClearableField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Чекбокс согласия с правилами */}
|
||||
<div className="f-m-group">
|
||||
<label className="f-rules-checkbox">
|
||||
|
||||
570
src/pages/MainFeed.tsx
Normal file
570
src/pages/MainFeed.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import "../styles/index.css";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import BottomNav from "../components/nav/BottomNav";
|
||||
import RightSidebar from "../components/RightSidebar";
|
||||
import CreateUpdatePostLegacy from "./CreateUpdatePost";
|
||||
import ImageViewer from "../components/ImageViewer";
|
||||
import CityFilterLegacy from "../components/CityFilter";
|
||||
import { ProfileModal } from "../components/profile";
|
||||
import AuthModal from "../components/AuthModal";
|
||||
import RulesModal from "./RulesModal";
|
||||
import FeedTopBar from "../components/feed/FeedTopBar";
|
||||
import FeedSearchBar from "../components/feed/FeedSearchBar";
|
||||
|
||||
import FeedContent from "../components/feed/FeedContent";
|
||||
import PullToRefresh from "../components/feed/PullToRefresh";
|
||||
import MapView from "../components/MapView";
|
||||
import PostDetailModal from "../components/PostDetailModal";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { useCities } from "../hooks/useCities";
|
||||
import { useFeedPosts } from "../hooks/useFeedPosts";
|
||||
import { useMyPosts } from "../hooks/useMyPosts";
|
||||
import { useCityFeed } from "../hooks/useCityFeed";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
|
||||
import { useModals } from "../hooks/useModals";
|
||||
import { useFeedActions } from "../hooks/useFeedActions";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProfile } from "../hooks/useProfile";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные»
|
||||
// сигнатуры). Приводим к типовому виду вручную — никакой логики не меняет.
|
||||
const CityFilter = CityFilterLegacy as unknown as ComponentType<any>;
|
||||
const CreateUpdatePost = CreateUpdatePostLegacy as unknown as ComponentType<any>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyAds = any[];
|
||||
|
||||
/**
|
||||
* MainFeed — корневой компонент ленты.
|
||||
*
|
||||
* Эффект «стеклянной шапки с затуханием ленты»:
|
||||
* - `.f-feed` (скролл-контейнер) получает mask-image (linear-gradient),
|
||||
* из-за чего верхние карточки, доезжающие под шапку, плавно растворяются;
|
||||
* - высота зоны затухания управляется CSS-переменной --feed-fade-height,
|
||||
* которую мы здесь анимируем через @property (см. layout.css);
|
||||
* - значение берётся из РЕАЛЬНОЙ высоты FeedTopBar (ResizeObserver),
|
||||
* что делает маску и шапку всегда согласованными, в т.ч. при safe-area;
|
||||
* - FeedTopBar закреплён наверху экрана и всегда виден (без скрытия по скроллу).
|
||||
*/
|
||||
export default function MainFeed() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, token, isAuthenticated, updateUser } = useAuthContext() as {
|
||||
user: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
login?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
} | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
updateUser: (data: unknown) => void;
|
||||
};
|
||||
const { fetchProfile } = useProfile();
|
||||
const { cities } = useCities() as { cities: string[]; loading: boolean; error: string };
|
||||
const { loadFavorites, loadFavoritePosts, error: favoritesError } = useFavorites();
|
||||
const { t } = useTranslation();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const modals = useModals() as any;
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [activeNav, setActiveNav] = useState("home");
|
||||
const [typeFilter, setTypeFilter] = useState<"loss" | "found" | null>(null);
|
||||
const [selectedCities, setSelectedCities] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [liked, setLiked] = useState<Set<number>>(new Set());
|
||||
const [likeCountAdj, setLikeCountAdj] = useState<Record<string, number>>({});
|
||||
const [favoritePosts, setFavoritePosts] = useState<AnyAds>([]);
|
||||
const profileFetchedFor = useRef<number | null>(null);
|
||||
const [favoritesLoading, setFavoritesLoading] = useState(false);
|
||||
|
||||
const feedRef = useRef<HTMLDivElement>(null);
|
||||
// Состояние ленты перед переходом на карту — чтобы вернуть юзера на то же место
|
||||
const feedStateRef = useRef({ scrollTop: 0, url: "/feed", activeNav: "home" });
|
||||
const restoreScrollRef = useRef(false);
|
||||
|
||||
const isSearchView = new URLSearchParams(location.search).get("view") === "search";
|
||||
const isMapView = new URLSearchParams(location.search).get("view") === "map";
|
||||
// параметры фокуса карты на конкретном объявлении (?view=map&post=ID&lat=..&lng=..)
|
||||
const mapFocusParams = new URLSearchParams(location.search);
|
||||
const focusPostId = isMapView ? mapFocusParams.get("post") : null;
|
||||
const focusLat = parseFloat(mapFocusParams.get("lat") ?? "");
|
||||
const focusLng = parseFloat(mapFocusParams.get("lng") ?? "");
|
||||
const hasActiveFilters = searchQuery.trim().length > 0 || selectedCities.length > 0;
|
||||
const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0;
|
||||
const searchPrompt = isSearchView && !hasSearchParams;
|
||||
|
||||
const feed = useFeedPosts();
|
||||
const myPosts = useMyPosts(user?.id);
|
||||
const cityFeed = useCityFeed(selectedCities);
|
||||
|
||||
const profileData = useMemo(() => {
|
||||
if (!user) return {} as Record<string, unknown>;
|
||||
const nameParts = (user.name || "").trim().split(/\s+/);
|
||||
const firstName = user.firstName || nameParts[0] || "";
|
||||
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
|
||||
return {
|
||||
login: user.login || "",
|
||||
firstName,
|
||||
lastName,
|
||||
email: user.email || "",
|
||||
phone: user.phone || "",
|
||||
avatar: getImageUrl(user.avatar),
|
||||
id: user.id,
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const refreshCurrentList = useCallback(() => {
|
||||
if (activeNav === "myads") {
|
||||
return Promise.resolve(myPosts.loadMyPosts());
|
||||
}
|
||||
if (activeNav === "favorites") {
|
||||
setFavoritesLoading(true);
|
||||
return loadFavoritePosts().then((posts: AnyAds) => {
|
||||
setFavoritePosts(posts);
|
||||
setFavoritesLoading(false);
|
||||
});
|
||||
}
|
||||
if (selectedCities.length > 0) {
|
||||
return cityFeed.searchCities(selectedCities);
|
||||
}
|
||||
return feed.loadFeed();
|
||||
}, [activeNav, myPosts, selectedCities, cityFeed, feed, loadFavoritePosts]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actions = useFeedActions({
|
||||
refresh: refreshCurrentList,
|
||||
loadMyPosts: myPosts.loadMyPosts,
|
||||
onAuthRequired: () => modals.open("auth"),
|
||||
isAuthenticated,
|
||||
t,
|
||||
setLiked,
|
||||
setLikeCountAdj,
|
||||
} as any);
|
||||
|
||||
// ── «Стеклянная шапка»: закреплена наверху, высоту зоны затухания маски
|
||||
// ленты подгоняем под РЕАЛЬНУЮ высоту шапки.
|
||||
// Это гарантирует согласованность: где заканчивается стекло — там лента
|
||||
// уже полностью непрозрачна (нет резкого разрыва).
|
||||
const topBarRef = useRef<HTMLDivElement>(null);
|
||||
const [measuredFade, setMeasuredFade] = useState(48);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMapView) return;
|
||||
const el = topBarRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
// Реальная высота бара (равна 0, если бар скрыт display:none — десктоп/поиск)
|
||||
setMeasuredFade(Math.round(el.getBoundingClientRect().height));
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [isMapView, location.search]);
|
||||
|
||||
const fadeHeight = measuredFade;
|
||||
|
||||
// Загружаем лайки пользователя при авторизации
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && token) {
|
||||
loadFavorites().then((favoriteIds: number[]) => {
|
||||
const s = new Set(favoriteIds);
|
||||
actions.syncLiked(s);
|
||||
setLiked(s);
|
||||
});
|
||||
} else {
|
||||
actions.syncLiked(new Set());
|
||||
setLiked(new Set());
|
||||
}
|
||||
setLikeCountAdj({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, token, loadFavorites]);
|
||||
|
||||
// Открываем модалку аутентификации если пришли с лендинга
|
||||
useEffect(() => {
|
||||
if (location.state?.openAuth) {
|
||||
modals.open("auth");
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// При загрузке приложения подтягиваем свежие данные профиля (аватар и т.д.)
|
||||
// с бэка, чтобы нижняя навигация и профиль отражали изменения с других
|
||||
// устройств, а не устаревший кэш контекста. Дергаем один раз на пользователя.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.id && profileFetchedFor.current !== user.id) {
|
||||
profileFetchedFor.current = user.id;
|
||||
fetchProfile(user.id).then((result: { success: boolean; data?: unknown }) => {
|
||||
if (result.success) updateUser(result.data);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, user?.id]);
|
||||
|
||||
// При открытии профиля подтягиваем свежие данные (аватар/профиль) с бэка,
|
||||
// чтобы изменения с других устройств отражались, а не брались из кэша контекста
|
||||
useEffect(() => {
|
||||
if (modals.isOpen("profile") && isAuthenticated && user?.id) {
|
||||
fetchProfile(user.id).then((result: { success: boolean; data?: unknown }) => {
|
||||
if (result.success) updateUser(result.data);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modals.isOpen("profile"), isAuthenticated, user?.id]);
|
||||
|
||||
// Загрузка основной ленты (не в режиме поиска)
|
||||
useEffect(() => {
|
||||
if (!isSearchView && selectedCities.length === 0 && !searchQuery.trim()) {
|
||||
feed.loadFeed(0, "", false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSearchView]);
|
||||
|
||||
// Дебаунс поиска: только когда в search view и есть параметры
|
||||
useEffect(() => {
|
||||
if (!isSearchView) return;
|
||||
if (!hasSearchParams) return;
|
||||
const timer = setTimeout(() => {
|
||||
feed.loadFeed(0, searchQuery, false);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery, selectedCities, isSearchView]);
|
||||
|
||||
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
|
||||
useEffect(() => {
|
||||
if (activeNav === "myads" && isAuthenticated && user?.id) {
|
||||
myPosts.loadMyPosts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeNav, isAuthenticated, user?.id]);
|
||||
|
||||
// Загрузка избранных постов при переходе во вкладку «Избранное»
|
||||
useEffect(() => {
|
||||
if (activeNav !== "favorites") return;
|
||||
if (!isAuthenticated || !token) return;
|
||||
let cancelled = false;
|
||||
setFavoritesLoading(true);
|
||||
loadFavoritePosts().then((posts: AnyAds) => {
|
||||
if (cancelled) return;
|
||||
setFavoritePosts(posts);
|
||||
setFavoritesLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [activeNav, isAuthenticated, token, loadFavoritePosts]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
() => feed.loadFeed(feed.page + 1, searchQuery, true),
|
||||
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
|
||||
);
|
||||
|
||||
const handleImageClick = useCallback((images: string[], index: number, ad: Record<string, unknown>) => {
|
||||
if (images?.length) modals.open("viewer", { images, index, ad });
|
||||
}, [modals]);
|
||||
|
||||
const handleCreatePost = useCallback(() => {
|
||||
if (!isAuthenticated) modals.open("auth");
|
||||
else modals.open("create");
|
||||
}, [isAuthenticated, modals]);
|
||||
|
||||
const handleEdit = useCallback((ad: Record<string, unknown>) => {
|
||||
if (!isAuthenticated) modals.open("auth");
|
||||
else modals.open("edit", ad);
|
||||
}, [isAuthenticated, modals]);
|
||||
|
||||
const handleHomeClick = useCallback(() => {
|
||||
setActiveNav("home");
|
||||
setSearchQuery("");
|
||||
setSelectedCities([]);
|
||||
if (isSearchView || isMapView) navigate("/feed");
|
||||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [isSearchView, isMapView, navigate]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
setActiveNav("home");
|
||||
setSearchQuery("");
|
||||
setSelectedCities([]);
|
||||
if (!isSearchView) navigate("/feed?view=search");
|
||||
else if (isMapView) navigate("/feed?view=search");
|
||||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [isSearchView, isMapView, navigate]);
|
||||
|
||||
// Карта с объявлениями
|
||||
const saveFeedState = useCallback(() => {
|
||||
feedStateRef.current = {
|
||||
scrollTop: feedRef.current?.scrollTop ?? 0,
|
||||
url: `${location.pathname}${location.search}`,
|
||||
activeNav,
|
||||
};
|
||||
restoreScrollRef.current = true;
|
||||
}, [location.pathname, location.search, activeNav]);
|
||||
|
||||
const handleMapClick = useCallback(() => {
|
||||
saveFeedState();
|
||||
setActiveNav("map");
|
||||
if (!isMapView) navigate("/feed?view=map");
|
||||
}, [isMapView, navigate, saveFeedState]);
|
||||
|
||||
const handleMapPostClick = useCallback((postId: number) => {
|
||||
modals.open("postDetail", { postId });
|
||||
}, [modals]);
|
||||
|
||||
// Возврат с карты на ленту — на то же место, где юзер был до открытия карты
|
||||
const handleMapBack = useCallback(() => {
|
||||
const { url, activeNav: prevNav } = feedStateRef.current;
|
||||
setActiveNav(prevNav || "home");
|
||||
navigate(url || "/feed", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
// Восстанавливаем прокрутку ленты после возврата с карты
|
||||
useEffect(() => {
|
||||
if (isMapView || !restoreScrollRef.current) return;
|
||||
const el = feedRef.current;
|
||||
if (el) el.scrollTop = feedStateRef.current.scrollTop;
|
||||
restoreScrollRef.current = false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMapView, feed.ads, cityFeed.cityAds, feed.loading, cityFeed.cityLoading]);
|
||||
|
||||
// Клик по локации на карточке — карта с фокусом на этом объявлении
|
||||
const handleLocationClick = useCallback((ad: Record<string, unknown>) => {
|
||||
saveFeedState();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params = new URLSearchParams({ view: "map", post: ad.id as any });
|
||||
if (ad.latitude != null && ad.longitude != null) {
|
||||
params.set("lat", String(ad.latitude));
|
||||
params.set("lng", String(ad.longitude));
|
||||
}
|
||||
setActiveNav("map");
|
||||
navigate(`/feed?${params.toString()}`);
|
||||
}, [navigate, saveFeedState]);
|
||||
|
||||
const handleProfileClick = useCallback(() => {
|
||||
if (!isAuthenticated) modals.open("auth");
|
||||
else modals.open("profile");
|
||||
}, [isAuthenticated, modals]);
|
||||
|
||||
const filteredAds = useMemo((): AnyAds => {
|
||||
let result: AnyAds;
|
||||
if (activeNav === "myads") {
|
||||
result = myPosts.myAds;
|
||||
} else if (activeNav === "favorites") {
|
||||
result = favoritePosts;
|
||||
} else if (selectedCities.length > 0) {
|
||||
result = cityFeed.cityAds;
|
||||
} else {
|
||||
result = feed.ads;
|
||||
}
|
||||
|
||||
if (activeNav === "items") {
|
||||
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category));
|
||||
} else if (activeNav === "pets") {
|
||||
result = result.filter(ad => ad.category === "Питомец");
|
||||
}
|
||||
|
||||
if (typeFilter) {
|
||||
result = result.filter(ad => ad.type === typeFilter);
|
||||
}
|
||||
return result;
|
||||
}, [activeNav, myPosts.myAds, favoritePosts, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
|
||||
|
||||
const listLoading = activeNav === "myads"
|
||||
? myPosts.loading
|
||||
: activeNav === "favorites"
|
||||
? favoritesLoading
|
||||
: feed.loading;
|
||||
const listError = activeNav === "myads"
|
||||
? myPosts.error
|
||||
: activeNav === "favorites"
|
||||
? favoritesError
|
||||
: feed.error;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`f-app${sidebarOpen ? " f-sb-open" : ""}${isSearchView ? " f-search-view" : ""}${isMapView ? " f-map-view" : ""}`}
|
||||
>
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
activeNav={activeNav}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNavClick={(key: string) => {
|
||||
setActiveNav(key);
|
||||
setSearchQuery("");
|
||||
setSelectedCities([]);
|
||||
if (isMapView || isSearchView) navigate("/feed");
|
||||
}}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLoginClick={() => modals.open("auth")}
|
||||
onRulesClick={() => modals.open("rules")}
|
||||
onReviewsClick={() => navigate("/reviews")}
|
||||
onCreatePost={handleCreatePost}
|
||||
onProfileClick={() => modals.open("profile")}
|
||||
profileData={profileData}
|
||||
/>
|
||||
|
||||
<div className="f-main f-main-feed">
|
||||
{isSearchView && (
|
||||
<FeedTopBar
|
||||
onMenuClick={() => setSidebarOpen(v => !v)}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
/>
|
||||
)}
|
||||
<div className="f-search-panel">
|
||||
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
|
||||
|
||||
{activeNav === "home" && (
|
||||
<div className="f-city-filter-wrap">
|
||||
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="f-wrap">
|
||||
{isMapView ? (
|
||||
<MapView
|
||||
focusPostId={focusPostId}
|
||||
focusLat={focusLat}
|
||||
focusLng={focusLng}
|
||||
onPostClick={handleMapPostClick}
|
||||
onBack={handleMapBack}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Скролл-контейнер ленты. Маска затухания висит на .f-feed-scroll:
|
||||
верхние карточки, доезжающие до стеклянной шапки, плавно
|
||||
растворяются; высота зоны --feed-fade-height согласована
|
||||
с фактической высотой FeedTopBar (ResizeObserver). */}
|
||||
<div className="f-feed">
|
||||
<div
|
||||
className="f-feed-scroll"
|
||||
ref={feedRef}
|
||||
style={{ ["--feed-fade-height" as any]: `${fadeHeight}px` }}
|
||||
>
|
||||
<PullToRefresh
|
||||
containerRef={feedRef}
|
||||
onRefresh={refreshCurrentList}
|
||||
disabled={isMapView || isSearchView || (activeNav !== "home" && activeNav !== "myads" && activeNav !== "favorites")}
|
||||
/>
|
||||
<FeedContent
|
||||
loading={listLoading}
|
||||
cityLoading={cityFeed.cityLoading}
|
||||
citySearchActive={selectedCities.length > 0}
|
||||
error={listError}
|
||||
filteredAds={filteredAds}
|
||||
activeNav={activeNav}
|
||||
searchPrompt={searchPrompt}
|
||||
liked={liked}
|
||||
likeCountAdj={likeCountAdj}
|
||||
onLike={actions.toggleLike}
|
||||
onEdit={handleEdit}
|
||||
onDelete={actions.handleDelete}
|
||||
onMarkFound={actions.handleMarkFound}
|
||||
onImageClick={handleImageClick}
|
||||
onLocationClick={handleLocationClick}
|
||||
onAuthRequired={() => modals.open("auth")}
|
||||
t={t}
|
||||
/>
|
||||
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
|
||||
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||
)}
|
||||
{feed.loadingMore && (
|
||||
<div className="f-center" style={{ padding: "1rem" }}>
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isSearchView && (
|
||||
<FeedTopBar
|
||||
onMenuClick={() => setSidebarOpen(v => !v)}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
topBarRef={topBarRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<RightSidebar
|
||||
ads={filteredAds}
|
||||
profileData={profileData}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onProfileClick={() => modals.open("profile")}
|
||||
onLoginClick={() => modals.open("auth")}
|
||||
onNavClick={(key: string) => {
|
||||
setActiveNav(key);
|
||||
setSearchQuery("");
|
||||
setSelectedCities([]);
|
||||
if (isMapView || isSearchView) navigate("/feed");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modals.isOpen("create") && isAuthenticated && (
|
||||
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
|
||||
)}
|
||||
|
||||
{modals.isOpen("edit") && isAuthenticated && (
|
||||
<CreateUpdatePost
|
||||
initialData={modals.modal.payload}
|
||||
onClose={modals.close}
|
||||
onSuccess={actions.handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modals.isOpen("profile") && isAuthenticated && (
|
||||
<ProfileModal onClose={modals.close} initialData={profileData} />
|
||||
)}
|
||||
|
||||
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
|
||||
|
||||
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
|
||||
|
||||
{modals.isOpen("viewer") && (
|
||||
<ImageViewer
|
||||
images={modals.modal.payload.images}
|
||||
initialIndex={modals.modal.payload.index}
|
||||
onClose={modals.close}
|
||||
postData={modals.modal.payload.ad}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modals.isOpen("postDetail") && (
|
||||
<PostDetailModal
|
||||
postId={modals.modal.payload.postId}
|
||||
onClose={modals.close}
|
||||
onImageClick={(images: string[], index: number, ad: Record<string, unknown>) => {
|
||||
modals.open("viewer", { images, index, ad });
|
||||
}}
|
||||
onLocationClick={(ad: Record<string, unknown>) => {
|
||||
modals.close();
|
||||
handleLocationClick(ad);
|
||||
}}
|
||||
onAuthRequired={() => modals.open("auth")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BottomNav
|
||||
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
|
||||
hasFilters={hasActiveFilters}
|
||||
profileData={profileData}
|
||||
onHome={handleHomeClick}
|
||||
onMap={handleMapClick}
|
||||
onCreatePost={handleCreatePost}
|
||||
onSearch={handleSearchClick}
|
||||
onProfile={handleProfileClick}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,15 +8,18 @@ import {useContactSeller} from "../hooks/useContactSeller";
|
||||
import {useAuthContext} from "../context/AuthContext";
|
||||
import {getImageUrl} from "../config/authConstants";
|
||||
import DateCalendarPopup from "../components/DateCalendarPopup";
|
||||
import RewardPopup from "../components/feed/RewardPopup";
|
||||
import Toast from "../components/Toast";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onLocationClick, onAuthRequired, compact }) {
|
||||
const { t } = useTranslation();
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const [showReward, setShowReward] = useState(false);
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
const [descTruncated, setDescTruncated] = useState(false);
|
||||
const dateAnchorRef = useRef(null);
|
||||
const rewardAnchorRef = useRef(null);
|
||||
const descRef = useRef(null);
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { contactSeller, error: contactError, clearError } = useContactSeller(ad);
|
||||
@@ -163,9 +166,26 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on
|
||||
</div>
|
||||
)}
|
||||
{ad.reward && (
|
||||
<span className="f-card-reward-text">
|
||||
{t("postCard.reward")}
|
||||
</span>
|
||||
<>
|
||||
<button
|
||||
ref={rewardAnchorRef}
|
||||
type="button"
|
||||
className="f-card-reward-text f-card-reward-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setShowReward(s => !s);
|
||||
}}
|
||||
>
|
||||
<span className="f-reward-btn-label">{t("postCard.reward")}</span>
|
||||
</button>
|
||||
{showReward && (
|
||||
<RewardPopup
|
||||
text={ad.rewardText || t("postCard.reward")}
|
||||
anchorRef={rewardAnchorRef}
|
||||
onClose={() => setShowReward(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
}
|
||||
|
||||
.nk-logo svg {
|
||||
height: 80px;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
@@ -455,7 +455,7 @@
|
||||
}
|
||||
|
||||
.nk-logo svg {
|
||||
height: 64px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.nk-hero-text {
|
||||
|
||||
@@ -110,8 +110,12 @@
|
||||
|
||||
.f-card-reward-text {
|
||||
display: inline-block;
|
||||
font-size: .74rem; font-weight: 600; letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Золотой переливающийся текст внутри кнопки-пилюли */
|
||||
.f-reward-btn-label {
|
||||
font-size: .74rem; font-weight: 600; letter-spacing: .02em;
|
||||
background: linear-gradient(110deg, #e6b800 30%, #ffec99 50%, #e6b800 70%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
@@ -124,6 +128,73 @@
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Кнопка «Вознаграждение» в виде пилюли в золотой обводке — видно, что
|
||||
на неё можно нажать. Заливка лёгкая, сам перелив живёт в .f-reward-btn-label */
|
||||
button.f-card-reward-btn {
|
||||
border: 1px solid rgba(230, 184, 0, 0.5);
|
||||
border-radius: 999px;
|
||||
background: rgba(230, 184, 0, 0.08);
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
line-height: inherit;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
transition: transform .15s ease, filter .15s ease, border-color .15s ease, background .15s ease;
|
||||
}
|
||||
button.f-card-reward-btn:hover {
|
||||
transform: scale(1.08);
|
||||
border-color: rgba(230, 184, 0, 0.85);
|
||||
background: rgba(230, 184, 0, 0.16);
|
||||
filter: brightness(1.1) drop-shadow(0 0 6px rgba(230, 184, 0, 0.45));
|
||||
}
|
||||
button.f-card-reward-btn:active { transform: scale(.94); }
|
||||
button.f-card-reward-btn:focus-visible {
|
||||
outline: 2px solid rgba(230,184,0,.7);
|
||||
outline-offset: 2px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ── Всплывающее окно вознаграждения ── */
|
||||
.f-reward-popup {
|
||||
max-width: 260px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(150deg, rgba(48,39,16,.97), rgba(22,18,10,.98));
|
||||
backdrop-filter: blur(10px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
border: 1px solid rgba(230,184,0,.55);
|
||||
box-shadow:
|
||||
0 8px 30px rgba(0,0,0,.4),
|
||||
0 0 0 1px rgba(230,184,0,.12),
|
||||
0 0 26px rgba(230,184,0,.2);
|
||||
animation: f-reward-pop-in .3s cubic-bezier(.34,1.56,.64,1);
|
||||
}
|
||||
.f-reward-popup-title {
|
||||
font-size: .74rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 7px;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(110deg, #e6b800 25%, #ffec99 50%, #e6b800 75%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: f-reward-shimmer 3.2s ease-in-out infinite;
|
||||
}
|
||||
.f-reward-popup-text {
|
||||
font-size: .92rem;
|
||||
line-height: 1.5;
|
||||
color: #ffe9a8;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@keyframes f-reward-pop-in {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(.85); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ── Likes ── */
|
||||
.f-card-likes {
|
||||
padding: 2px 16px;
|
||||
|
||||
@@ -664,6 +664,58 @@
|
||||
.f-feed-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.f-feed-scroll::-webkit-scrollbar-thumb { background: var(--muted2); border-radius: 2px; }
|
||||
|
||||
/* ── Pull-to-refresh ──
|
||||
Индикатор живёт внутри .f-feed-scroll первым элементом и своим
|
||||
текущим ростом «вытягивает» ленту вниз. Чтобы он был виден на фоне
|
||||
карточек во время жеста, на скролл-контейнер вешается класс
|
||||
.f-ptr-active, снимающий mask (иначе верхняя зона «растворяется»). */
|
||||
.f-ptr {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: height 0.45s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
/* Пока палец на экране — без transition, чтобы индикатор мгновенно
|
||||
следовал за жестом и не «отставал» от толщины натяжения. */
|
||||
.f-ptr.f-ptr-dragging {
|
||||
transition: none;
|
||||
}
|
||||
/* Пружинистое возвращение к состоянию обновления: лёгкий довод/перелёт */
|
||||
.f-ptr.f-ptr-refreshing {
|
||||
transition: height 0.5s cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||
}
|
||||
.f-ptr-scale {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.03s,
|
||||
opacity 0.25s ease 0.03s;
|
||||
}
|
||||
.f-ptr-visible .f-ptr-scale {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
.f-ptr-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 3px solid rgba(var(--teal-rgb), 0.25);
|
||||
border-top-color: var(--teal);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.f-ptr-refreshing .f-ptr-spinner {
|
||||
animation: spin 0.7s linear infinite;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.f-feed-scroll.f-ptr-active {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.f-main-feed .f-topbar {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user