diff --git a/CLAUDE.md b/CLAUDE.md index 4788c69c..a0f9d62f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ ## Font passport + + - Allowed sizes: **8, 9, 9.14, 9.5, 10, 10.5, 11, 11.5, 11.52, 12, 12.5, 13, 13.5, 13.6, 13.76, 14, 14.344, 14.4, 15, 16, 17, 17.6, 18, 18.4, 20, 21, 22, 22.4, 23, 24, 25, 26, 28, 30, 32, 34, 34.56, 35, 36, 38, 40, 45, 48, 64, 76px**. Hard floor: **8px**. - Contrast: **4.5:1** minimum, or **3:1** for text at 18px and above. - If text does not fit, fix the layout. This passport outranks template and skill defaults. diff --git a/src/pages/uxcat/ongoing.tsx b/src/pages/uxcat/ongoing.tsx index 15141d8a..fd0e9acd 100644 --- a/src/pages/uxcat/ongoing.tsx +++ b/src/pages/uxcat/ongoing.tsx @@ -1,24 +1,20 @@ -import { useRouter } from 'next/router'; -import React, { FC, useEffect, useState } from 'react'; - -import { TRouter } from '@uxcore/local-types/global'; -import { UXCatDataTypes } from '@uxcore/local-types/uxcat-types/types'; - import { UXCatConfigs } from '@uxcore/api/uxcat/configs'; import { getFinalTest } from '@uxcore/api/uxcat/final-test'; import { getUXCatStartTest } from '@uxcore/api/uxcat/start-test'; import { getUserInfo } from '@uxcore/api/uxcat/users-me'; import { getUXCatData } from '@uxcore/api/uxcat/uxcat'; - -import { achievementSlugs } from '@uxcore/data/uxcat/ongoingTest/realTimeAchievements'; - import SeoGenerator from '@uxcore/components/SeoGenerator'; import Spinner from '@uxcore/components/Spinner'; - -import styles from '@uxcore/layouts/OngoingLayout/OngoingLayout.module.scss'; +import { achievementSlugs } from '@uxcore/data/uxcat/ongoingTest/realTimeAchievements'; +import { TRouter } from '@uxcore/local-types/global'; +import { UXCatDataTypes } from '@uxcore/local-types/uxcat-types/types'; +import { useRouter } from 'next/router'; +import React, { FC, useEffect, useState } from 'react'; import OngoingLayout from 'src/uxcore/layouts/OngoingLayout'; +import styles from '@uxcore/layouts/OngoingLayout/OngoingLayout.module.scss'; + type OngoingProps = { configs: { testExpirationTime: number; @@ -84,14 +80,20 @@ const Ongoing: FC = ({ configs, uxcatData }) => { const userInfo = await getUserInfo(); setUserInfo(userInfo); const ongoingTest = userInfo?.ongoingTest; - if (isFinalTest) { + // When resuming, the server's isFinal flag is the source of truth: + // the localStorage flag can be stale (or absent in another + // browser), and a wrong test length breaks question numbering and + // ends a 30-question final at question 10. + const treatAsFinal = ongoingTest + ? !!ongoingTest.isFinal + : isFinalTest; + if (treatAsFinal) { const testResult = !ongoingTest ? await getFinalTest(accessToken) : ongoingTest; setTest(testResult); setTestLength(30); - } - if (!isFinalTest) { + } else { const testResult = !ongoingTest ? await getUXCatStartTest(accessToken) : ongoingTest; diff --git a/src/pages/uxcore/[slug].tsx b/src/pages/uxcore/[slug].tsx index d7ae3f4a..7b470c92 100644 --- a/src/pages/uxcore/[slug].tsx +++ b/src/pages/uxcore/[slug].tsx @@ -10,6 +10,7 @@ import { getAdjacentBiasTitles, mergeBiasesLocalization, } from '@uxcore/lib/helpers'; +import { isOffsecEnabled } from '@uxcore/lib/offsec'; import { getUXCoreTextPaths } from '@uxcore/lib/paths'; import type { QuestionType, @@ -41,8 +42,8 @@ const UXCoreIds: FC = ({ uxcgLocalizedData, }) => { const [activeBiasNumber, setActiveBiasNumber] = useState(null); - const [isModalClosed, setIsModalClosed] = useState(true); - const [, { isProductView }] = useUXCoreGlobals(); + const [, setIsModalClosed] = useState(true); + const [, { isProductView, isOffsecView }] = useUXCoreGlobals(); const router = useRouter(); const { locale } = router as TRouter; @@ -53,10 +54,19 @@ const UXCoreIds: FC = ({ const strapiQuestions = uxcgLocalizedData?.[locale] ?? []; + // mentionedQuestionsIds comes from Strapi as a JSON string; a null/garbage + // value must degrade to "no mentions", not crash the whole bias page. + const mentionedQuestionIds = useMemo(() => { + try { + const parsed = JSON.parse(currentModalData?.mentionedQuestionsIds); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, [currentModalData?.mentionedQuestionsIds]); + const mentionedQuestions = strapiQuestions.filter(({ attributes }) => - JSON.parse(currentModalData.mentionedQuestionsIds).includes( - attributes?.number, - ), + mentionedQuestionIds.includes(attributes?.number), ); const openSelectedBias = (number, slug) => { @@ -100,7 +110,7 @@ const UXCoreIds: FC = ({ ogImage: { data: { attributes: { - url: currentModalData[`OGTags${lang}`]?.OGTags?.ogImage?.data + url: currentModalData[`OGTags${lang}`]?.ogImage?.data ?.attributes?.url, staticUrl: '/assets/ogImages/UXCore.png', }, @@ -111,21 +121,24 @@ const UXCoreIds: FC = ({ } }, [currentActiveBias.number, locale]); + // Keep the modal's bias in sync with the route. Depending on the prop (not + // mount-only) makes browser Back/Forward land on the right bias instead of + // leaving the modal stuck on the previously viewed one. useEffect(() => { setActiveBiasNumber(Number(currentActiveBias.number)); - if (!isModalClosed) { - setActiveBiasNumber(null); - } - }, []); + }, [currentActiveBias.number]); useEffect(() => { - const newHash = isProductView ? '' : 'hr'; + const offsecActive = isOffsecEnabled && isOffsecView; + const newHash = offsecActive ? 'offsec' : isProductView ? '' : 'hr'; const currentPath = router.asPath.split('#')[0]; const newUrl = `${currentPath}${newHash ? '#' + newHash : ''}`; if (router.asPath !== newUrl) { - router.push(newUrl, undefined, { shallow: false }); + // Hash-only change: shallow, no scroll — switching the use case inside + // the modal must not refetch the page or jump to the top. + router.push(newUrl, undefined, { shallow: true, scroll: false }); } - }, [isProductView, router.asPath]); + }, [isProductView, isOffsecView, router.asPath]); const openPage = () => { router.push(`/uxcore`, undefined, { scroll: true }); diff --git a/src/uxcore/api/biases.ts b/src/uxcore/api/biases.ts index a51ab586..59ba65ff 100644 --- a/src/uxcore/api/biases.ts +++ b/src/uxcore/api/biases.ts @@ -1,8 +1,15 @@ let cachedBiases: any = null; +let cachedBiasesAt = 0; let cachedSlimBiases: any = null; const LOCALES = ['en', 'ru', 'hy']; const PAGE_SIZE = 100; const TOTAL_ITEMS_EXPECTED = 105; +// A complete result is reused for the TTL, then refetched so ISR +// revalidation actually picks up Strapi content edits. An incomplete or +// failed fetch is never cached (a transient empty response must not +// 404-poison every bias page until a container restart); the previous +// complete result keeps serving instead. +const CACHE_TTL_MS = 5 * 60 * 1000; const SLIM_FIELDS = [ 'number', @@ -48,7 +55,9 @@ export const getSlimBiases = async () => { }; export const getStrapiBiases = async () => { - if (cachedBiases) return cachedBiases; + if (cachedBiases && Date.now() - cachedBiasesAt < CACHE_TTL_MS) { + return cachedBiases; + } const allData = { en: [], @@ -56,24 +65,40 @@ export const getStrapiBiases = async () => { hy: [], }; - for (const locale of LOCALES) { - let page = 1; - let fetched = 0; - while (fetched < TOTAL_ITEMS_EXPECTED) { - const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&populate[OGTags][populate]=ogImage`; - const res = await fetch(url); - const json = await res.json(); + try { + for (const locale of LOCALES) { + let page = 1; + let fetched = 0; + while (fetched < TOTAL_ITEMS_EXPECTED) { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&populate[OGTags][populate]=ogImage`; + const res = await fetch(url); + const json = await res.json(); - if (!json.data || json.data.length === 0) break; + if (!json.data || json.data.length === 0) break; - allData[locale].push(...json.data); - fetched += json.data.length; + allData[locale].push(...json.data); + fetched += json.data.length; - if (json.data.length < PAGE_SIZE) break; // no more pages - page++; + if (json.data.length < PAGE_SIZE) break; // no more pages + page++; + } } + } catch (err) { + if (cachedBiases) return cachedBiases; + throw err; } - cachedBiases = allData; + // hy is a partial override set in Strapi, so completeness is judged on + // the two full locales only. + const isComplete = + allData.en.length >= TOTAL_ITEMS_EXPECTED && + allData.ru.length >= TOTAL_ITEMS_EXPECTED; + + if (!isComplete && cachedBiases) return cachedBiases; + + if (isComplete) { + cachedBiases = allData; + cachedBiasesAt = Date.now(); + } return allData; }; diff --git a/src/uxcore/api/personas.ts b/src/uxcore/api/personas.ts index 3bb4a2ef..cd63cbd9 100644 --- a/src/uxcore/api/personas.ts +++ b/src/uxcore/api/personas.ts @@ -1,13 +1,23 @@ -const headers = - typeof window !== 'undefined' - ? { - 'Content-Type': 'application/json', - Authorization: `Bearer ${ - localStorage.getItem('accessToken') || - localStorage.getItem('googleToken') - }`, - } - : null; +// Auth headers are built per call: a module-level snapshot would freeze the +// token present at first page load, so a user who logs in afterwards would +// keep POSTing "Bearer null". +const getAuthHeaders = () => { + if (typeof window === 'undefined') return null; + const token = + localStorage.getItem('accessToken') || localStorage.getItem('googleToken'); + if (!token) return null; + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; +}; + +const parseOrThrow = async (response: Response) => { + if (!response.ok) { + throw new Error(`Persona request failed with status ${response.status}.`); + } + return response.json(); +}; export const getPersonaList = async () => { const token = @@ -35,8 +45,9 @@ export const addPersona = async ( decisionTable: string, accountName: string, ) => { + const headers = getAuthHeaders(); if (!headers) { - return; + throw new Error('Not authorized: no access token for saving a persona.'); } const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/personas`; const body = JSON.stringify({ @@ -47,7 +58,7 @@ export const addPersona = async ( method: 'POST', headers, body, - }).then(data => data.json()); + }).then(parseOrThrow); }; export const updatePersona = async ( @@ -56,8 +67,9 @@ export const updatePersona = async ( decisionTable: string, accountName: string, ) => { + const headers = getAuthHeaders(); if (!headers) { - return; + throw new Error('Not authorized: no access token for saving a persona.'); } const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/personas/${Number( String(entryId).slice(1), @@ -70,12 +82,13 @@ export const updatePersona = async ( method: 'PUT', headers, body, - }).then(data => data.json()); + }).then(parseOrThrow); }; export const deletePersona = async (entryId: number | string) => { + const headers = getAuthHeaders(); if (!headers) { - return; + throw new Error('Not authorized: no access token for deleting a persona.'); } const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/personas/${Number( String(entryId).slice(1), @@ -84,7 +97,7 @@ export const deletePersona = async (entryId: number | string) => { return await fetch(url, { method: 'DELETE', headers, - }).then(data => data.json()); + }).then(parseOrThrow); }; export const getPersona = async (entryId: string, accountName: string) => { diff --git a/src/uxcore/api/rating.ts b/src/uxcore/api/rating.ts index d97f712a..72a0dff6 100644 --- a/src/uxcore/api/rating.ts +++ b/src/uxcore/api/rating.ts @@ -3,23 +3,32 @@ export const rateRequest = async ( rating: number, type: 'bias' | 'question', ) => { + // Geo enrichment is best-effort: a failed /api/user lookup must not + // abort the vote itself. + let userData: any = {}; try { - // TODO: keep data as it will be sent to avoid multiple requests - const userData = await fetch('/api/user').then(data => data.json()); - const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/ratings`; - const headers = { 'Content-Type': 'application/json' }; + userData = await fetch('/api/user').then(data => data.json()); + } catch { + userData = {}; + } + + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/ratings`; + const headers = { 'Content-Type': 'application/json' }; - const { country, region, city, ip } = userData; + const { country, region, city, ip } = userData; - const body = JSON.stringify({ - data: { elemId: `${id}`, rating, country, region, city, ip, type }, - }); - return await fetch(url, { - method: 'POST', - headers, - body, - }).then(data => data.json()); - } catch (err) { - throw new Error('Error occured while sending rating request.'); + const body = JSON.stringify({ + data: { elemId: `${id}`, rating, country, region, city, ip, type }, + }); + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + // A 4xx/5xx from Strapi is a lost vote — surface it to the caller + // instead of counting it as success. + if (!response.ok) { + throw new Error(`Rating request failed with status ${response.status}.`); } + return response.json(); }; diff --git a/src/uxcore/components/ModalRaiting/ModalRaiting.tsx b/src/uxcore/components/ModalRaiting/ModalRaiting.tsx index cb1b24f4..831b5aec 100644 --- a/src/uxcore/components/ModalRaiting/ModalRaiting.tsx +++ b/src/uxcore/components/ModalRaiting/ModalRaiting.tsx @@ -1,17 +1,16 @@ +import { rateRequest } from '@uxcore/api/rating'; +import modalIntl from '@uxcore/data/modalRaiting'; +import useSpinner from '@uxcore/hooks/useSpinner'; +import { + getRatedItems, + saveInLocalStorage, + updateVH, +} from '@uxcore/lib/helpers'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import { useRouter } from 'next/router'; import { FC, MouseEvent, useEffect, useState } from 'react'; -import type { TRouter } from '@uxcore/local-types/global'; - -import useSpinner from '@uxcore/hooks/useSpinner'; - -import { getRatedItems, saveInLocalStorage, updateVH } from '@uxcore/lib/helpers'; - -import { rateRequest } from '@uxcore/api/rating'; - -import modalIntl from '@uxcore/data/modalRaiting'; - import styles from './ModalRaiting.module.scss'; const rangeItems = Array(10) @@ -29,6 +28,7 @@ const ModalRaiting: FC = ({ id, type }) => { const { setIsVisible } = useSpinner()[0]; const [hoveredRangeItemId, setHoveredRangeItemId] = useState(null); const [isRateVisibile, setIsRateVisibile] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); const handleRangeItemMouseOver = (e: MouseEvent) => { const { raiting } = e.currentTarget.dataset; @@ -40,18 +40,24 @@ const ModalRaiting: FC = ({ id, type }) => { }; const handleRate = async (e: MouseEvent) => { + // Guard against double clicks: each extra click would POST another vote. + if (isSubmitting) return; const { raiting } = e.currentTarget.dataset; + setIsSubmitting(true); setIsVisible(true); try { await rateRequest(id, Number(raiting), type); saveInLocalStorage(id, type); + // Swap to "thanks" only after the vote actually landed; on failure + // the row stays so the user can retry instead of losing the vote. + setIsRateVisibile(false); } catch (err) { console.error('Error while rating:', err); } setIsVisible(false); - setIsRateVisibile(false); + setIsSubmitting(false); }; useEffect(() => { diff --git a/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx b/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx index 66f4404e..0200665b 100644 --- a/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx +++ b/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx @@ -68,7 +68,7 @@ const KemmioCredit = () => { kemmio {' '} - — Vahe Karapetyan{' '} + · Vahe Karapetyan{' '} { className={styles.hexensLink} > Hexens - {' '} - — the cybersecurity firm whose audits have safeguarded over + + , the cybersecurity firm whose audits have safeguarded over $125B in assets.