Regulamin i polityka prywatnosci zgodne ze stanem serwisu
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Audyt pokazal rozbieznosci w obie strony: dokumenty opisywaly funkcje, ktorych nie ma (logowanie Google/Facebook), a kod robil rzeczy, ktorych dokumenty nie ujawnialy. Kod: - zgody marketingowe domyslnie WYLACZONE (byly true dla kazdego nowego konta) + skrypt fix-marketing-consents.sql czyszczacy stare wpisy, - usuniete tlumaczenie przez nieoficjalne API Google - przy zmianie jezyka wysylalo do USA tresci stron, w tym wiadomosci i nazwiska, - osadzone spacery (YouTube, Matterport, Kuula) laduja sie dopiero po zgodzie; YouTube w trybie bez ciasteczek; mapy jak dotad, - usuniecie konta kasuje dane osobowe (AccountErasureService), a nie tylko wiersz app_users, - wygasle tokeny i kody SMS kasowane po 7 dniach (ExpiredTokenCleaner), - zapisujemy moment akceptacji regulaminu przy rejestracji. Dokumenty: opisuja realne funkcje, wszystkich odbiorcow danych (OVH, Autopay, SMTP, SMS, Esri, OpenStreetMap, YouTube/Matterport/Kuula, GUS), transfery poza EOG, konkretne okresy przechowywania, procedure zgloszen z DSA, brak profilowania i tabele pamieci przegladarki zamiast fikcyjnych cookies analitycznych. Zakres NIS2 i DORA opisany zgodnie z prawda. Uklad stron prawnych jak reszta serwisu: kontener 1760 px, karty, spis tresci z boku. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-209
@@ -19,7 +19,7 @@ import { BannerSlot } from './Banners';
|
||||
import { AdminBannersView } from './admin/banners';
|
||||
import { AdminPromotionView } from './admin/promotion';
|
||||
import { AdminSettingsView } from './admin/settings';
|
||||
import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal';
|
||||
import { TermsPage, PrivacyPolicyPage, CookieBanner, hasExternalEmbedConsent } from './legal';
|
||||
import { SiteFooter } from './Footer';
|
||||
import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages';
|
||||
|
||||
@@ -1995,7 +1995,8 @@ function getVirtualTourEmbed(url: string): VirtualTourEmbedInfo | null {
|
||||
const id = pathParts[0];
|
||||
if (id) {
|
||||
return {
|
||||
embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(id)}`,
|
||||
// Tryb bez ciasteczek: YouTube nie zaklada ciasteczek sledzacych przed odtworzeniem.
|
||||
embedUrl: `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`,
|
||||
providerLabel: 'YouTube',
|
||||
};
|
||||
}
|
||||
@@ -2007,7 +2008,7 @@ function getVirtualTourEmbed(url: string): VirtualTourEmbedInfo | null {
|
||||
|| (pathParts[0] === 'shorts' ? pathParts[1] : null);
|
||||
if (videoId) {
|
||||
return {
|
||||
embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(videoId)}`,
|
||||
embedUrl: `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`,
|
||||
providerLabel: 'YouTube',
|
||||
};
|
||||
}
|
||||
@@ -2636,11 +2637,6 @@ function App() {
|
||||
[favoriteListingIdList, listingPriceAlertIdList],
|
||||
);
|
||||
const monitoredListingIdsSignature = useMemo(() => monitoredListingIdList.join(','), [monitoredListingIdList]);
|
||||
const translatedOriginalTextRef = useRef<Map<Text, string>>(new Map());
|
||||
const translatedOriginalAttrRef = useRef<Map<Element, Record<string, string>>>(new Map());
|
||||
const translationObserverRef = useRef<MutationObserver | null>(null);
|
||||
const translationTimerRef = useRef<number | null>(null);
|
||||
const translationInFlightRef = useRef(false);
|
||||
const filters = useFilterState();
|
||||
const notificationsCenter = useNotifications();
|
||||
const [savedSearchesState, setSavedSearchesState] = useState<SavedSearchRecord[]>([]);
|
||||
@@ -2805,198 +2801,6 @@ function App() {
|
||||
document.documentElement.lang = htmlLang;
|
||||
}, [user?.preferredLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
const targetLanguage = user?.preferredLanguage ?? 'PL';
|
||||
const textNodeSources = translatedOriginalTextRef.current;
|
||||
const attrSources = translatedOriginalAttrRef.current;
|
||||
|
||||
const restorePolish = () => {
|
||||
textNodeSources.forEach((source, node) => {
|
||||
if (node.isConnected) {
|
||||
node.textContent = source;
|
||||
}
|
||||
});
|
||||
attrSources.forEach((sourceAttrs, element) => {
|
||||
if (!element.isConnected) {
|
||||
return;
|
||||
}
|
||||
Object.entries(sourceAttrs).forEach(([attr, value]) => {
|
||||
element.setAttribute(attr, value);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const shouldTranslateSource = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// Skip pure numbers and separators.
|
||||
if (!/[a-zA-Z\u00C0-\u024F\u0400-\u04FF\u0104\u0105\u0106\u0107\u0118\u0119\u0141\u0142\u0143\u0144\u00D3\u00F3\u015A\u015B\u0179\u017A\u017B\u017C]/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const collectTargets = () => {
|
||||
const textTargets: Array<{ node: Text; source: string }> = [];
|
||||
const attrTargets: Array<{ element: Element; attr: string; source: string }> = [];
|
||||
|
||||
const root = document.querySelector('.site-shell') ?? document.body;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const textNode = current as Text;
|
||||
const parent = textNode.parentElement;
|
||||
if (parent && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA'].includes(parent.tagName)) {
|
||||
const source = textNodeSources.get(textNode) ?? textNode.textContent ?? '';
|
||||
if (!textNodeSources.has(textNode)) {
|
||||
textNodeSources.set(textNode, source);
|
||||
}
|
||||
if (shouldTranslateSource(source)) {
|
||||
textTargets.push({ node: textNode, source });
|
||||
}
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
|
||||
root.querySelectorAll('[placeholder], [title], [aria-label]').forEach((element) => {
|
||||
const tracked = attrSources.get(element) ?? {};
|
||||
['placeholder', 'title', 'aria-label'].forEach((attr) => {
|
||||
const raw = element.getAttribute(attr);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const source = tracked[attr] ?? raw;
|
||||
if (!tracked[attr]) {
|
||||
tracked[attr] = source;
|
||||
}
|
||||
if (shouldTranslateSource(source)) {
|
||||
attrTargets.push({ element, attr, source });
|
||||
}
|
||||
});
|
||||
if (Object.keys(tracked).length > 0) {
|
||||
attrSources.set(element, tracked);
|
||||
}
|
||||
});
|
||||
|
||||
return { textTargets, attrTargets };
|
||||
};
|
||||
|
||||
const translateVisibleContent = async () => {
|
||||
if (targetLanguage === 'PL') {
|
||||
restorePolish();
|
||||
return;
|
||||
}
|
||||
if (translationInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { textTargets, attrTargets } = collectTargets();
|
||||
const sources = Array.from(new Set([
|
||||
...textTargets.map((item) => item.source),
|
||||
...attrTargets.map((item) => item.source),
|
||||
]));
|
||||
|
||||
if (sources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
translationInFlightRef.current = true;
|
||||
try {
|
||||
const mergedTranslations: Record<string, string> = {};
|
||||
const missingForApi: string[] = [];
|
||||
|
||||
sources.forEach((source) => {
|
||||
const manual = getManualTranslation(targetLanguage, source);
|
||||
if (manual != null) {
|
||||
mergedTranslations[source] = manual;
|
||||
} else if (source.length <= 380) {
|
||||
missingForApi.push(source);
|
||||
} else {
|
||||
mergedTranslations[source] = source;
|
||||
}
|
||||
});
|
||||
|
||||
if (missingForApi.length > 0) {
|
||||
const fallbackChunks: string[][] = [];
|
||||
for (let index = 0; index < missingForApi.length; index += 150) {
|
||||
fallbackChunks.push(missingForApi.slice(index, index + 150));
|
||||
}
|
||||
|
||||
for (const chunk of fallbackChunks) {
|
||||
const response = await apiFetch<{ language: PreferredLanguage; translations: Record<string, string> }>('/i18n/translate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ language: targetLanguage, texts: chunk }),
|
||||
});
|
||||
Object.assign(mergedTranslations, response.translations);
|
||||
}
|
||||
}
|
||||
|
||||
textTargets.forEach(({ node, source }) => {
|
||||
if (!node.isConnected) {
|
||||
return;
|
||||
}
|
||||
const translated = mergedTranslations[source];
|
||||
if (translated && node.textContent !== translated) {
|
||||
node.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
attrTargets.forEach(({ element, attr, source }) => {
|
||||
if (!element.isConnected) {
|
||||
return;
|
||||
}
|
||||
const translated = mergedTranslations[source];
|
||||
if (translated) {
|
||||
element.setAttribute(attr, translated);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// If translation fails, leave original text untouched.
|
||||
} finally {
|
||||
translationInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const queueTranslate = () => {
|
||||
if (translationTimerRef.current != null) {
|
||||
window.clearTimeout(translationTimerRef.current);
|
||||
}
|
||||
translationTimerRef.current = window.setTimeout(() => {
|
||||
void translateVisibleContent();
|
||||
}, 80);
|
||||
};
|
||||
|
||||
translationObserverRef.current?.disconnect();
|
||||
if (targetLanguage === 'PL') {
|
||||
restorePolish();
|
||||
return;
|
||||
}
|
||||
|
||||
queueTranslate();
|
||||
const observer = new MutationObserver(() => {
|
||||
queueTranslate();
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['placeholder', 'title', 'aria-label'],
|
||||
});
|
||||
translationObserverRef.current = observer;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
translationObserverRef.current = null;
|
||||
if (translationTimerRef.current != null) {
|
||||
window.clearTimeout(translationTimerRef.current);
|
||||
translationTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [user?.preferredLanguage, location.pathname]);
|
||||
|
||||
// Miasto i typ oferty trafiaja do query stringa - wynik na mapie da sie udostepnic linkiem.
|
||||
const openMap = (name: string, source: 'buy' | 'rent' = 'buy') => {
|
||||
navigate(mapPath(name, source === 'rent' ? 'RENT' : 'SALE'));
|
||||
@@ -26777,6 +26581,9 @@ function ListingDetailPage({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
// Osadzony spacer z obcego serwisu ladujemy dopiero za zgoda ogladajacego (baner cookie
|
||||
// "akceptuje wszystkie" ja obejmuje) - ramka przekazuje dostawcy adres IP.
|
||||
const [tourEmbedAllowed, setTourEmbedAllowed] = useState(() => hasExternalEmbedConsent());
|
||||
const { formatPrice, formatPricePerArea, formatArea, areaSuffix } = useDisplay();
|
||||
const [listing, setListing] = useState<ApiListingDetail | null>(null);
|
||||
// Historia ogladania jest per konto, wiec zapisujemy ja dopiero, gdy wiadomo, kto oglada -
|
||||
@@ -27390,15 +27197,28 @@ function ListingDetailPage({
|
||||
<div className="listing-detail-card">
|
||||
<h2>Wirtualny spacer 3D</h2>
|
||||
{virtualTourEmbed ? (
|
||||
<div className="listing-detail-tour-embed">
|
||||
<iframe
|
||||
src={virtualTourEmbed.embedUrl}
|
||||
title={`Wirtualny spacer (${virtualTourEmbed.providerLabel})`}
|
||||
loading="lazy"
|
||||
allow="xr-spatial-tracking; vr; gyroscope; accelerometer; fullscreen"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
/* Ramka z obcego serwisu przekazuje mu adres IP ogladajacego, wiec bez zgody
|
||||
pokazujemy zastepnik i mowimy wprost, kto te dane dostanie. */
|
||||
tourEmbedAllowed ? (
|
||||
<div className="listing-detail-tour-embed">
|
||||
<iframe
|
||||
src={virtualTourEmbed.embedUrl}
|
||||
title={`Wirtualny spacer (${virtualTourEmbed.providerLabel})`}
|
||||
loading="lazy"
|
||||
allow="xr-spatial-tracking; vr; gyroscope; accelerometer; fullscreen"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="listing-detail-tour-consent">
|
||||
<span><Icon name="tour" /></span>
|
||||
<p>
|
||||
Spacer jest osadzony z serwisu <strong>{virtualTourEmbed.providerLabel}</strong>.
|
||||
Po włączeniu Twój adres IP trafi do tego dostawcy.
|
||||
</p>
|
||||
<button type="button" onClick={() => setTourEmbedAllowed(true)}>Pokaż wirtualny spacer</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="listing-detail-description">Ten typ linku nie obsługuje osadzenia w karcie. Otwórz spacer w nowej karcie.</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user