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.
- Authored the Aptos critical-vulnerability research —
- unpatched, the flaw would have erased over $1T from Web3.
+ Authored the Aptos critical-vulnerability research. Unpatched,
+ the flaw would have erased over $1T from Web3.
Authored the disclosure behind the largest critical
- vulnerability in Web3 history — $500M of instant loss and
- $1.7T of cascade-effect damage on the table. Caught in private
+ vulnerability in Web3 history: $500M of instant loss and $1.7T
+ of cascade-effect damage on the table. Caught in private
disclosure; exploitation never landed.