diff --git a/.gitignore b/.gitignore index 3f78fe62..65e2c957 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ yarn-error.log* # Coverage directory /coverage +/coverage-quiz # Dotenv files *.local @@ -64,7 +65,7 @@ next-env.d.ts # Documentation (development only) .claude/ CLAUDE.md -frontend/docs/ +frontend/_dev-notes/ frontend/.env.bak diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd6120a..ef4f7f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,3 +159,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Stabilized shop checkout, refund, inventory, and webhook flows - Improved Neon performance and reduced CU-hours usage - Cleaned up redundant files, comments, and legacy code + +## [0.5.0] - 2026-01-26 + +### Added + +- AI-powered Word Helper for Q&A: + - Text selection with floating “Explain” button + - Multilingual explanations (uk / en / pl) + - Draggable modal with caching and rate limiting +- Extensive automated testing: + - 90%+ coverage for Quiz core logic (unit + integration) + - Full Q&A component, hook, and API test coverage +- SEO & content enhancements for Blog: + - Breadcrumb navigation (posts & categories) + - Schema.org JSON-LD (Article, BreadcrumbList) + - Improved metadata (descriptions, dates, authors) +- Improved navigation & UX: + - Refactored responsive header with clear active states + - GitHub stars indicator in header + - Enhanced mobile menu with scroll locking +- Visual & UX polish across platform: + - Unified category accent colors across Quiz & Q&A + - Refined Leaderboard layout and mobile responsiveness + - Updated About page interactions and game mechanics +- Infrastructure & environment support: + - GROQ API integration for AI features + - New environment variable support and documentation + +### Changed + +- Quiz UI unified with Q&A design system: + - Shared CategoryTabButton component + - Category-based accent colors across full quiz flow + - Traffic-light countdown timer (green / yellow / red) +- Blog experience refined: + - Improved search relevance and filtering UX + - Better author navigation and category presentation +- Header, footer, and navigation styles aligned to brand tokens +- Shop UI polish: + - Button styles and hero messaging updated + - Stripe checkout success redirect fixed +- Removed deprecated Contacts page and all references + +### Fixed + +- Stabilized Leaderboard layout, spacing, and mobile behavior +- Fixed quiz timer persistence and anti-cheat messaging UX +- Improved accessibility and visual consistency across components +- Resolved locale duplication in Stripe checkout redirects +- Cleaned up redundant UI states, placeholders, and legacy styles diff --git a/frontend/.env.example b/frontend/.env.example index 0eb502ab..9feb174f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,7 @@ # --- Core / Environment +APP_ADDITIONAL_ORIGINS=https://admin.example.test APP_ENV= +APP_ORIGIN=https://example.test APP_URL= NEXT_PUBLIC_SITE_URL= @@ -83,6 +85,11 @@ STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60 STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30 STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60 +# SECURITY: If true, trust Cloudflare's cf-connecting-ip header for rate limiting. +# Enable ONLY when traffic is fronted by Cloudflare (header is set by Cloudflare at the edge). +# Default: false (0). Keep 0 in untrusted environments to avoid IP spoofing. +TRUST_CF_CONNECTING_IP=0 + # SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting. # Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers. # Default: false (empty/0/false). @@ -90,3 +97,5 @@ TRUST_FORWARDED_HEADERS=0 # emergency switch RATE_LIMIT_DISABLED=0 + +GROQ_API_KEY= \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index ca291aa3..f5dd8f9e 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/coverage-quiz # next.js /.next/ @@ -45,5 +46,5 @@ next-env.d.ts # Documentation (only for development) CLAUDE.md -docs/ -.claude \ No newline at end of file +_dev-notes/ +.claude diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index 34169551..80d13ea1 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -4,6 +4,7 @@ import groq from 'groq'; import { getTranslations } from 'next-intl/server'; import { client } from '@/client'; import { Link } from '@/i18n/routing'; +import { formatBlogDate } from '@/lib/blog/date'; export const revalidate = 0; @@ -45,6 +46,63 @@ function plainTextFromPortableText(value: any): string { .trim(); } +function linkifyText(text: string) { + const urlRegex = /(https?:\/\/[^\s]+)/g; + const parts = text.split(urlRegex); + return parts.map((part, index) => { + if (!part) return null; + if (urlRegex.test(part)) { + return ( + + {part} + + ); + } + return {part}; + }); +} + +function renderPortableTextSpans( + children: Array<{ _type?: string; text?: string; marks?: string[] }> = [], + markDefs: Array<{ _key?: string; _type?: string; href?: string }> = [] +) { + const linkMap = new Map( + markDefs + .filter(def => def?._type === 'link' && def?._key && def?.href) + .map(def => [def._key as string, def.href as string]) + ); + + return children.map((child, index) => { + const text = child?.text || ''; + if (!text) return null; + const marks = child?.marks || []; + const linkKey = marks.find(mark => linkMap.has(mark)); + + if (linkKey) { + const href = linkMap.get(linkKey)!; + return ( + + {text} + + ); + } + + return {linkifyText(text)}; + }); +} + function seededShuffle(items: T[], seed: number) { const result = [...items]; let value = seed; @@ -100,9 +158,17 @@ const recommendedQuery = groq` publishedAt, "mainImage": mainImage.asset->url, slug, + "categories": categories[]->title, "author": author->{ "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), "image": image.asset->url + }, + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + ..., + _type == "image" => { + ..., + "url": asset->url + } } } `; @@ -115,21 +181,22 @@ export default async function PostDetails({ locale: string; }) { const t = await getTranslations({ locale, namespace: 'blog' }); + const tNav = await getTranslations({ locale, namespace: 'navigation' }); const slugParam = String(slug || '').trim(); if (!slugParam) return notFound(); const post: Post | null = await client .withConfig({ useCdn: false }) .fetch(query, { - slug: slugParam, - locale, - }); + slug: slugParam, + locale, + }); const recommendedAll: Post[] = await client .withConfig({ useCdn: false }) .fetch(recommendedQuery, { - slug: slugParam, - locale, - }); + slug: slugParam, + locale, + }); const recommendedPosts = seededShuffle( recommendedAll, hashString(slugParam) @@ -145,131 +212,287 @@ export default async function PostDetails({ post.author?.city, ].filter(Boolean) as string[]; const authorMeta = authorMetaParts.join(' · '); + const categoryLabel = post.categories?.[0]; + const categoryDisplay = categoryLabel + ? getCategoryLabel(categoryLabel, t) + : null; + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL; + const postUrl = baseUrl ? `${baseUrl}/${locale}/blog/${slugParam}` : null; + const blogUrl = baseUrl ? `${baseUrl}/${locale}/blog` : null; + const description = plainTextFromPortableText(post.body).slice(0, 160); + const categoryHref = categoryLabel + ? `/blog/category/${categoryLabel + .toLowerCase() + .replace(/[^a-z0-9\\s-]/g, '') + .replace(/\\s+/g, '-')}` + : null; + const categoryUrl = + baseUrl && categoryLabel + ? `${baseUrl}/${locale}/blog/category/${categoryLabel + .toLowerCase() + .replace(/[^a-z0-9\\s-]/g, '') + .replace(/\\s+/g, '-')}` + : null; + const breadcrumbsItems = [ + { + name: tNav('blog'), + href: '/blog', + url: blogUrl, + }, + ...(categoryLabel + ? [ + { + name: categoryDisplay || categoryLabel, + href: categoryHref, + url: categoryUrl, + }, + ] + : []), + { + name: post.title, + href: '', + url: postUrl, + }, + ]; + const breadcrumbsJsonLd = + blogUrl && postUrl + ? { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: breadcrumbsItems + .filter(item => item.url) + .map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: item.url, + })), + } + : null; + const articleJsonLd = postUrl + ? { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline: post.title, + description: description || undefined, + mainEntityOfPage: postUrl, + url: postUrl, + datePublished: post.publishedAt || undefined, + author: post.author?.name + ? { + '@type': 'Person', + name: post.author.name, + } + : undefined, + image: post.mainImage ? [post.mainImage] : undefined, + } + : null; return ( -
- - - {t('goBack')} - - - {post.categories?.[0] && ( -
- - {post.categories[0] === 'Growth' ? 'Career' : post.categories[0]} - -
+
+ {breadcrumbsJsonLd && ( +