Skip to content
Merged
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Font passport

<!-- font-passport: allowed=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,76; floor=8; contrast=4.5 -->

- 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.
Expand Down
30 changes: 16 additions & 14 deletions src/pages/uxcat/ongoing.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -84,14 +80,20 @@ const Ongoing: FC<OngoingProps> = ({ 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;
Expand Down
39 changes: 26 additions & 13 deletions src/pages/uxcore/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,8 +42,8 @@ const UXCoreIds: FC<UXCoreProps> = ({
uxcgLocalizedData,
}) => {
const [activeBiasNumber, setActiveBiasNumber] = useState<number>(null);
const [isModalClosed, setIsModalClosed] = useState<boolean>(true);
const [, { isProductView }] = useUXCoreGlobals();
const [, setIsModalClosed] = useState<boolean>(true);
const [, { isProductView, isOffsecView }] = useUXCoreGlobals();
const router = useRouter();
const { locale } = router as TRouter;

Expand All @@ -53,10 +54,19 @@ const UXCoreIds: FC<UXCoreProps> = ({

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) => {
Expand Down Expand Up @@ -100,7 +110,7 @@ const UXCoreIds: FC<UXCoreProps> = ({
ogImage: {
data: {
attributes: {
url: currentModalData[`OGTags${lang}`]?.OGTags?.ogImage?.data
url: currentModalData[`OGTags${lang}`]?.ogImage?.data
?.attributes?.url,
staticUrl: '/assets/ogImages/UXCore.png',
},
Expand All @@ -111,21 +121,24 @@ const UXCoreIds: FC<UXCoreProps> = ({
}
}, [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 });
Expand Down
53 changes: 39 additions & 14 deletions src/uxcore/api/biases.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -48,32 +55,50 @@ 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: [],
ru: [],
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;
};
45 changes: 29 additions & 16 deletions src/uxcore/api/personas.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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({
Expand All @@ -47,7 +58,7 @@ export const addPersona = async (
method: 'POST',
headers,
body,
}).then(data => data.json());
}).then(parseOrThrow);
};

export const updatePersona = async (
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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) => {
Expand Down
39 changes: 24 additions & 15 deletions src/uxcore/api/rating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let userData: any = {} reintroduces an explicit any. Since only country, region, city, ip are destructured from it, a small inline type (or a shared TUserGeo type matching /api/user's response) would keep this typed without much overhead.

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();
};
Loading
Loading