diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index df1730b9..f0399a94 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,3 +1,4 @@ +import { getSlimBiases } from '@uxcore/api/biases'; import { getOurProjects } from '@uxcore/api/our-projects'; import { GlobalContext as UXCoreGlobalContext } from '@uxcore/components/Context/GlobalContext'; import { NewUpdateModalContainer } from '@uxcore/components/NewUpdateModal'; @@ -42,6 +43,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { const [selectedTitle, setSelectedTitle] = useState(''); const [updatedUsername, setUpdatedUsername] = useState(''); const [ourProjectsModalData, setOurProjectsModalData] = useState(null); + const [uxCoreData, setUxCoreData] = useState(null); const isIndexingOn = process.env.NEXT_PUBLIC_INDEXING === 'on'; const isProduction = process.env.NEXT_PUBLIC_ENV === 'prod'; @@ -276,6 +278,24 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { }; }, [isUxcoreRoute, router.locale]); + const isUxcatRoute = router.pathname.startsWith('/uxcat'); + + useEffect(() => { + if (!isUxcatRoute || uxCoreData) return; + let cancelled = false; + (async () => { + try { + const data = await getSlimBiases(); + if (!cancelled) setUxCoreData(data); + } catch (err) { + console.warn('[uxcat-biases] fetch failed:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [isUxcatRoute, uxCoreData]); + const uxcoreContextValue = useMemo( () => ({ accountData, @@ -289,7 +309,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { setUpdatedUsername, ourProjectsModalData, setOurProjectsModalData, - uxCoreData: null, + uxCoreData, uxcgLocalizedData: null, uxcgData: null, }), @@ -299,6 +319,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { selectedTitle, updatedUsername, ourProjectsModalData, + uxCoreData, ], ); diff --git a/src/uxcore/api/biases.ts b/src/uxcore/api/biases.ts index 20cbbd26..a51ab586 100644 --- a/src/uxcore/api/biases.ts +++ b/src/uxcore/api/biases.ts @@ -1,8 +1,52 @@ let cachedBiases: any = null; +let cachedSlimBiases: any = null; const LOCALES = ['en', 'ru', 'hy']; const PAGE_SIZE = 100; const TOTAL_ITEMS_EXPECTED = 105; +const SLIM_FIELDS = [ + 'number', + 'title', + 'description', + 'slug', + // test-result.tsx JSON.parses this to pick recommended reading — omitting it + // makes that page throw once the bias list is populated. + 'mentionedQuestionsIds', +]; +const SLIM_LOCALES = ['en', 'ru']; + +// UXCAT renders bias titles/descriptions from the shared bias list, but the +// full payload (SEO, OG images, usage sections) is ~30x larger than what those +// screens read. Fetch only the fields UXCAT needs. +export const getSlimBiases = async () => { + if (cachedSlimBiases) return cachedSlimBiases; + + const fieldsQuery = SLIM_FIELDS.map( + (field, index) => `fields[${index}]=${field}`, + ).join('&'); + + const allData = { en: [], ru: [], hy: [] }; + + for (const locale of SLIM_LOCALES) { + let page = 1; + while (true) { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&${fieldsQuery}`; + const res = await fetch(url); + const json = await res.json(); + + if (!json.data || json.data.length === 0) break; + + allData[locale].push(...json.data); + + if (json.data.length < PAGE_SIZE) break; + page++; + } + } + + cachedSlimBiases = allData; + return allData; +}; + export const getStrapiBiases = async () => { if (cachedBiases) return cachedBiases; diff --git a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx index 0def0567..aaae7978 100644 --- a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx +++ b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx @@ -1,17 +1,14 @@ +import CalendarItems from '@uxcore/components/CalendarItems'; +import Modal from '@uxcore/components/Modal'; +import calendar from '@uxcore/data/uxcat/calendar'; +import { useClickOutside } from '@uxcore/hooks/useClickOutside'; +import useMobile from '@uxcore/hooks/useMobile'; +import { getEventWindow, toICalUTC } from '@uxcore/lib/ics'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import { useRouter } from 'next/router'; import { FC, useState } from 'react'; -import type { TRouter } from '@uxcore/local-types/global'; - -import { useClickOutside } from '@uxcore/hooks/useClickOutside'; -import useMobile from '@uxcore/hooks/useMobile'; - -import calendar from '@uxcore/data/uxcat/calendar'; - -import CalendarItems from '@uxcore/components/CalendarItems'; -import Modal from '@uxcore/components/Modal'; - import styles from './AddToCalendar.module.scss'; type AddToCalendarProps = { @@ -33,18 +30,23 @@ const AddToCalendar: FC = ({ const currentLocale = locale === 'ru' ? 'ru' : 'en'; const { addToCalendar, title, description } = calendar[currentLocale]; - const calendarDescription = `${description} ${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const testUrl = `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const calendarDescription = `${description} ${testUrl}`; + + const eventWindow = getEventWindow(startTime); + if (!eventWindow) return null; const event = { - title: title, - startTime: startTime?.toString(), + title, + start: eventWindow.start, + end: eventWindow.end, description: calendarDescription, - url: `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`, + url: testUrl, }; - const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(event.title)}&dates=${event.startTime}&details=${encodeURIComponent(calendarDescription)}`; + const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toICalUTC(eventWindow.start)}/${toICalUTC(eventWindow.end)}&details=${encodeURIComponent(calendarDescription)}`; - const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(event.title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${event.startTime}`; + const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${eventWindow.start.toISOString()}&enddt=${eventWindow.end.toISOString()}`; return ( <> diff --git a/src/uxcore/components/CalendarItems/CalendarItems.tsx b/src/uxcore/components/CalendarItems/CalendarItems.tsx index 85dc8af4..d9d5ffd5 100644 --- a/src/uxcore/components/CalendarItems/CalendarItems.tsx +++ b/src/uxcore/components/CalendarItems/CalendarItems.tsx @@ -1,11 +1,11 @@ +import { buildICS, CalendarEvent, downloadICS } from '@uxcore/lib/ics'; import Image from 'next/image'; import { FC } from 'react'; -import ICalendarLink from 'react-icalendar-link'; import styles from './CalendarItems.module.scss'; type CalendarItemsProps = { - event: any; + event: CalendarEvent; googleCalendarUrl: string; outlookCalendarUrl: string; }; @@ -17,8 +17,11 @@ const CalendarItems: FC = ({ return (
- {/*@ts-ignore*/} - +
= ({ ), ); } - }, []); + }, [uxCoreData]); return ( String(value).padStart(2, '0'); + +// nextTestTime arrives as an epoch timestamp, and `new Date('1758...')` parses +// as Invalid Date — numeric strings have to be coerced to a number first. +export const parseEventDate = ( + value?: string | number | Date | null, +): Date | null => { + if (value === null || value === undefined || value === '') return null; + + const raw = value instanceof Date ? value : String(value).trim(); + const normalized = + typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : raw; + + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const getEventWindow = (value?: string | number | Date | null) => { + const start = parseEventDate(value); + if (!start) return null; + + return { start, end: new Date(start.getTime() + DEFAULT_DURATION_MS) }; +}; + +// RFC 5545 UTC date-time: YYYYMMDDTHHMMSSZ +export const toICalUTC = (date: Date) => + [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + 'T', + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + 'Z', + ].join(''); + +// RFC 5545 §3.3.11 +const escapeText = (value: string) => + value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); + +// RFC 5545 §3.1 — content lines are folded at 75 octets, and Cyrillic copy +// blows past that in half the characters, so fold by byte length not length. +const foldLine = (line: string) => { + const encoder = new TextEncoder(); + if (encoder.encode(line).length <= MAX_LINE_OCTETS) return line; + + const chunks: string[] = []; + let current = ''; + let currentOctets = 0; + + for (const char of line) { + const octets = encoder.encode(char).length; + if (currentOctets + octets > MAX_LINE_OCTETS) { + chunks.push(current); + current = ''; + currentOctets = 0; + } + current += char; + currentOctets += octets; + } + chunks.push(current); + + return chunks.join(`${CRLF} `); +}; + +export const buildICS = ({ + title, + start, + end, + description, + url, +}: CalendarEvent) => { + const startUTC = toICalUTC(start); + const endUTC = toICalUTC( + end ?? new Date(start.getTime() + DEFAULT_DURATION_MS), + ); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + `PRODID:${PRODID}`, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + // Deterministic UID so re-downloading updates the event instead of + // stacking duplicates in the user's calendar. + `UID:uxcat-${startUTC}@keepsimple.io`, + `DTSTAMP:${toICalUTC(new Date())}`, + `DTSTART:${startUTC}`, + `DTEND:${endUTC}`, + `SUMMARY:${escapeText(title)}`, + ]; + + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`); + // URL is a URI value, not TEXT — it must not be backslash-escaped. + if (url) lines.push(`URL:${url}`); + + lines.push('END:VEVENT', 'END:VCALENDAR'); + + return `${lines.map(foldLine).join(CRLF)}${CRLF}`; +}; + +export const downloadICS = (content: string, filename: string) => { + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index b98d25d1..b5579048 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -2184,9 +2184,19 @@ export function AskUxCore({ lang }: { lang: Lang }) { const occluded = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0; + // Host pages can lift the widget off the bottom edge (--ks-aux-lift). + // That clearance eats into the room the panel has, so subtract it or + // the panel overflows the top of short screens. + const widget = document.querySelector('.ks-aux-root'); + const lift = widget + ? parseFloat(getComputedStyle(widget).getPropertyValue('--ks-aux-lift')) + : 0; root.style.setProperty('--ks-aux-vh', `${h}px`); root.style.setProperty('--ks-aux-bottom-offset', `${occluded}px`); - root.style.setProperty('--ks-aux-panel-h', `${Math.max(220, h - 96)}px`); + root.style.setProperty( + '--ks-aux-panel-h', + `${Math.max(220, h - 96 - (lift || 0))}px`, + ); }; setVh(); const vv = window.visualViewport; diff --git a/widget/src/styles.css b/widget/src/styles.css index 8d027253..046f47ed 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -2,6 +2,10 @@ position: fixed; right: 24px; bottom: 24px; + /* Extra clearance for host pages that park their own controls in the + bottom-right corner. Margin (not `bottom`) so it composes with the + mobile keyboard offset below without rewriting that calc. */ + margin-bottom: var(--ks-aux-lift, 0px); z-index: 2147483600; font-family: 'Jost-Regular', 'Jost', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; @@ -24,6 +28,13 @@ } } +/* UX Core surfaces (uxcore + bias pages, uxcg + questions, uxcp, uxcat, + uxcore-api) keep their own chrome in the bottom-right corner. `_app.tsx` + sets body.uxcorePage for exactly those routes. */ +body.uxcorePage .ks-aux-root { + --ks-aux-lift: 70px; +} + @keyframes ks-aux-pulse { 0%, 100% { box-shadow: 0 4px 16px rgba(31, 29, 26, 0.16); @@ -1312,7 +1323,10 @@ right: 0; bottom: 60px; /* sits just above the pill */ width: auto; - height: var(--ks-aux-panel-h, calc(100dvh - 96px)); + height: var( + --ks-aux-panel-h, + calc(100dvh - 96px - var(--ks-aux-lift, 0px)) + ); max-height: 640px; border-radius: 14px; }