From 0aa8e946fa48e6baa7632dfeab2b416f7df1324a Mon Sep 17 00:00:00 2001 From: kosyachniy Date: Wed, 24 Dec 2025 20:23:23 +0300 Subject: [PATCH] Custom Router --- web/src/app/[locale]/error.tsx | 2 +- .../posts/[categoryUrl]/PostActions.tsx | 7 +- .../posts/[categoryUrl]/PostDetailClient.tsx | 21 +++-- web/src/i18n/routing.ts | 86 +++++++++++++++++-- .../components/layout/ThemeAwareContent.tsx | 2 + .../components/layout/VkLaunchParamsSync.tsx | 27 ++++++ web/src/shared/components/layout/index.ts | 1 + web/src/shared/lib/vk.ts | 39 +++++++++ web/src/shared/ui/breadcrumb-description.tsx | 17 +++- web/src/shared/ui/card.tsx | 4 +- web/src/shared/ui/entity-management.tsx | 20 +++-- .../ui/CategoryPreview.tsx | 9 +- .../category/ui/CategoriesHoverPopup.tsx | 7 +- .../category/ui/CategoryBreadcrumbs.tsx | 18 +++- .../category/ui/SubcategoryNavigation.tsx | 11 ++- web/src/widgets/footer/ui/Footer.tsx | 2 +- .../post-management/ui/PostListItem.tsx | 5 ++ web/src/widgets/posts-list/ui/PostCard.tsx | 7 +- .../widgets/product-card/ui/ProductCard.tsx | 7 +- .../product-management/ui/ProductListItem.tsx | 5 ++ .../space-management/ui/SpaceListItem.tsx | 5 ++ 21 files changed, 260 insertions(+), 42 deletions(-) create mode 100644 web/src/shared/components/layout/VkLaunchParamsSync.tsx diff --git a/web/src/app/[locale]/error.tsx b/web/src/app/[locale]/error.tsx index 30e62028..351581ba 100644 --- a/web/src/app/[locale]/error.tsx +++ b/web/src/app/[locale]/error.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import { PageHeader } from '@/shared/ui/page-header'; diff --git a/web/src/app/[locale]/posts/[categoryUrl]/PostActions.tsx b/web/src/app/[locale]/posts/[categoryUrl]/PostActions.tsx index 1bdfa1a7..27fbc445 100644 --- a/web/src/app/[locale]/posts/[categoryUrl]/PostActions.tsx +++ b/web/src/app/[locale]/posts/[categoryUrl]/PostActions.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useTransition } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import { ButtonGroup } from '@/shared/ui/button-group'; @@ -20,12 +20,11 @@ import { interface PostActionsProps { post: Post; - locale: string; isEditing: boolean; onToggleEdit: () => void; } -export function PostActions({ post, locale, isEditing, onToggleEdit }: PostActionsProps) { +export function PostActions({ post, isEditing, onToggleEdit }: PostActionsProps) { const router = useRouter(); const tSystem = useTranslations('system'); const tPosts = useTranslations('posts'); @@ -67,7 +66,7 @@ export function PostActions({ post, locale, isEditing, onToggleEdit }: PostActio success(tSystem('deleted')); setDeleteOpen(false); startTransition(() => { - router.push(locale ? `/${locale}/posts` : '/posts'); + router.push('/posts'); router.refresh(); }); } catch (err) { diff --git a/web/src/app/[locale]/posts/[categoryUrl]/PostDetailClient.tsx b/web/src/app/[locale]/posts/[categoryUrl]/PostDetailClient.tsx index b5978950..de5abd50 100644 --- a/web/src/app/[locale]/posts/[categoryUrl]/PostDetailClient.tsx +++ b/web/src/app/[locale]/posts/[categoryUrl]/PostDetailClient.tsx @@ -2,8 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react'; import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import { PageHeader } from '@/shared/ui/page-header'; @@ -194,7 +193,10 @@ export function PostDetailClient({ }); success(tSystem('saved')); startTransition(() => { - router.push(locale ? `/${locale}/posts/${created.url}` : `/posts/${created.url}`); + router.push({ + pathname: '/posts/[categoryUrl]', + params: { categoryUrl: created.url }, + }); router.refresh(); }); return; @@ -224,7 +226,12 @@ export function PostDetailClient({ setEditing(false); }; - const categoryLink = post.category_data ? `/posts/${post.category_data.url}` : undefined; + const categoryLink = post.category_data + ? ({ + pathname: '/posts/[categoryUrl]', + params: { categoryUrl: post.category_data.url }, + } as const) + : undefined; const imageValue = image || post.image || ''; const imageFileData = imageValue && fileData ? { ...fileData, type: 'image' as const } @@ -236,7 +243,6 @@ export function PostDetailClient({ : ( { if (isEditing) { @@ -286,7 +292,10 @@ export function PostDetailClient({ {post.category_data && (
diff --git a/web/src/i18n/routing.ts b/web/src/i18n/routing.ts index 20e9d836..b4023ccf 100644 --- a/web/src/i18n/routing.ts +++ b/web/src/i18n/routing.ts @@ -1,5 +1,8 @@ +import type { ComponentProps } from 'react'; +import { createElement, useCallback, useMemo } from 'react'; import { defineRouting } from 'next-intl/routing'; import { createNavigation } from 'next-intl/navigation'; +import { appendVkLaunchParamsToUrl, getVkLaunchQuery } from '@/shared/lib/vk'; export type Locale = 'en' | 'ru' | 'zh' | 'es' | 'ar'; @@ -16,7 +19,7 @@ export const routing = defineRouting({ '/posts': '/posts', '/posts/[categoryUrl]': '/posts/[categoryUrl]', '/catalog/[id]': '/catalog/[id]', - '/space': '/space', + '/space': '/space', '/hub': '/hub', '/tasks': '/tasks', '/catalog': '/catalog', @@ -46,7 +49,80 @@ export const routing = defineRouting({ localeDetection: true }); -// Lightweight wrappers around Next.js' navigation APIs -// that will consider the routing configuration -export const { Link, redirect, usePathname, useRouter } = - createNavigation(routing); +const navigation = createNavigation(routing); + +export const { redirect, usePathname } = navigation; + +type AppRouterInstance = ReturnType; +export type RouteHref = Parameters[0]; +type RoutePath = Extract; +type HashHref = `${RoutePath}#${string}`; +type LinkHref = RouteHref | HashHref; + +const mergeVkQuery = (href: RouteHref, vkQuery: Record | null): RouteHref => { + if (!vkQuery || Object.keys(vkQuery).length === 0) { + return href; + } + + if (typeof href === 'string') { + return appendVkLaunchParamsToUrl(href, vkQuery) as RouteHref; + } + + return { + ...href, + query: { ...vkQuery, ...(href.query ?? {}) }, + }; +}; + +const mergeVkQueryForLink = (href: LinkHref, vkQuery: Record | null): LinkHref => { + if (!vkQuery || Object.keys(vkQuery).length === 0) { + return href; + } + + if (typeof href === 'string') { + return appendVkLaunchParamsToUrl(href, vkQuery) as LinkHref; + } + + return { + ...href, + query: { ...vkQuery, ...(href.query ?? {}) }, + }; +}; + +// VK Mini App navigation requires keeping launch params in the URL. +export const useRouter = () => { + const router = navigation.useRouter(); + const vkQuery = useMemo(() => getVkLaunchQuery(), []); + + const push = useCallback( + (href, options) => router.push(mergeVkQuery(href, vkQuery), options), + [router, vkQuery] + ); + + const replace = useCallback( + (href, options) => router.replace(mergeVkQuery(href, vkQuery), options), + [router, vkQuery] + ); + + return useMemo( + () => ({ + ...router, + push, + replace, + }), + [push, replace, router] + ); +}; + +type LinkProps = Omit, 'href'> & { + href: LinkHref; +}; + +export const Link = ({ href, ...props }: LinkProps) => { + const vkQuery = getVkLaunchQuery(); + const mergedHref = mergeVkQueryForLink(href, vkQuery) as ComponentProps['href']; + return createElement(navigation.Link, { + ...props, + href: mergedHref, + }); +}; diff --git a/web/src/shared/components/layout/ThemeAwareContent.tsx b/web/src/shared/components/layout/ThemeAwareContent.tsx index d151db74..a91d4cda 100644 --- a/web/src/shared/components/layout/ThemeAwareContent.tsx +++ b/web/src/shared/components/layout/ThemeAwareContent.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { useTheme } from '@/providers/ThemeProvider'; import LoadingScreen from './LoadingScreen'; import { Header, MobileBottomBar } from '@/widgets/header'; +import { VkLaunchParamsSync } from '@/shared/components/layout'; import { Footer } from '@/widgets/footer'; import { useAppDispatch, useAppSelector } from '@/shared/stores/store'; import { selectIsApp, setIsApp } from '@/shared/stores/layoutSlice'; @@ -30,6 +31,7 @@ export default function ThemeAwareContent({ children }: ThemeAwareContentProps) return ( + {shouldUseAppShell ? null :
}
{ + const vkQuery = getVkLaunchQuery(); + if (!vkQuery) return; + + const anchors = document.querySelectorAll('a[href]'); + anchors.forEach((anchor) => { + const href = anchor.getAttribute('href'); + if (!href) return; + + const updated = appendVkLaunchParamsToUrl(href, vkQuery); + if (updated !== href) { + anchor.setAttribute('href', updated); + } + }); + }, [pathname]); + + return null; +} diff --git a/web/src/shared/components/layout/index.ts b/web/src/shared/components/layout/index.ts index 3ea6e86d..aaea2abe 100644 --- a/web/src/shared/components/layout/index.ts +++ b/web/src/shared/components/layout/index.ts @@ -5,3 +5,4 @@ export { default as ThemeSwitcher } from './ThemeSwitcher'; export { default as ThemeAwareContent } from './ThemeAwareContent'; export { default as StructuredData } from './StructuredData'; export { default as VkBridgeInitializer } from './VkBridgeInitializer'; +export { default as VkLaunchParamsSync } from './VkLaunchParamsSync'; diff --git a/web/src/shared/lib/vk.ts b/web/src/shared/lib/vk.ts index 0c9df90e..04499044 100644 --- a/web/src/shared/lib/vk.ts +++ b/web/src/shared/lib/vk.ts @@ -49,6 +49,45 @@ const readStoredVkLaunchParams = () => { } }; +const shouldSkipHref = (href: string) => { + const lower = href.toLowerCase(); + return ( + lower.startsWith('mailto:') || + lower.startsWith('tel:') || + lower.startsWith('javascript:') || + lower.startsWith('#') + ); +}; + +export const appendVkLaunchParamsToUrl = ( + href: string, + vkQuery?: Record | null +) => { + if (!vkQuery || Object.keys(vkQuery).length === 0) return href; + if (typeof window === 'undefined') return href; + if (!href || shouldSkipHref(href)) return href; + + let url: URL; + try { + url = new URL(href, window.location.href); + } catch { + return href; + } + + if (url.origin !== window.location.origin) { + return href; + } + + Object.entries(vkQuery).forEach(([key, value]) => { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, value); + } + }); + + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ''}${url.hash || ''}`; +}; + export const getVkLaunchParams = () => { const params = getVkQueryParams(); if (isValidVkLaunchParams(params)) { diff --git a/web/src/shared/ui/breadcrumb-description.tsx b/web/src/shared/ui/breadcrumb-description.tsx index 34871b1d..be325976 100644 --- a/web/src/shared/ui/breadcrumb-description.tsx +++ b/web/src/shared/ui/breadcrumb-description.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import Link from 'next/link' +import { Link } from '@/i18n/routing' interface BreadcrumbItem { id: number @@ -15,13 +15,24 @@ interface BreadcrumbDescriptionProps { export function BreadcrumbDescription({ breadcrumbs }: BreadcrumbDescriptionProps) { if (breadcrumbs.length <= 1) return null + const resolveBreadcrumbHref = (url: string) => { + if (url === '/posts') { + return '/posts' as const + } + const slug = url.replace(/^\/posts\//, '') + return { + pathname: '/posts/[categoryUrl]', + params: { categoryUrl: slug } + } as const + } + return ( {breadcrumbs.slice(0, -1).map((breadcrumb, index) => ( {index > 0 && /} @@ -31,4 +42,4 @@ export function BreadcrumbDescription({ breadcrumbs }: BreadcrumbDescriptionProp ))} ) -} \ No newline at end of file +} diff --git a/web/src/shared/ui/card.tsx b/web/src/shared/ui/card.tsx index 944e821d..9754fa8e 100644 --- a/web/src/shared/ui/card.tsx +++ b/web/src/shared/ui/card.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useState } from 'react'; import Image from 'next/image'; -import Link from 'next/link'; +import { Link, type RouteHref } from '@/i18n/routing'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/shared/lib/utils'; @@ -221,7 +221,7 @@ interface CardProps extends VariantProps { images: string[]; // Navigation - href?: string; + href?: RouteHref; onClick?: () => void; // Filters above title (plain icon + value) diff --git a/web/src/shared/ui/entity-management.tsx b/web/src/shared/ui/entity-management.tsx index 22dae0c8..d607cf3b 100644 --- a/web/src/shared/ui/entity-management.tsx +++ b/web/src/shared/ui/entity-management.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link, type RouteHref } from '@/i18n/routing'; import { ReactNode } from 'react'; import { Alert, AlertDescription } from './alert'; import { Box } from './box'; @@ -79,6 +79,7 @@ interface EntityRowProps { id?: number | string; title: string; url?: string; + urlHref?: RouteHref; badges?: ReactNode[]; secondRowItems?: EntityRowMetaItem[]; leftSlot?: ReactNode; @@ -94,6 +95,7 @@ export function EntityRow({ id, title, url, + urlHref, badges = [], secondRowItems = [], leftSlot, @@ -113,12 +115,16 @@ export function EntityRow({ ) : null} {title} {url ? ( - - /{url} - + urlHref ? ( + + /{url} + + ) : ( + /{url} + ) ) : null} {badges.length > 0 ?
{badges}
: null}
diff --git a/web/src/widgets/category-management/ui/CategoryPreview.tsx b/web/src/widgets/category-management/ui/CategoryPreview.tsx index 87bed30a..53e6832f 100644 --- a/web/src/widgets/category-management/ui/CategoryPreview.tsx +++ b/web/src/widgets/category-management/ui/CategoryPreview.tsx @@ -125,6 +125,12 @@ export function CategoryPreview({ const hasSubcategories = category.categories && category.categories.length > 0; const paddingLeft = level * 24; // 24px per level for indentation + const categoryHref = category.url + ? ({ + pathname: '/posts/[categoryUrl]', + params: { categoryUrl: category.url }, + } as const) + : undefined; // No default right actions for preview mode const defaultRightActions = null; @@ -138,7 +144,8 @@ export function CategoryPreview({ {getStatusBadge(category.status ?? 1)} diff --git a/web/src/widgets/category/ui/CategoriesHoverPopup.tsx b/web/src/widgets/category/ui/CategoriesHoverPopup.tsx index 077061eb..5f532e12 100644 --- a/web/src/widgets/category/ui/CategoriesHoverPopup.tsx +++ b/web/src/widgets/category/ui/CategoriesHoverPopup.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import Link from 'next/link'; +import { Link } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import { Popover, PopoverContent, PopoverTrigger } from '@/shared/ui/popover'; import { Box } from '@/shared/ui/box'; @@ -109,7 +109,10 @@ export function CategoriesHoverPopup({ return ( {/* Category Icon - Just the icon without colored background */} diff --git a/web/src/widgets/category/ui/CategoryBreadcrumbs.tsx b/web/src/widgets/category/ui/CategoryBreadcrumbs.tsx index dcc8a31f..7e8329e9 100644 --- a/web/src/widgets/category/ui/CategoryBreadcrumbs.tsx +++ b/web/src/widgets/category/ui/CategoryBreadcrumbs.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import type { Category } from '@/entities/category'; import { buildBreadcrumbs, generateBreadcrumbStructuredData } from '@/entities/category'; @@ -12,7 +12,17 @@ interface CategoryBreadcrumbsProps { export function CategoryBreadcrumbs({ category, className = '' }: CategoryBreadcrumbsProps) { const t = useTranslations('navigation'); - + const resolveBreadcrumbHref = (url: string) => { + if (url === '/posts') { + return '/posts' as const; + } + const slug = url.replace(/^\/posts\//, ''); + return { + pathname: '/posts/[categoryUrl]', + params: { categoryUrl: slug }, + } as const; + }; + const breadcrumbs = buildBreadcrumbs(category || null, t('posts')); const structuredData = generateBreadcrumbStructuredData(breadcrumbs); @@ -50,7 +60,7 @@ export function CategoryBreadcrumbs({ category, className = '' }: CategoryBreadc // Link to parent pages <> ); -} \ No newline at end of file +} diff --git a/web/src/widgets/category/ui/SubcategoryNavigation.tsx b/web/src/widgets/category/ui/SubcategoryNavigation.tsx index 22e87aeb..e2bff6e6 100644 --- a/web/src/widgets/category/ui/SubcategoryNavigation.tsx +++ b/web/src/widgets/category/ui/SubcategoryNavigation.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/routing'; import { Button } from '@/shared/ui/button'; import type { Category } from '@/entities/category'; import { getActiveSubcategories } from '@/entities/category'; @@ -33,7 +33,12 @@ export function SubcategoryNavigation({ asChild className="hover:bg-primary hover:text-white dark:hover:text-white transition-colors" > - + {subcategory.title} @@ -56,4 +61,4 @@ export function SubcategoryNavigation({ })}
); -} \ No newline at end of file +} diff --git a/web/src/widgets/footer/ui/Footer.tsx b/web/src/widgets/footer/ui/Footer.tsx index 02c512de..3949db46 100644 --- a/web/src/widgets/footer/ui/Footer.tsx +++ b/web/src/widgets/footer/ui/Footer.tsx @@ -151,7 +151,7 @@ export function Footer() {