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/components/library/molecules/StepIndicator/StepIndicator.module.scss b/src/components/library/molecules/StepIndicator/StepIndicator.module.scss index e7f019e9..b0029cbf 100644 --- a/src/components/library/molecules/StepIndicator/StepIndicator.module.scss +++ b/src/components/library/molecules/StepIndicator/StepIndicator.module.scss @@ -103,7 +103,6 @@ top: 18px; left: 46px; right: 46px; - width: 79.5%; height: 1px; background: var(--gray-100); overflow: hidden; diff --git a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss index b8eafd48..6dc68489 100644 --- a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss +++ b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss @@ -8,25 +8,35 @@ position: relative; } +// Geometry mirrors the Dropdown trigger so the two fields read as one control +// when they stack in a form: same height, padding, radius, and chevron divider. .trigger { width: 100%; + height: 44px; + box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--white); border: 1px solid var(--brown-border); - border-radius: 4px; - padding: 12px 16px; + border-radius: 8px; + padding: 0 0 0 12px; cursor: pointer; + transition: all 0.2s ease; &:disabled { opacity: 0.5; cursor: not-allowed; } - &.open { + &.open, + &:not(:disabled):hover { border-color: var(--brown); + + .iconWrapper { + border-left: 1px solid var(--brown); + } } .placeholder { @@ -35,13 +45,27 @@ color: var(--black); } + .iconWrapper { + border-left: 1px solid var(--brown-border); + height: -webkit-fill-available; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: center; + } + .icon { + flex-shrink: 0; transform: rotate(90deg); transition: transform 0.2s ease; &.rotated { transform: rotate(-90deg); } + + path { + fill: var(--brown); + } } } diff --git a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx index 7969576f..0a2e4b5a 100644 --- a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx +++ b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx @@ -67,11 +67,13 @@ export function TagMultiSelect(props: TagMultiSelectProps): JSX.Element { > {value.length > 0 ? `${value.length} selected` : placeholder} - +
+ +
{isOpen && (!portal || menuPos) && diff --git a/src/components/library/organisms/InteractiveCover/coverHotspots.ts b/src/components/library/organisms/InteractiveCover/coverHotspots.ts index 21406198..cab5b449 100644 --- a/src/components/library/organisms/InteractiveCover/coverHotspots.ts +++ b/src/components/library/organisms/InteractiveCover/coverHotspots.ts @@ -123,11 +123,39 @@ const makeHotspot = ( const lorem = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cih sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus'; +// Hit boxes are sized to the glow silhouette each hotspot lights up, so the +// whole building answers the pointer rather than just its roofline. +// +// Order matters: overlapping triggers share a z-index, so the last one declared +// wins the pointer. The tree (house-2) is the backdrop the buildings sit in +// front of, so it goes first and the buildings keep their own hover areas. export const coverHotspots: CoverHotspot[] = [ + makeHotspot( + 'house-2', + { + // The glow here traces the tree, not a building — hence the wide box. + hit: { left: 17.76, top: 0, width: 30.85, height: 55 }, + highlight: { + src: '/library/images/hotspots/house-2.svg', + alt: '', + left: 17.98, + top: 0, + width: 47.06, + }, + card: { left: 53.0, top: 19.01 }, + }, + { + libraryName: 'Sarah’s Library', + about: lorem, + bookCount: 88, + videoCount: 34, + songCount: 12, + }, + ), makeHotspot( 'house-1', { - hit: { left: 37.73, top: 11.9, width: 12.55, height: 35.9 }, + hit: { left: 41.45, top: 20.02, width: 13.13, height: 29.79 }, highlight: { src: '/library/images/hotspots/house-1.svg', alt: '', @@ -145,36 +173,15 @@ export const coverHotspots: CoverHotspot[] = [ songCount: 17, }, { - hit: { left: 45.8, top: 11.9, width: 6 }, + // The panorama art sits 0.33% lower here, matching the highlight offset. + hit: { top: 20.35 }, highlight: { left: 45.6, top: 7.9 }, }, ), - makeHotspot( - 'house-2', - { - hit: { left: 14.13, top: 2, width: 22.41, height: 60 }, - highlight: { - src: '/library/images/hotspots/house-2.svg', - alt: '', - left: 17.98, - top: 0, - width: 47.06, - }, - card: { left: 53.0, top: 19.01 }, - }, - { - libraryName: 'Sarah’s Library', - about: lorem, - bookCount: 88, - videoCount: 34, - songCount: 12, - }, - { hit: { left: 34.7824, top: 0 } }, - ), makeHotspot( 'house-3', { - hit: { left: 27.58, top: 43, width: 12.7, height: 34 }, + hit: { left: 31.13, top: 50.79, width: 15.57, height: 37.59 }, highlight: { src: '/library/images/hotspots/house-3.svg', alt: '', @@ -192,14 +199,14 @@ export const coverHotspots: CoverHotspot[] = [ songCount: 9, }, { - hit: { left: 41, top: 52, width: 7.096, height: 34 }, + hit: { top: 49.79 }, highlight: { left: 40.4, top: 17.5 }, }, ), makeHotspot( 'house-4', { - hit: { left: 17.12, top: 57, width: 10.46, height: 17 }, + hit: { left: 20.02, top: 61.73, width: 11.43, height: 22.42 }, highlight: { src: '/library/images/hotspots/house-4.svg', alt: '', @@ -217,7 +224,7 @@ export const coverHotspots: CoverHotspot[] = [ songCount: 6, }, { - hit: { left: 35.6, top: 66 }, + hit: { top: 60.83 }, highlight: { left: 35.552, top: 28 }, }, ), @@ -225,7 +232,7 @@ export const coverHotspots: CoverHotspot[] = [ 'house-5', { // Leader line exits to the LEFT, so the card sits left of the lantern. - hit: { left: 61.94, top: 38, width: 8.96, height: 26 }, + hit: { left: 65.44, top: 49.75, width: 8.59, height: 24.69 }, highlight: { src: '/library/images/hotspots/house-5.svg', alt: '', @@ -242,6 +249,5 @@ export const coverHotspots: CoverHotspot[] = [ videoCount: 14, songCount: 4, }, - { hit: { left: 57.7312, top: 44, width: 4.3008, height: 28 } }, ), ]; diff --git a/src/components/library/organisms/Sidebar/Sidebar.module.scss b/src/components/library/organisms/Sidebar/Sidebar.module.scss index f661b41d..60771ee9 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.module.scss +++ b/src/components/library/organisms/Sidebar/Sidebar.module.scss @@ -133,13 +133,15 @@ display: flex; flex-wrap: wrap; + // 20px row + the wrapper's 12px padding = a 44px box. &.tagsEmpty { - height: 50px; + height: 20px; align-items: center; } .emptyTags { color: var(--gray-medium); + line-height: 20px; } .button { 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..84471aa9 100644 --- a/src/uxcore/api/rating.ts +++ b/src/uxcore/api/rating.ts @@ -3,23 +3,38 @@ 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. + type TUserGeo = { + country?: string; + region?: string; + city?: string; + ip?: string; + }; + let userData: TUserGeo = {}; 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.