diff --git a/CHANGELOG.md b/CHANGELOG.md index e66817dd..41e80e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -818,3 +818,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Optimized checkout and payment status polling with backoff strategy - Lightweight order status view for faster client updates - Reduced session activity write frequency + +## [1.0.7] - 2026-03-08 + +### Added + +- Blog platform migration: + - Blog content fully migrated from **Sanity → PostgreSQL** + - New **Drizzle ORM query layer** for blog posts, categories and authors + - Multi-language blog support (uk / en / pl) + - Portable Text → Tiptap JSON conversion + - Cloudinary image hosting for blog assets +- Wallet payments foundation: + - Monobank **Google Pay checkout support** + - Wallet payment adapter and wallet service layer + - Payment method selection with idempotent checkout flow + +### Changed + +- Blog architecture: + - Public blog routes now read **entirely from PostgreSQL** + - Removed all runtime calls to `api.sanity.io` + - Blog rendering switched to server component `BlogPostRenderer` +- Internationalization improvements: + - Improved locale handling for blog pages + - Expanded translation coverage across auth and dashboard flows + +### Fixed + +- Fixed **500 error on blog post pages** caused by `pt::text()` GROQ query +- Fixed layout issues with ISR/SSG blog rendering +- Fixed duplicate response handling in order status API +- Improved error handling and automatic cleanup in order processing +- Stabilized shipping status transition validation +- Fixed missing translation keys and raw error strings + +### Shop & Payments + +- Stabilized checkout compatibility across Monobank and Stripe +- Added wallet attribution support for Google Pay +- Hardened Nova Poshta warehouse synchronization and caching +- Improved webhook retry handling for payment flows +- Added Google Pay fallback messaging for Monobank checkout + +### Performance & Infrastructure + +- Added DB indexes for blog queries +- Optimized blog queries through Drizzle ORM +- Reduced external API dependencies (Sanity removed from runtime) +- Improved shipping and payment event processing reliability diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts index 761fe18a..4b68b5b3 100644 --- a/frontend/actions/quiz.ts +++ b/frontend/actions/quiz.ts @@ -10,9 +10,10 @@ import { quizAttempts, quizQuestions, } from '@/db/schema/quiz'; -import { getCurrentUser } from '@/lib/auth'; import { ACHIEVEMENTS, computeAchievements } from '@/lib/achievements'; +import { getCurrentUser } from '@/lib/auth'; import { getUserStatsForAchievements } from '@/lib/user-stats'; + import { createNotification } from './notifications'; export interface UserAnswer { diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index 95b3c13c..ebf76440 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -1,303 +1,14 @@ -import groq from 'groq'; import Image from 'next/image'; import { notFound } from 'next/navigation'; -import { getTranslations } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { client } from '@/client'; +import BlogPostRenderer from '@/components/blog/BlogPostRenderer'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { getBlogPostBySlug, getBlogPosts } from '@/db/queries/blog/blog-posts'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; - -type SocialLink = { - _key?: string; - platform?: string; - url?: string; -}; - -type Author = { - name?: string; - company?: string; - jobTitle?: string; - city?: string; - image?: string; - bio?: any; - socialMedia?: SocialLink[]; -}; - -type Post = { - _id?: string; - title?: string; - publishedAt?: string; - mainImage?: string; - categories?: string[]; - tags?: string[]; - resourceLink?: string; - author?: Author; - body?: any[]; - slug?: { current?: string }; -}; - -function plainTextFromPortableText(value: any): string { - if (!Array.isArray(value)) return ''; - return value - .filter(b => b?._type === 'block') - .map(b => (b.children || []).map((c: any) => c.text || '').join('')) - .join('\n') - .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 || []; - - let node: React.ReactNode = marks.length === 0 ? linkifyText(text) : text; - - for (const mark of marks) { - if (linkMap.has(mark)) { - const href = linkMap.get(mark)!; - node = ( - - {node} - - ); - continue; - } - if (mark === 'strong') { - node = {node}; - continue; - } - if (mark === 'em') { - node = {node}; - continue; - } - if (mark === 'underline') { - node = {node}; - continue; - } - if (mark === 'code') { - node = ( - - {node} - - ); - continue; - } - if (mark === 'strike-through' || mark === 'strike') { - node = {node}; - } - } - - return {node}; - }); -} - -function renderPortableTextBlock(block: any, index: number): React.ReactNode { - const children = renderPortableTextSpans(block.children, block.markDefs); - const style = block?.style || 'normal'; - - if (style === 'h1') { - return ( -

- {children} -

- ); - } - if (style === 'h2') { - return ( -

- {children} -

- ); - } - if (style === 'h3') { - return ( -

- {children} -

- ); - } - if (style === 'h4') { - return ( -

- {children} -

- ); - } - if (style === 'h5') { - return ( -
- {children} -
- ); - } - if (style === 'h6') { - return ( -
- {children} -
- ); - } - if (style === 'blockquote') { - return ( -
- {children} -
- ); - } - - return ( -

- {children} -

- ); -} - -function renderPortableText( - body: any[], - postTitle?: string -): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - let i = 0; - - while (i < body.length) { - const block = body[i]; - - if (block?._type === 'block' && block.listItem) { - const listType = block.listItem === 'number' ? 'ol' : 'ul'; - const level = block.level ?? 1; - const items: React.ReactNode[] = []; - let j = i; - - while ( - j < body.length && - body[j]?._type === 'block' && - body[j].listItem === block.listItem && - (body[j].level ?? 1) === level - ) { - const item = body[j]; - items.push( -
  • - {renderPortableTextSpans(item.children, item.markDefs)} -
  • - ); - j += 1; - } - - const listClass = - listType === 'ol' ? 'my-4 list-decimal pl-6' : 'my-4 list-disc pl-6'; - const levelClass = level > 1 ? 'ml-6' : ''; - nodes.push( - listType === 'ol' ? ( -
      - {items} -
    - ) : ( - - ) - ); - i = j; - continue; - } - - if (block?._type === 'block') { - nodes.push(renderPortableTextBlock(block, i)); - i += 1; - continue; - } - - if (block?._type === 'image' && block?.url) { - nodes.push( - {postTitle - ); - i += 1; - continue; - } - - i += 1; - } - - return nodes; -} +import { extractPlainText } from '@/lib/blog/text'; function seededShuffle(items: T[], seed: number) { const result = [...items]; @@ -318,56 +29,15 @@ function hashString(input: string) { return hash; } -const query = groq` - *[_type=="post" && slug.current==$slug][0]{ - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), - publishedAt, - "mainImage": mainImage.asset->url, - "categories": categories[]->title, - tags, - resourceLink, - - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio), - "image": image.asset->url, - socialMedia[]{ _key, platform, url } - }, - - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ - ..., - _type == "image" => { - ..., - "url": asset->url - } - } - } -`; -const recommendedQuery = groq` - *[_type=="post" && defined(slug.current) && slug.current != $slug]{ - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), - 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 - } - } - } -`; +function getCategoryLabel(categoryName: string, t: (key: string) => string) { + const key = categoryName.toLowerCase(); + if (key === 'growth') return t('categories.career'); + if (key === 'tech') return t('categories.tech'); + if (key === 'career') return t('categories.career'); + if (key === 'insights') return t('categories.insights'); + if (key === 'news') return t('categories.news'); + return categoryName; +} export default async function PostDetails({ slug, @@ -376,54 +46,35 @@ export default async function PostDetails({ slug: string; locale: string; }) { + setRequestLocale(locale); 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.fetch(query, { - slug: slugParam, - locale, - }); - const recommendedAll: Post[] = await client.fetch(recommendedQuery, { - slug: slugParam, - locale, - }); + const [post, allPosts] = await Promise.all([ + getBlogPostBySlug(slugParam, locale), + getBlogPosts(locale), + ]); + + if (!post) return notFound(); + const recommendedPosts = seededShuffle( - recommendedAll, + allPosts.filter(p => p.slug !== slugParam), hashString(slugParam) ).slice(0, 3); - if (!post?.title) return notFound(); - - const authorBio = plainTextFromPortableText(post.author?.bio); const authorName = post.author?.name; - const authorMetaParts = [ - post.author?.jobTitle, - post.author?.company, - post.author?.city, - ].filter(Boolean) as string[]; - const authorMeta = authorMetaParts.join(' · '); - const categoryLabel = post.categories?.[0]; - const categoryDisplay = categoryLabel - ? getCategoryLabel(categoryLabel, t) - : null; + const category = post.categories?.[0]; + const categoryDisplay = category ? getCategoryLabel(category.title, 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 description = extractPlainText(post.body).slice(0, 160); + const categoryHref = category ? `/blog/category/${category.slug}` : null; const categoryUrl = - baseUrl && categoryLabel - ? `${baseUrl}/${locale}/blog/category/${categoryLabel - .toLowerCase() - .replace(/[^a-z0-9\\s-]/g, '') - .replace(/\\s+/g, '-')}` + baseUrl && category + ? `${baseUrl}/${locale}/blog/category/${category.slug}` : null; const breadcrumbsItems = [ { @@ -431,10 +82,10 @@ export default async function PostDetails({ href: '/blog', url: blogUrl, }, - ...(categoryLabel + ...(category ? [ { - name: categoryDisplay || categoryLabel, + name: categoryDisplay || category.title, href: categoryHref, url: categoryUrl, }, @@ -531,13 +182,13 @@ export default async function PostDetails({
    - {categoryLabel && ( + {category && (
    - {categoryDisplay || categoryLabel} + {categoryDisplay || category.title}
    )} @@ -565,8 +216,6 @@ export default async function PostDetails({ )}
    - {(post.tags?.length || 0) > 0 && null} - {post.mainImage && (
    - {renderPortableText(post.body || [], post.title)} +
    @@ -600,13 +249,13 @@ export default async function PostDetails({ {recommendedPosts.map(item => { const itemCategory = item.categories?.[0]; const itemCategoryDisplay = itemCategory - ? getCategoryLabel(itemCategory, t) + ? getCategoryLabel(itemCategory.title, t) : null; return ( {item.mainImage && ( @@ -615,9 +264,7 @@ export default async function PostDetails({ src={item.mainImage} alt={item.title || 'Post image'} fill - unoptimized={shouldBypassImageOptimization( - item.mainImage - )} + unoptimized={shouldBypassImageOptimization(item.mainImage)} className="object-cover transition-transform duration-300 group-hover:scale-[1.03]" /> @@ -625,9 +272,9 @@ export default async function PostDetails({

    {item.title}

    - {item.body && ( + {item.body != null && (

    - {plainTextFromPortableText(item.body)} + {extractPlainText(item.body)}

    )} {(item.author?.name || @@ -640,9 +287,7 @@ export default async function PostDetails({ src={item.author.image} alt={item.author.name || 'Author'} fill - unoptimized={shouldBypassImageOptimization( - item.author.image - )} + unoptimized={shouldBypassImageOptimization(item.author.image)} className="object-cover" /> @@ -675,21 +320,7 @@ export default async function PostDetails({ )} - - {post.resourceLink && null} - - {(authorBio || authorName || authorMeta) && null} ); } - -function getCategoryLabel(categoryName: string, t: (key: string) => string) { - const key = categoryName.toLowerCase(); - if (key === 'growth') return t('categories.career'); - if (key === 'tech') return t('categories.tech'); - if (key === 'career') return t('categories.career'); - if (key === 'insights') return t('categories.insights'); - if (key === 'news') return t('categories.news'); - return categoryName; -} diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index 377b9354..265c6870 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -1,20 +1,10 @@ -import groq from 'groq'; +import { setRequestLocale } from 'next-intl/server'; -import { client } from '@/client'; +import { getBlogPostBySlug } from '@/db/queries/blog/blog-posts'; import PostDetails from './PostDetails'; -export const revalidate = 3600; - -export async function generateStaticParams() { - const slugs = await client.fetch( - groq`*[_type == "post" && defined(slug.current)][].slug.current` - ); - - return slugs.map(slug => ({ - slug, - })); -} +export const dynamic = 'force-dynamic'; export async function generateMetadata({ params, @@ -22,19 +12,8 @@ export async function generateMetadata({ params: Promise<{ slug: string; locale: string }>; }) { const { slug, locale } = await params; - - const post = await client.fetch( - groq`*[_type == "post" && slug.current == $slug][0]{ - "title": coalesce(title[$locale], title.en, title), - "description": pt::text(coalesce(body[$locale], body.en, body))[0...160] - }`, - { slug, locale } - ); - - return { - title: post?.title || 'Post', - description: post?.description || undefined, - }; + const post = await getBlogPostBySlug(slug, locale); + return { title: post?.title ?? 'Post' }; } export default async function Page({ @@ -43,5 +22,6 @@ export default async function Page({ params: Promise<{ slug: string; locale: string }>; }) { const { slug, locale } = await params; + setRequestLocale(locale); return ; } diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index 8db74901..a0c257e5 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -1,45 +1,26 @@ -import groq from 'groq'; import Image from 'next/image'; import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { client } from '@/client'; import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid'; import { FeaturedPostCtaButton } from '@/components/blog/FeaturedPostCtaButton'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { getBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getBlogPostsByCategory } from '@/db/queries/blog/blog-posts'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; -import { shouldBypassImageOptimization } from '@/lib/blog/image'; export const revalidate = 3600; -type Author = { - name?: string; - image?: string; -}; - -type Post = { - _id: string; - title: string; - slug: { current: string }; - publishedAt?: string; - categories?: string[]; - mainImage?: string; - body?: any[]; - author?: Author; -}; - -type Category = { - _id: string; - title: string; -}; - -const categoriesQuery = groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } -`; +function getCategoryLabel(categoryName: string, t: (key: string) => string) { + const key = categoryName.toLowerCase(); + if (key === 'growth') return t('categories.career'); + if (key === 'tech') return t('categories.tech'); + if (key === 'career') return t('categories.career'); + if (key === 'insights') return t('categories.insights'); + if (key === 'news') return t('categories.news'); + return categoryName; +} export default async function BlogCategoryPage({ params, @@ -49,39 +30,16 @@ export default async function BlogCategoryPage({ const { locale, category } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); const tNav = await getTranslations({ locale, namespace: 'navigation' }); - const categoryKey = String(category || '').toLowerCase(); - const categories: Category[] = await client.fetch(categoriesQuery); - const matchedCategory = categories.find( - item => slugify(item.title) === categoryKey - ); - if (!matchedCategory) return notFound(); - const categoryTitle = matchedCategory.title; - const categoryDisplay = getCategoryLabel(categoryTitle, t); + const [categories, posts] = await Promise.all([ + getBlogCategories(locale), + getBlogPostsByCategory(category, locale), + ]); - const posts: Post[] = await client.fetch( - groq` - *[_type == "post" && defined(slug.current) && $category in categories[]->title] - | order(coalesce(publishedAt, _createdAt) desc) { - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), - slug, - publishedAt, - "categories": categories[]->title, - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ - ..., - children[]{ text } - }, - "mainImage": mainImage.asset->url, - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), - "image": image.asset->url - } - } - `, - { locale, category: categoryTitle } - ); + const matchedCategory = categories.find(c => c.slug === category); + if (!matchedCategory) return notFound(); + const categoryDisplay = getCategoryLabel(matchedCategory.title, t); const featuredPost = posts[0]; const restPosts = posts.slice(1); const featuredDate = formatBlogDate(featuredPost?.publishedAt); @@ -120,18 +78,16 @@ export default async function BlogCategoryPage({ alt={featuredPost.title} width={1400} height={800} - unoptimized={shouldBypassImageOptimization( - featuredPost.mainImage - )} + unoptimized className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" priority={false} />
    - {featuredPost.categories?.[0] && ( + {featuredPost.categories?.[0]?.title && (
    - {featuredPost.categories[0]} + {featuredPost.categories[0].title}
    )}

    @@ -144,9 +100,7 @@ export default async function BlogCategoryPage({ alt={featuredPost.author.name || 'Author'} width={28} height={28} - unoptimized={shouldBypassImageOptimization( - featuredPost.author.image - )} + unoptimized className="h-7 w-7 rounded-full object-cover" /> )} @@ -162,7 +116,7 @@ export default async function BlogCategoryPage({

    @@ -170,7 +124,7 @@ export default async function BlogCategoryPage({ )}
    - +
    {!posts.length && (

    {t('noPosts')}

    @@ -179,21 +133,3 @@ export default async function BlogCategoryPage({ ); } - -function slugify(value: string) { - return value - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-'); -} - -function getCategoryLabel(categoryName: string, t: (key: string) => string) { - const key = categoryName.toLowerCase(); - if (key === 'growth') return t('categories.career'); - if (key === 'tech') return t('categories.tech'); - if (key === 'career') return t('categories.career'); - if (key === 'insights') return t('categories.insights'); - if (key === 'news') return t('categories.news'); - return categoryName; -} diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index a0a159d6..6562b358 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -1,10 +1,10 @@ -import groq from 'groq'; import { getTranslations } from 'next-intl/server'; -import { client } from '@/client'; import BlogFilters from '@/components/blog/BlogFilters'; import { BlogPageHeader } from '@/components/blog/BlogPageHeader'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { getBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getBlogPosts } from '@/db/queries/blog/blog-posts'; export const revalidate = 3600; @@ -30,52 +30,12 @@ export default async function BlogPage({ const { locale } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); - const posts = await client.fetch( - groq` - *[_type == "post" && defined(slug.current)] - | order(coalesce(publishedAt, _createdAt) desc) { - _id, - "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), - slug, - publishedAt, - tags, - resourceLink, + const [posts, categories] = await Promise.all([ + getBlogPosts(locale), + getBlogCategories(locale), + ]); - "categories": categories[]->title, - - "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ - ..., - children[]{ - text - } - }, - "mainImage": mainImage.asset->url, - "author": author->{ - "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), - "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company), - "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle), - "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city), - "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio), - "image": image.asset->url, - socialMedia[]{ - _key, - platform, - url - } - } - } - `, - { locale } - ); - const categories = await client.fetch( - groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } - ` - ); - const featuredPost = posts?.[0]; + const featuredPost = posts[0]; return ( diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index ef202148..3cd94956 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -1,33 +1,19 @@ -import groq from 'groq'; -import { unstable_cache } from 'next/cache'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import type React from 'react'; import { Toaster } from 'sonner'; -import { client } from '@/client'; import { AppChrome } from '@/components/header/AppChrome'; import { MainSwitcher } from '@/components/header/MainSwitcher'; import { CookieBanner } from '@/components/shared/CookieBanner'; import Footer from '@/components/shared/Footer'; import { ScrollWatcher } from '@/components/shared/ScrollWatcher'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; +import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; import { AuthProvider } from '@/hooks/useAuth'; import { locales } from '@/i18n/config'; -const getCachedBlogCategories = unstable_cache( - async () => - client.fetch>(groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } - `), - ['blog-categories'], - { revalidate: 3600, tags: ['blog-categories'] } -); - export default async function LocaleLayout({ children, params, @@ -41,7 +27,7 @@ export default async function LocaleLayout({ const [messages, blogCategories] = await Promise.all([ getMessages({ locale }), - getCachedBlogCategories(), + getCachedBlogCategories(locale), ]); const enableAdmin = diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 5bffd616..30b66fab 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -72,9 +72,14 @@ const ORDERS_CARD = cn('border-border rounded-md border p-4'); type Props = { stripeEnabled: boolean; monobankEnabled: boolean; + monobankGooglePayEnabled: boolean; }; type CheckoutProvider = 'stripe' | 'monobank'; +type CheckoutPaymentMethod = + | 'stripe_card' + | 'monobank_invoice' + | 'monobank_google_pay'; function resolveInitialProvider(args: { stripeEnabled: boolean; @@ -85,11 +90,22 @@ function resolveInitialProvider(args: { const canUseStripe = args.stripeEnabled; const canUseMonobank = args.monobankEnabled && isUah; - if (canUseStripe) return 'stripe'; if (canUseMonobank) return 'monobank'; + if (canUseStripe) return 'stripe'; return 'stripe'; } +function resolveDefaultMethodForProvider(args: { + provider: CheckoutProvider; + currency: string | null | undefined; +}): CheckoutPaymentMethod | null { + if (args.provider === 'stripe') return 'stripe_card'; + if (args.provider === 'monobank') { + return args.currency === 'UAH' ? 'monobank_invoice' : null; + } + return null; +} + type OrdersSummaryState = { count: number; latestOrderId: string | null; @@ -112,13 +128,84 @@ type ShippingWarehouse = { address: string | null; }; +function normalizeLookupValue(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function normalizeShippingCity(raw: unknown): ShippingCity | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const item = raw as Record; + + const ref = typeof item.ref === 'string' ? item.ref.trim() : ''; + + const rawName = + typeof item.nameUa === 'string' + ? item.nameUa + : typeof item.name_ua === 'string' + ? item.name_ua + : typeof item.name === 'string' + ? item.name + : typeof item.present === 'string' + ? item.present + : ''; + + const nameUa = rawName.trim(); + + if (!ref || !nameUa) { + return null; + } + + return { + ref, + nameUa, + }; +} + +function parseShippingCitiesResponse(data: unknown): { + available: boolean | null; + items: ShippingCity[]; +} { + if (Array.isArray(data)) { + return { + available: null, + items: data + .map(normalizeShippingCity) + .filter((item): item is ShippingCity => item !== null), + }; + } + + if (!data || typeof data !== 'object') { + return { + available: null, + items: [], + }; + } + + const obj = data as Record; + const itemsRaw = Array.isArray(obj.items) ? obj.items : []; + + return { + available: typeof obj.available === 'boolean' ? obj.available : null, + items: itemsRaw + .map(normalizeShippingCity) + .filter((item): item is ShippingCity => item !== null), + }; +} + function isWarehouseMethod( methodCode: CheckoutDeliveryMethodCode | null ): boolean { return methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER'; } -export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { +export default function CartPage({ + stripeEnabled, + monobankEnabled, + monobankGooglePayEnabled, +}: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); const t = useTranslations('shop.cart'); @@ -134,14 +221,20 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); const [isOrdersLoading, setIsOrdersLoading] = useState(false); - const [selectedProvider, setSelectedProvider] = useState( - () => - resolveInitialProvider({ - stripeEnabled, - monobankEnabled, + const initialProvider = resolveInitialProvider({ + stripeEnabled, + monobankEnabled, + currency: cart?.summary?.currency, + }); + const [selectedProvider, setSelectedProvider] = + useState(initialProvider); + const [selectedPaymentMethod, setSelectedPaymentMethod] = + useState(() => + resolveDefaultMethodForProvider({ + provider: initialProvider, currency: cart?.summary?.currency, }) - ); + ); const [isClientReady, setIsClientReady] = useState(false); const [shippingMethods, setShippingMethods] = useState([]); const [shippingMethodsLoading, setShippingMethodsLoading] = useState(true); @@ -156,6 +249,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const [selectedCityRef, setSelectedCityRef] = useState(null); const [selectedCityName, setSelectedCityName] = useState(null); const [citiesLoading, setCitiesLoading] = useState(false); + const [cityLookupFailed, setCityLookupFailed] = useState(false); const [warehouseQuery, setWarehouseQuery] = useState(''); const [warehouseOptions, setWarehouseOptions] = useState( @@ -188,6 +282,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const isUahCheckout = cart.summary.currency === 'UAH'; const canUseStripe = stripeEnabled; const canUseMonobank = monobankEnabled && isUahCheckout; + const canUseMonobankGooglePay = canUseMonobank && monobankGooglePayEnabled; const hasSelectableProvider = canUseStripe || canUseMonobank; const country = localeToCountry(locale); const shippingUnavailableHardBlock = @@ -246,6 +341,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { if (key) return safeT(key, code ?? 'SHIPPING_INVALID'); return safeT('delivery.validation.invalid', code ?? 'SHIPPING_INVALID'); }; + const clearCheckoutUiErrors = () => { + setDeliveryUiError(null); + setCheckoutError(null); + }; useEffect(() => { if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { @@ -257,6 +356,40 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } }, [canUseMonobank, canUseStripe, selectedProvider]); + useEffect(() => { + if (selectedProvider === 'stripe') { + if (selectedPaymentMethod !== 'stripe_card') { + setSelectedPaymentMethod('stripe_card'); + } + return; + } + + if ( + selectedPaymentMethod === 'monobank_google_pay' && + !canUseMonobankGooglePay + ) { + setSelectedPaymentMethod('monobank_invoice'); + return; + } + + if ( + selectedPaymentMethod !== 'monobank_invoice' && + selectedPaymentMethod !== 'monobank_google_pay' + ) { + setSelectedPaymentMethod( + resolveDefaultMethodForProvider({ + provider: 'monobank', + currency: cart.summary.currency, + }) + ); + } + }, [ + canUseMonobankGooglePay, + cart.summary.currency, + selectedPaymentMethod, + selectedProvider, + ]); + useEffect(() => { let cancelled = false; const controller = new AbortController(); @@ -414,6 +547,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { useEffect(() => { if (!shippingAvailable) { setCityOptions([]); + setCityLookupFailed(false); setCitiesLoading(false); return; } @@ -421,14 +555,18 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const query = cityQuery.trim(); if (query.length < 2) { setCityOptions([]); + setCityLookupFailed(false); setCitiesLoading(false); return; } let cancelled = false; const controller = new AbortController(); + const timeoutId = setTimeout(async () => { setCitiesLoading(true); + setCityLookupFailed(false); + try { const qs = new URLSearchParams({ q: query, @@ -437,31 +575,47 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ...(country ? { country } : {}), }); - const response = await fetch(`/api/shop/shipping/np/cities?${qs}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - cache: 'no-store', - signal: controller.signal, - }); + const response = await fetch( + `/api/shop/shipping/np/cities?${qs.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + } + ); const data = await response.json().catch(() => null); + const parsed = parseShippingCitiesResponse(data); - if (!response.ok || !data || data.available === false) { + if (!response.ok || parsed.available === false) { if (!cancelled) { - setCityOptions([]); + setCityLookupFailed(true); } return; } if (!cancelled) { - const next = Array.isArray(data.items) - ? (data.items as ShippingCity[]) - : []; - setCityOptions(next); + setCityLookupFailed(false); + const next = parsed.items; + const normalizedQuery = normalizeLookupValue(query); + + const exactMatches = next.filter( + city => normalizeLookupValue(city.nameUa) === normalizedQuery + ); + + if (exactMatches.length === 1) { + const exactCity = exactMatches[0]!; + setSelectedCityRef(exactCity.ref); + setSelectedCityName(exactCity.nameUa); + setCityOptions([]); + } else { + setCityOptions(next); + } } } catch { if (!cancelled) { - setCityOptions([]); + setCityLookupFailed(true); } } finally { if (!cancelled) { @@ -728,6 +882,16 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); return; } + if ( + selectedProvider === 'monobank' && + selectedPaymentMethod === 'monobank_google_pay' && + !canUseMonobankGooglePay + ) { + setCheckoutError( + t('checkout.paymentMethod.monobankGooglePayUnavailable') + ); + return; + } if (shippingMethodsLoading) { setCheckoutError(safeT('delivery.methodsLoading', 'METHODS_LOADING')); return; @@ -772,7 +936,12 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } const idempotencyKey = generateIdempotencyKey(); - + const checkoutPaymentMethod = + selectedProvider === 'stripe' ? 'stripe_card' : selectedPaymentMethod; + if (!checkoutPaymentMethod) { + setCheckoutError(t('checkout.paymentMethod.noAvailable')); + return; + } const response = await fetch('/api/shop/checkout', { method: 'POST', headers: { @@ -781,6 +950,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { }, body: JSON.stringify({ paymentProvider: selectedProvider, + paymentMethod: checkoutPaymentMethod, ...(shippingPayloadResult?.ok ? { shipping: shippingPayloadResult.shipping, @@ -826,6 +996,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0 ? data.pageUrl : null; + const statusToken: string | null = + typeof data.statusToken === 'string' && + data.statusToken.trim().length > 0 + ? data.statusToken + : null; const orderId = String(data.orderId); setCreatedOrderId(orderId); @@ -838,14 +1013,30 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); return; } - if (paymentProvider === 'monobank' && monobankPageUrl) { + + if (paymentProvider === 'monobank') { + if (checkoutPaymentMethod === 'monobank_google_pay') { + if (!statusToken) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } + + router.push( + `${shopBase}/checkout/payment/monobank/${encodeURIComponent( + orderId + )}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1` + ); + return; + } + + if (!monobankPageUrl) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } + window.location.assign(monobankPageUrl); return; } - if (paymentProvider === 'monobank' && !monobankPageUrl) { - setCheckoutError(t('checkout.errors.unexpectedResponse')); - return; - } const paymentsDisabledFlag = paymentProvider !== 'stripe' || !clientSecret @@ -866,8 +1057,16 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const shippingUnavailableText = resolveShippingUnavailableText(shippingReasonCode); + const hasValidPaymentSelection = + selectedProvider === 'stripe' + ? canUseStripe && selectedPaymentMethod === 'stripe_card' + : canUseMonobank && + (selectedPaymentMethod === 'monobank_invoice' || + (selectedPaymentMethod === 'monobank_google_pay' && + canUseMonobankGooglePay)); const canPlaceOrder = hasSelectableProvider && + hasValidPaymentSelection && !shippingMethodsLoading && !shippingUnavailableHardBlock && (!shippingAvailable || !!selectedShippingMethod); @@ -1195,9 +1394,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { name="delivery-method" value={method.methodCode} checked={selectedShippingMethod === method.methodCode} - onChange={() => - setSelectedShippingMethod(method.methodCode) - } + onChange={() => { + clearCheckoutUiErrors(); + setSelectedShippingMethod(method.methodCode); + }} className="h-4 w-4" /> @@ -1218,7 +1418,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { id="shipping-city-search" type="text" value={cityQuery} + autoComplete="off" + spellCheck={false} onChange={event => { + clearCheckoutUiErrors(); + setCityLookupFailed(false); setCityQuery(event.target.value); setSelectedCityRef(null); setSelectedCityName(null); @@ -1248,9 +1452,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { key={city.ref} type="button" onClick={() => { + clearCheckoutUiErrors(); setSelectedCityRef(city.ref); setSelectedCityName(city.nameUa); setCityQuery(city.nameUa); + setCityOptions([]); }} className="hover:bg-secondary block w-full rounded px-2 py-1 text-left text-xs" > @@ -1259,6 +1465,16 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ))} ) : null} + + {!citiesLoading && + !cityLookupFailed && + cityQuery.trim().length >= 2 && + !selectedCityRef && + cityOptions.length === 0 ? ( +

    + {t('delivery.city.noResults')} +

    + ) : null} {isWarehouseSelectionMethod ? ( @@ -1281,15 +1497,29 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { type="text" value={warehouseQuery} onChange={event => { + clearCheckoutUiErrors(); setWarehouseQuery(event.target.value); setSelectedWarehouseRef(null); setSelectedWarehouseName(null); }} - placeholder={t('delivery.warehouse.placeholder')} + placeholder={ + selectedCityRef + ? t('delivery.warehouse.placeholder') + : t('delivery.warehouse.selectCityFirst') + } className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" disabled={!selectedCityRef} /> + {!selectedCityRef ? ( +

    + {t('delivery.warehouse.cityRequired')} +

    + ) : null} + {selectedWarehouseRef ? (

    {t('delivery.warehouse.selected', { @@ -1312,6 +1542,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { key={warehouse.ref} type="button" onClick={() => { + clearCheckoutUiErrors(); setSelectedWarehouseRef(warehouse.ref); setSelectedWarehouseName(warehouse.name); setWarehouseQuery( @@ -1319,6 +1550,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ? `${warehouse.name} (${warehouse.address})` : warehouse.name ); + setWarehouseOptions([]); }} className="hover:bg-secondary block w-full rounded px-2 py-1 text-left text-xs" > @@ -1343,9 +1575,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { id="shipping-address-1" type="text" value={courierAddressLine1} - onChange={event => - setCourierAddressLine1(event.target.value) - } + onChange={event => { + clearCheckoutUiErrors(); + setCourierAddressLine1(event.target.value); + }} placeholder={t( 'delivery.courierAddress.line1Placeholder' )} @@ -1354,9 +1587,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { - setCourierAddressLine2(event.target.value) - } + onChange={event => { + clearCheckoutUiErrors(); + setCourierAddressLine2(event.target.value); + }} placeholder={t( 'delivery.courierAddress.line2Placeholder' )} @@ -1376,7 +1610,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { id="recipient-name" type="text" value={recipientName} - onChange={event => setRecipientName(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientName(event.target.value); + }} placeholder={t('delivery.recipientName.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1393,7 +1630,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { id="recipient-phone" type="tel" value={recipientPhone} - onChange={event => setRecipientPhone(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientPhone(event.target.value); + }} placeholder={t('delivery.recipientPhone.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1410,7 +1650,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { id="recipient-email" type="email" value={recipientEmail} - onChange={event => setRecipientEmail(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientEmail(event.target.value); + }} placeholder={t('delivery.recipientEmail.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1426,7 +1669,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {