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