Claude güncellemesi Public sayfaları güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-10 16:53:24 +03:00
parent a42776e9c9
commit bbfa1cee0d
30 changed files with 3455 additions and 3646 deletions

View file

@ -17,9 +17,16 @@ using System.Text.Json;
using Volo.Abp.Identity;
using Sozsoft.Languages;
using Sozsoft.Languages.Entities;
using Microsoft.AspNetCore.Authorization;
using static Sozsoft.Platform.Data.Seeds.SeedConsts;
namespace Sozsoft.Platform.Public;
/// <summary>
/// Public site uc noktalari. Okuma metotlari anonim erisime aciktir; sayfa tasarimini
/// degistiren <c>Save*PageAsync</c> metotlari <see cref="AppCodes.WebSiteDesign"/>
/// yetkileriyle korunur (yetkiler PermissionsData.json icinde tanimli, seeder ile yonetilir).
/// </summary>
public class PublicAppService : PlatformAppService
{
private readonly IRepository<Service, Guid> _serviceRepository;
@ -96,6 +103,7 @@ public class PublicAppService : PlatformAppService
return ObjectMapper.Map<List<Service>, List<ServiceDto>>(entity);
}
[Authorize(AppCodes.WebSiteDesign.About)]
public async Task SaveAboutPageAsync(SaveAboutPageInput input)
{
var entity = await _aboutRepository.FirstOrDefaultAsync() ?? throw new EntityNotFoundException(typeof(About));
@ -148,6 +156,7 @@ public class PublicAppService : PlatformAppService
await _languageTextAppService.ClearRedisCacheAsync();
}
[Authorize(AppCodes.WebSiteDesign.Services)]
public async Task SaveServicesPageAsync(SaveServicesPageInput input)
{
var existingEntities = await _serviceRepository.GetListAsync();
@ -207,6 +216,7 @@ public class PublicAppService : PlatformAppService
return ObjectMapper.Map<Home, HomeDto>(entity);
}
[Authorize(AppCodes.WebSiteDesign.Home)]
public async Task SaveHomePageAsync(SaveHomePageInput input)
{
var entity = await _homeRepository.FirstOrDefaultAsync() ?? throw new EntityNotFoundException(typeof(Home));
@ -956,6 +966,7 @@ public class PublicAppService : PlatformAppService
.ToList();
}
[Authorize(AppCodes.WebSiteDesign.Contact)]
public async Task SaveContactPageAsync(SaveContactPageInput input)
{
var entity = await _contactRepository.FirstOrDefaultAsync() ?? throw new EntityNotFoundException(typeof(Contact));

View file

@ -1603,6 +1603,15 @@
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Home.Design",
"ParentName": "App.Home",
"DisplayName": "Design",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About",
@ -1612,6 +1621,15 @@
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About.Design",
"ParentName": "App.About",
"DisplayName": "Design",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services",
@ -1621,6 +1639,15 @@
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services.Design",
"ParentName": "App.Services",
"DisplayName": "Design",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Orders.Products",
@ -2080,6 +2107,15 @@
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Contact.Design",
"ParentName": "App.Contact",
"DisplayName": "Design",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Menus.Routes",

View file

@ -526,6 +526,18 @@ public static class PlatformConsts
public const string Demos = Prefix.App + ".Demos";
public const string Contact = Prefix.App + ".Contact";
/// <summary>
/// Public sitedeki sayfa tasarim modu (`?design=1`) yetkileri.
/// Yetkiler <c>Seeds/PermissionsData.json</c> icinde tanimlanir ve seeder ile yonetilir.
/// </summary>
public static class WebSiteDesign
{
public const string Home = AppCodes.Home + ".Design";
public const string About = AppCodes.About + ".Design";
public const string Services = AppCodes.Services + ".Design";
public const string Contact = AppCodes.Contact + ".Design";
}
//Administration
public const string Administration = Prefix.App + ".Administration";

View file

@ -443,6 +443,18 @@ public static class SeedConsts
public const string Demos = Prefix.App + ".Demos";
public const string Contact = Prefix.App + ".Contact";
/// <summary>
/// Public sitedeki sayfa tasarim modu (`?design=1`) yetkileri.
/// Yetkiler <c>Seeds/PermissionsData.json</c> icinde tanimlanir ve seeder ile yonetilir.
/// </summary>
public static class WebSiteDesign
{
public const string Home = AppCodes.Home + ".Design";
public const string About = AppCodes.About + ".Design";
public const string Services = AppCodes.Services + ".Design";
public const string Contact = AppCodes.Contact + ".Design";
}
public static class Menus
{
public const string Default = Prefix.App + ".Menus";

View file

@ -33,7 +33,7 @@ export const BillingControls: React.FC<BillingControlsProps> = ({
return (
<div
className={`sticky z-20 bg-white border-b border-gray-200 shadow-sm mb-6 transition-all duration-300 dark:border-gray-800 dark:bg-gray-900 dark:shadow-gray-950/40 ${
isScrolled ? 'top-[5.5rem]' : 'top-[6.5rem]'
isScrolled ? 'top-[6rem]' : 'top-[6.5rem]'
}`}
>
<div className="p-2">

View file

@ -3,3 +3,15 @@ export const AI_ASSISTANT = 'App.Definitions.AiBot.Asistant'
/** Veritabanı migration + seed tetikleme yetkisi (host tarafı). */
export const DB_MIGRATE = 'App.Setup.Migrate'
/**
* Public site sayfalarının tasarım modu yetkileri.
* Sunucuda `PermissionsData.json` içinde tanımlıdır ve seeder ile yönetilir;
* asıl kontrol `PublicAppService.Save*PageAsync` üzerindedir.
*/
export const PUBLIC_PAGE_DESIGN = {
HOME: 'App.Home.Design',
ABOUT: 'App.About.Design',
SERVICES: 'App.Services.Design',
CONTACT: 'App.Contact.Design',
} as const

File diff suppressed because it is too large Load diff

View file

@ -1,63 +1,90 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaCalendarAlt, FaUser, FaTag, FaSearch } from 'react-icons/fa'
import { Helmet } from 'react-helmet'
import { showDbDateAsIs } from '@/utils/dateUtils'
import { BlogCategory, BlogPost } from '@/proxy/blog/blog'
import { blogService } from '@/services/blog.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Helmet } from 'react-helmet'
import { Loading } from '@/components/shared'
import { APP_NAME } from '@/constants/app.constant'
import { Button } from '@/components/ui'
import { translateLabel } from './designer'
import { PublicPageLoader } from './shared'
const Blog = () => {
const PAGE_SIZE = 10
const PAGINATION_WINDOW = 2
const BLOG_HERO_IMAGE =
'https://images.pexels.com/photos/3183164/pexels-photo-3183164.jpeg?auto=compress&cs=tinysrgb&w=1920'
function buildPageNumbers(currentPage: number, totalPages: number) {
const pages: number[] = []
for (let page = 1; page <= totalPages; page += 1) {
if (
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= PAGINATION_WINDOW
) {
pages.push(page)
}
}
return pages
}
const Blog: React.FC = () => {
const { translate } = useLocalization()
const [posts, setPosts] = useState<BlogPost[]>([])
const [categories, setCategories] = useState<BlogCategory[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [selectedCategory, setSelectedCategory] = useState('')
const [searchQuery, setSearchQuery] = useState('')
// Arama yalnizca form gonderildiginde uygulanir; her tusa basista istek atilmaz.
const [appliedSearch, setAppliedSearch] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
useEffect(() => {
loadBlogData()
}, [currentPage, selectedCategory])
const loadBlogData = useCallback(async () => {
setLoading(true)
const loadBlogData = async () => {
try {
setLoading(true)
const postsData = await blogService.getPosts({
const result = await blogService.getPosts({
page: currentPage,
pageSize: 10,
pageSize: PAGE_SIZE,
categoryId: selectedCategory,
search: searchQuery,
search: appliedSearch,
})
if (
postsData.posts &&
postsData.posts.items &&
postsData.posts.totalCount &&
postsData.categories
) {
setPosts(postsData.posts.items.filter((a) => a.isPublished))
setTotalPages(postsData.posts.totalCount / 10)
setCategories(postsData.categories.filter((a) => a.isActive))
}
// Sonuc bos donse de liste sifirlanmali; aksi halde onceki sayfanin yazilari ekranda kalir.
setPosts((result?.posts?.items ?? []).filter((post) => post.isPublished))
setTotalCount(result?.posts?.totalCount ?? 0)
setCategories((result?.categories ?? []).filter((category) => category.isActive))
} catch (error) {
console.error('Blog verileri yüklenemedi:', error)
console.error('Blog verileri yuklenemedi:', error)
setPosts([])
setTotalCount(0)
} finally {
setLoading(false)
}
}
}, [appliedSearch, currentPage, selectedCategory])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setCurrentPage(1)
useEffect(() => {
loadBlogData()
}, [loadBlogData])
// Kesirli sayfa sayisi `Array(totalPages)` cagrisini patlatiyordu; yukari yuvarlanir.
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const pageNumbers = useMemo(
() => buildPageNumbers(currentPage, totalPages),
[currentPage, totalPages],
)
const handleSearch = (event: React.FormEvent) => {
event.preventDefault()
setCurrentPage(1)
setAppliedSearch(searchQuery.trim())
}
const handleCategoryChange = (categoryId: string) => {
@ -65,147 +92,137 @@ const Blog = () => {
setCurrentPage(1)
}
const categoryButtonClass = (isActive: boolean) =>
`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive
? 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-900 dark:hover:bg-blue-800'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
}`
if (loading && posts.length === 0) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<Loading loading={loading} />
</div>
</div>
)
return <PublicPageLoader />
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + 'App.BlogManagement')}
title={translate('::App.BlogManagement')}
defaultTitle={APP_NAME}
></Helmet>
{/* Hero Section */}
<div className="relative bg-blue-900 text-white py-12 dark:bg-gray-950">
/>
{/* Hero */}
<div className="relative bg-blue-900 py-12 text-white dark:bg-gray-950">
<div
className="absolute inset-0 opacity-20 dark:opacity-35"
style={{
backgroundImage:
'url("https://images.pexels.com/photos/3183164/pexels-photo-3183164.jpeg?auto=compress&cs=tinysrgb&w=1920")',
backgroundImage: `url("${BLOG_HERO_IMAGE}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
></div>
<div className="absolute inset-0 bg-blue-950/25 dark:bg-gray-950/45"></div>
<div className="container mx-auto pt-20 relative">
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">
/>
<div className="absolute inset-0 bg-blue-950/25 dark:bg-gray-950/45" />
<div className="container relative mx-auto pt-20">
<h1 className="mb-2 ml-4 mt-3 text-5xl font-bold text-white">
{translate('::App.BlogManagement')}
</h1>
<p className="text-xl max-w-3xl ml-4">{translate('::Public.blog.subtitle')}</p>
<p className="ml-4 max-w-3xl text-xl">{translate('::Public.blog.subtitle')}</p>
</div>
</div>
{/* Blog Posts Grid */}
<div className="container mx-auto px-4 py-6">
{/* Arama ve kategori filtresi */}
<div className="shadow-sm">
<div className="container mx-auto pb-4">
<div className="flex flex-col md:flex-row">
<form className="flex-1" onSubmit={handleSearch}>
<div className="relative">
<input
type="search"
value={searchQuery}
placeholder={translateLabel(
translate,
'Public.blog.searchPlaceholder',
'Blog yazilarinda ara...',
)}
aria-label={translate('::App.Reports.Search')}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
onChange={(event) => setSearchQuery(event.target.value)}
/>
<FaSearch className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
</div>
</form>
{/* Search and Filter Section */}
<div className="shadow-sm border-b border-gray-200 dark:border-gray-800">
<div className="container mx-auto pb-4">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Blog yazılarında ara..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
/>
<FaSearch className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category.id}
type="button"
onClick={() => handleCategoryChange(category.id)}
>
{translate('::Public.' + category.name)} ({category.postCount})
</Button>
))}
</div>
</form>
{/* Category Filter */}
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => handleCategoryChange('')}
className={`px-4 py-2 bg-blue-100 text-blue-800 text-sm font-medium rounded-lg transition-colors ${
selectedCategory === ''
? 'bg-blue-600 text-black'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
}`}
>
{translate('::App.Reports.Search')}
</Button>
{categories.map((category) => (
<Button
key={category.id}
onClick={() => handleCategoryChange(category.id)}
className={`px-4 py-2 bg-blue-100 text-blue-800 text-sm font-medium rounded-lg transition-colors ${
selectedCategory === category.id
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
}`}
>
{translate('::Public.' + category.name)} ({category.postCount})
</Button>
))}
</div>
</div>
</div>
</div>
{!Array.isArray(posts) || posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg dark:text-gray-300">Henüz blog yazısı bulunmuyor.</p>
{posts.length === 0 ? (
<div className="py-12 text-center">
<p className="text-lg text-gray-600 dark:text-gray-300">
{translateLabel(translate, 'Public.blog.empty', 'Henuz blog yazisi bulunmuyor.')}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link to={`/blog/${post.slug || post.id}`} key={post.id} className="block">
<article className="bg-white rounded-xl shadow-lg overflow-hidden hover:shadow-xl transition-shadow h-full flex flex-col dark:bg-gray-900 dark:shadow-gray-950/40">
<div className="aspect-w-16 aspect-h-9 relative">
<img
src={post.coverImage}
alt={post.title}
className="object-cover w-full h-48"
/>
<div className="absolute top-4 right-4 bg-blue-600 text-white px-3 py-1 rounded-full text-sm dark:bg-blue-900">
<Link key={post.id} to={`/blog/${post.slug || post.id}`} className="block">
<article className="flex h-full flex-col overflow-hidden rounded-xl bg-white shadow-lg transition-shadow hover:shadow-xl dark:bg-gray-900 dark:shadow-gray-950/40">
<div className="relative">
{post.coverImage && (
<img
src={post.coverImage}
alt={post.title}
loading="lazy"
className="h-48 w-full object-cover"
/>
)}
<div className="absolute right-4 top-4 rounded-full bg-blue-600 px-3 py-1 text-sm text-white dark:bg-blue-900">
{translate('::Public.' + post.category.name)}
</div>
</div>
<div className="p-6 flex-1 flex flex-col">
<h2 className="text-xl font-bold text-gray-900 mb-3 hover:text-blue-600 transition-colors dark:text-gray-100 dark:hover:text-blue-400">
<div className="flex flex-1 flex-col p-6">
<h2 className="mb-3 text-xl font-bold text-gray-900 transition-colors hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-400">
{translate('::Public.' + post.title)}
</h2>
<p className="text-gray-600 mb-4 flex-1 dark:text-gray-300">
<p className="mb-4 flex-1 text-gray-600 dark:text-gray-300">
{translate('::Public.' + post.summary)}
</p>
{/* Tags */}
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.slice(0, 3).map((tag, index) => (
<div className="mb-4 flex flex-wrap gap-2">
{post.tags.slice(0, 3).map((tag) => (
<span
key={index}
className="inline-flex items-center text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded dark:bg-gray-800 dark:text-gray-300"
key={tag}
className="inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<FaTag className="w-3 h-3 mr-1" />
<FaTag className="mr-1 h-3 w-3" />
{tag}
</span>
))}
</div>
)}
<div className="flex items-center text-sm text-gray-500 space-x-4 dark:text-gray-400">
<div className="flex items-center">
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center">
<FaUser size={16} className="mr-1" />
{post.author}
</div>
<div className="flex items-center">
</span>
<span className="flex items-center">
<FaCalendarAlt size={16} className="mr-1" />
{showDbDateAsIs(post.publishedAt || post.creationTime)}
</div>
</span>
</div>
</div>
</article>
@ -214,62 +231,65 @@ const Blog = () => {
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-12 flex justify-center">
<nav className="flex gap-2">
<Button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
Önceki
</Button>
<nav className="mt-12 flex justify-center gap-2">
<Button
type="button"
disabled={currentPage === 1}
className="rounded-lg border border-gray-300 px-4 py-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
>
{translateLabel(translate, 'Public.common.previous', 'Onceki')}
</Button>
{[...Array(totalPages)].map((_, i) => (
{pageNumbers.map((page, index) => (
<React.Fragment key={page}>
{index > 0 && page - pageNumbers[index - 1] > 1 && (
<span className="px-2 py-2 text-gray-400"></span>
)}
<Button
key={i + 1}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg ${
currentPage === i + 1
type="button"
className={`rounded-lg px-4 py-2 ${
currentPage === page
? 'bg-blue-600 text-white'
: 'border border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800'
}`}
onClick={() => setCurrentPage(page)}
>
{i + 1}
{page}
</Button>
))}
</React.Fragment>
))}
<Button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
Sonraki
</Button>
</nav>
</div>
<Button
type="button"
disabled={currentPage === totalPages}
className="rounded-lg border border-gray-300 px-4 py-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
>
{translateLabel(translate, 'Public.common.next', 'Sonraki')}
</Button>
</nav>
)}
</div>
{/* Newsletter Section */}
{/* Newsletter */}
<div className="bg-white py-16 dark:bg-gray-900">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-4 dark:text-gray-100">
<h2 className="mb-4 text-3xl font-bold text-gray-900 dark:text-gray-100">
{translate('::Public.blog.subscribe')}
</h2>
<p className="text-gray-600 mb-8 max-w-2xl mx-auto dark:text-gray-300">
<p className="mx-auto mb-8 max-w-2xl text-gray-600 dark:text-gray-300">
{translate('::Public.blog.subscribe.desc')}
</p>
<div className="max-w-md mx-auto flex gap-4">
<div className="mx-auto flex max-w-md gap-4">
<input
type="email"
placeholder={translate('::Abp.Account.EmailAddress')}
className="flex-1 px-2 py-1 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
aria-label={translate('::Abp.Account.EmailAddress')}
className="flex-1 rounded-lg border border-gray-300 bg-white px-2 py-1 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
/>
<Button variant='solid'>
{translate('::Public.common.subscribe')}
</Button>
<Button variant="solid">{translate('::Public.common.subscribe')}</Button>
</div>
</div>
</div>

View file

@ -1,19 +1,21 @@
import React, { useState, useEffect } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Helmet } from 'react-helmet'
import { showDbDateAsIs } from '@/utils/dateUtils'
import { BlogPost } from '@/proxy/blog/blog'
import { blogService } from '@/services/blog.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { Helmet } from 'react-helmet'
import { Loading } from '@/components/shared'
import { APP_NAME } from '@/constants/app.constant'
import { translateLabel } from './designer'
import { PublicPageLoader } from './shared'
interface PostData {
image?: string
author?: string
}
const CenteredMessage: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{children}</h1>
</div>
)
const BlogDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
@ -21,7 +23,6 @@ const BlogDetail: React.FC = () => {
const { currentLang } = useStoreState((state) => state.locale)
const [blogPost, setBlogPost] = useState<BlogPost | null>(null)
const [postData, setPostData] = useState<PostData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -29,94 +30,88 @@ const BlogDetail: React.FC = () => {
const fetchBlogPost = async () => {
setLoading(true)
setError(null)
if (!id) {
setError(translateLabel(translate, 'Public.blog.notFound', 'Blog yazisi bulunamadi.'))
setLoading(false)
return
}
try {
if (id) {
const response = await blogService.getPostBySlug(id)
setBlogPost(response)
setPostData({
image: response.coverImage,
author: response.author,
})
} else {
setError('Blog post ID is missing.')
}
} catch (error: any) {
setError(error.message || 'Failed to fetch blog post.')
setBlogPost(await blogService.getPostBySlug(id))
} catch (fetchError) {
console.error('Blog yazisi alinamadi:', fetchError)
setError(translateLabel(translate, 'Public.blog.notFound', 'Blog yazisi bulunamadi.'))
} finally {
setLoading(false)
}
}
fetchBlogPost()
// `translate` yalnizca hata metni icin kullanilir; dil degisiminde yeniden istek atilmaz.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
const title = blogPost ? translate('::Public.' + blogPost.title) : ''
// Icerik anahtari yoksa `'::' + undefined` cevirisi "undefined" basiyordu.
const content = useMemo(() => {
if (!blogPost) {
return ''
}
const contentKey = currentLang === 'tr' ? blogPost.contentTr : blogPost.contentEn
return contentKey ? translate('::' + contentKey) : ''
}, [blogPost, currentLang, translate])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<Loading loading={loading} />
</div>
</div>
)
return <PublicPageLoader />
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Error: {error}</h1>
</div>
)
return <CenteredMessage>{error}</CenteredMessage>
}
if (!blogPost || !postData) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{translate('::Public.blog.notFound')}</h1>
</div>
)
if (!blogPost) {
return <CenteredMessage>{translate('::Public.blog.notFound')}</CenteredMessage>
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + 'App.BlogManagement')}
title={title || translate('::App.BlogManagement')}
defaultTitle={APP_NAME}
></Helmet>
<div className="relative bg-blue-900 text-white py-12"></div>
/>
<div className="relative bg-blue-900 py-12 text-white dark:bg-gray-950" />
<div className="container mx-auto px-4">
<Link
to={ROUTES_ENUM.public.blog}
className="text-blue-600 hover:underline mt-4 mb-4 inline-block dark:text-blue-400"
className="mb-4 mt-4 inline-block text-blue-600 hover:underline dark:text-blue-400"
>
&larr; {translate('::App.BlogManagement')}
</Link>
{postData.image && (
{blogPost.coverImage && (
<img
src={postData.image}
alt={translate('::Public.' + blogPost.title)}
className="w-full h-96 object-cover rounded-lg mb-8"
src={blogPost.coverImage}
alt={title}
className="mb-8 h-96 w-full rounded-lg object-cover"
/>
)}
<h1 className="text-4xl font-bold text-gray-900 mb-6 dark:text-gray-100">
{translate('::Public.' + blogPost.title)}
</h1>
<div className="flex items-center text-sm text-gray-500 space-x-4 mb-8 dark:text-gray-400">
<div className="flex items-center">
<span>{postData.author}</span>
</div>
<div className="flex items-center">
{blogPost.publishedAt && showDbDateAsIs(blogPost.publishedAt)}
</div>
<h1 className="mb-6 text-4xl font-bold text-gray-900 dark:text-gray-100">{title}</h1>
<div className="mb-8 flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<span>{blogPost.author}</span>
<span>{showDbDateAsIs(blogPost.publishedAt || blogPost.creationTime)}</span>
</div>
<div
className="prose max-w-none text-gray-800 dark:prose-invert dark:text-gray-200"
dangerouslySetInnerHTML={{
__html:
currentLang == 'tr'
? translate('::' + blogPost.contentTr!)
: translate('::' + blogPost.contentEn!),
}}
className="prose max-w-none pb-12 text-gray-800 dark:prose-invert dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>

View file

@ -64,15 +64,7 @@ const Checkout: React.FC = () => {
></Helmet>
{/* Hero Section */}
<div className="relative bg-blue-900 text-white py-12">
<div
className="absolute inset-0 opacity-20"
style={{
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
></div>
</div>
<div className="relative bg-blue-900 py-12 text-white dark:bg-gray-950" />
<div className="container mx-auto px-4 pt-12">
<TenantForm onSubmit={handleTenantSubmit} onBack={handleBackToCatalog} />

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from 'react'
import {
FaBuilding,
FaUser,
@ -8,176 +8,231 @@ import {
FaUsers,
FaRegComment,
FaPaperPlane,
FaCheckCircle
} from 'react-icons/fa';
import { useLocalization } from "@/utils/hooks/useLocalization";
import { createDemoAsync } from "@/services/demo.service";
import { DemoDto } from "@/proxy/demo/models";
import { Button } from "@/components/ui";
FaCheckCircle,
} from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { createDemoAsync } from '@/services/demo.service'
import { DemoDto } from '@/proxy/demo/models'
import { Button, Notification, toast } from '@/components/ui'
import { translateLabel } from './designer'
interface DemoModalProps {
isOpen: boolean;
onClose: () => void;
isOpen: boolean
onClose: () => void
}
type DemoErrors = Partial<Record<keyof DemoDto, string>>
const NUMERIC_FIELDS: Array<keyof DemoDto> = ['numberOfBranches', 'numberOfUsers']
function createEmptyForm(): DemoDto {
return {
id: crypto.randomUUID(),
organizationName: '',
name: '',
email: '',
phoneNumber: '',
address: '',
numberOfBranches: 0,
numberOfUsers: 0,
message: '',
}
}
const inputClass = (hasError: boolean) =>
`w-full rounded-xl border bg-gray-50 py-2.5 pl-11 pr-4 text-gray-800 placeholder-gray-500 transition-all duration-300 focus:outline-none dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400 ${
hasError
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700'
}`
interface FieldProps {
label: string
error?: string
icon: React.ReactNode
children: React.ReactNode
}
const Field: React.FC<FieldProps> = ({ label, error, icon, children }) => (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-200">
{label} *
</label>
<div className="relative">
<span className="absolute left-3 top-3 h-5 w-5 text-gray-400">{icon}</span>
{children}
</div>
{error && <p className="mt-1 text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
)
const Demo: React.FC<DemoModalProps> = ({ isOpen, onClose }) => {
const { translate } = useLocalization()
const [formData, setFormData] = useState<DemoDto>({
id: crypto.randomUUID(),
organizationName: "",
name: "",
email: "",
phoneNumber: "",
address: "",
numberOfBranches: 0,
numberOfUsers: 0,
message: "",
});
const [errors, setErrors] = useState<Partial<DemoDto>>({});
const [isSubmitted, setIsSubmitted] = useState(false);
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
if (errors[name as keyof DemoDto]) {
setErrors((prev) => ({
...prev,
[name]: "",
}));
}
};
const validateForm = (): boolean => {
const newErrors: Partial<DemoDto> = {};
if (!formData.organizationName.trim())
newErrors.organizationName = "Organization name is required";
if (!formData.name.trim()) newErrors.name = "Full name is required";
if (!formData.email.trim()) {
newErrors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "Please enter a valid email";
}
if (!formData.phoneNumber.trim()) newErrors.phoneNumber = "Phone number is required";
if (!formData.address.trim()) newErrors.address = "Address is required";
if (!formData.message.trim()) newErrors.message = "Message is required";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
await createDemoAsync(formData);
setIsSubmitted(true);
onClose(); // modal'ı otomatik kapat
} catch (error) {
console.error("Gönderim hatası:", error);
alert("Sunucuya ulaşılamıyor.");
}
};
const [formData, setFormData] = useState<DemoDto>(createEmptyForm)
const [errors, setErrors] = useState<DemoErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const firstFieldRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
if (isOpen) {
window.addEventListener("keydown", handleKeyDown);
if (!isOpen) {
return
}
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
firstFieldRef.current?.focus()
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
const requiredMessage = translateLabel(
translate,
'App.Validation.Required',
'Bu alan zorunludur.',
)
const handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = event.target
const key = name as keyof DemoDto
setFormData((previous) => ({
...previous,
// Sayisal alanlar string olarak saklanirsa sunucu tarafinda tip hatasi olusuyor.
[key]: NUMERIC_FIELDS.includes(key) ? Number(value) || 0 : value,
}))
setErrors((previous) => (previous[key] ? { ...previous, [key]: undefined } : previous))
}
const validateForm = () => {
const nextErrors: DemoErrors = {}
if (!formData.organizationName.trim()) nextErrors.organizationName = requiredMessage
if (!formData.name.trim()) nextErrors.name = requiredMessage
if (!formData.email.trim()) {
nextErrors.email = requiredMessage
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
nextErrors.email = translateLabel(
translate,
'App.Validation.InvalidEmail',
'Gecerli bir e-posta adresi girin.',
)
}
if (!formData.phoneNumber.trim()) nextErrors.phoneNumber = requiredMessage
if (!formData.address.trim()) nextErrors.address = requiredMessage
if (!formData.message.trim()) nextErrors.message = requiredMessage
setErrors(nextErrors)
return Object.keys(nextErrors).length === 0
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (isSubmitting || !validateForm()) {
return
}
setIsSubmitting(true)
try {
await createDemoAsync(formData)
setIsSubmitted(true)
} catch (error) {
console.error('Demo talebi gonderilemedi:', error)
toast.push(
<Notification type="danger" duration={4000}>
{translateLabel(translate, 'App.Errors.ServerUnreachable', 'Sunucuya ulasilamiyor.')}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsSubmitting(false)
}
}
const resetForm = () => {
setIsSubmitted(false)
setErrors({})
setFormData(createEmptyForm())
}
if (!isOpen && !isSubmitted) {
return null
}
// 🎉 Gönderim sonrası teşekkür ekranı
if (isSubmitted) {
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto py-5 z-50 dark:bg-gray-950/70">
<div className="mx-auto bg-white rounded-xl shadow-lg max-w-md w-full relative dark:bg-gray-900">
<div className="max-w-md w-full bg-gradient-to-br from-blue-50 to-indigo-100 rounded-3xl p-8 text-center border border-blue-200 shadow-xl dark:from-gray-900 dark:to-gray-800 dark:border-gray-700">
{/* Kapat Butonu */}
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 overflow-y-auto bg-gray-600/50 py-5 dark:bg-gray-950/70"
>
<div className="relative mx-auto w-full max-w-md rounded-xl bg-white shadow-lg dark:bg-gray-900">
<div className="w-full rounded-3xl border border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-100 p-8 text-center shadow-xl dark:border-gray-700 dark:from-gray-900 dark:to-gray-800">
<Button
onClick={() => setIsSubmitted(false)}
variant="plain"
shape="circle"
className="absolute top-4 right-4 !h-8 !w-8 !px-0 text-2xl text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100"
aria-label={translate('::Close')}
className="absolute right-4 top-4 !h-8 !w-8 !px-0 text-2xl text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100"
onClick={() => {
resetForm()
onClose()
}}
>
&times;
</Button>
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-6">
<FaCheckCircle className="w-8 h-8 text-white" />
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500">
<FaCheckCircle className="h-8 w-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-4 dark:text-gray-100">
<h2 className="mb-4 text-2xl font-bold text-gray-800 dark:text-gray-100">
{translate('::Public.demo.thankYou')}
</h2>
<p className="text-gray-600 mb-6 dark:text-gray-300">
<p className="mb-6 text-gray-600 dark:text-gray-300">
{translate('::Public.demo.resultMessage')}
</p>
<Button
onClick={() => {
setIsSubmitted(false);
setFormData({
id: crypto.randomUUID(),
organizationName: "",
name: "",
email: "",
phoneNumber: "",
address: "",
numberOfBranches: 0,
numberOfUsers: 0,
message: "",
});
}}
variant="solid"
>
<Button variant="solid" onClick={resetForm}>
{translate('::Public.demo.newDemo')}
</Button>
</div>
</div>
</div>
);
)
}
// Modal kapalıysa render etme
if (!isOpen) return null;
// 🎯 Normal form ekranı
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto py-5 z-50 dark:bg-gray-950/70">
<div className="mx-auto bg-white rounded-xl shadow-lg max-w-2xl w-full relative dark:bg-gray-900">
<div
role="dialog"
aria-modal="true"
aria-label={translate('::Public.demo.title')}
className="fixed inset-0 z-50 overflow-y-auto bg-gray-600/50 py-5 dark:bg-gray-950/70"
>
<div className="relative mx-auto w-full max-w-2xl rounded-xl bg-white shadow-lg dark:bg-gray-900">
<Button
onClick={onClose}
title="Kapat"
aria-label="Kapat"
aria-label={translate('::Close')}
variant="default"
shape="circle"
size="lg"
className="absolute top-4 right-4 !h-8 !w-8 !px-0 bg-gray-900 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100"
className="absolute right-4 top-4 z-10 !h-8 !w-8 !px-0"
onClick={onClose}
>
&times;
</Button>
<form
noValidate
className="rounded-3xl border border-gray-100 bg-white p-6 shadow-xl dark:border-gray-700 dark:bg-gray-900 lg:p-8"
onSubmit={handleSubmit}
className="bg-white rounded-3xl p-6 lg:p-8 shadow-xl border border-gray-100 dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-6 pr-10">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
@ -189,190 +244,137 @@ const Demo: React.FC<DemoModalProps> = ({ isOpen, onClose }) => {
</div>
<div className="space-y-3">
{/* Organization Name */}
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Public.common.company')} *
</label>
<div className="relative">
<FaBuilding className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Field
label={translate('::Public.common.company')}
error={errors.organizationName}
icon={<FaBuilding className="h-5 w-5" />}
>
<input
ref={firstFieldRef}
type="text"
name="organizationName"
value={formData.organizationName}
className={inputClass(Boolean(errors.organizationName))}
placeholder={translate('::Public.demo.organizationName')}
onChange={handleInputChange}
/>
</Field>
<Field
label={translate('::Public.common.fullName')}
error={errors.name}
icon={<FaUser className="h-5 w-5" />}
>
<input
type="text"
name="name"
value={formData.name}
className={inputClass(Boolean(errors.name))}
placeholder={translate('::Public.demo.fullName')}
onChange={handleInputChange}
/>
</Field>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field
label={translate('::Abp.Account.EmailAddress')}
error={errors.email}
icon={<FaEnvelope className="h-5 w-5" />}
>
<input
type="text"
autoFocus
name="organizationName"
value={formData.organizationName}
type="email"
name="email"
value={formData.email}
className={inputClass(Boolean(errors.email))}
placeholder={translate('::Public.demo.email')}
onChange={handleInputChange}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.organizationName
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.organizationName')}
/>
</div>
</div>
</Field>
{/* Full Name */}
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Public.common.fullName')} *
</label>
<div className="relative">
<FaUser className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Field
label={translate('::Abp.Identity.User.UserInformation.PhoneNumber')}
error={errors.phoneNumber}
icon={<FaPhone className="h-5 w-5" />}
>
<input
type="text"
name="fullName"
value={formData.name}
type="tel"
name="phoneNumber"
value={formData.phoneNumber}
className={inputClass(Boolean(errors.phoneNumber))}
placeholder={translate('::Public.demo.phoneNumber')}
onChange={handleInputChange}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.name
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.fullName')}
/>
</div>
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Abp.Account.EmailAddress')} *
</label>
<div className="relative">
<FaEnvelope className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.email
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.email')}
/>
</div>
</div>
<Field
label={translate('::App.Address')}
error={errors.address}
icon={<FaMapPin className="h-5 w-5" />}
>
<textarea
name="address"
rows={3}
value={formData.address}
className={inputClass(Boolean(errors.address))}
placeholder={translate('::Public.demo.address')}
onChange={handleInputChange}
/>
</Field>
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Abp.Identity.User.UserInformation.PhoneNumber')} *
</label>
<div className="relative">
<FaPhone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="tel"
name="phoneNumber"
value={formData.phoneNumber}
onChange={handleInputChange}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.phoneNumber
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.phoneNumber')}
/>
</div>
</div>
</div>
{/* Address */}
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::App.Address')} *
</label>
<div className="relative">
<FaMapPin className="absolute left-3 top-3 w-5 h-5 text-gray-400" />
<textarea
name="address"
value={formData.address}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field
label={translate('::Public.common.branchCount')}
error={errors.numberOfBranches}
icon={<FaBuilding className="h-5 w-5" />}
>
<input
type="number"
min={0}
name="numberOfBranches"
value={formData.numberOfBranches}
className={inputClass(Boolean(errors.numberOfBranches))}
placeholder={translate('::Public.demo.branches')}
onChange={handleInputChange}
rows={3}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.address
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.address')}
/>
</div>
</div>
</Field>
{/* Branches & Users Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Public.common.branchCount')} *
</label>
<div className="relative">
<FaBuilding className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="number"
name="numberOfBranches"
value={formData.numberOfBranches}
onChange={handleInputChange}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.numberOfBranches
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.branches')}
min="1"
/>
</div>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Public.common.userCount')} *
</label>
<div className="relative">
<FaUsers className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="number"
name="numberOfUsers"
value={formData.numberOfUsers}
onChange={handleInputChange}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.numberOfUsers
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.users')}
min="1"
/>
</div>
</div>
</div>
{/* Message */}
<div>
<label className="block text-gray-700 text-sm font-medium mb-2 dark:text-gray-200">
{translate('::Public.common.message')} *
</label>
<div className="relative">
<FaRegComment className="absolute left-3 top-3 w-5 h-5 text-gray-400" />
<textarea
name="message"
value={formData.message}
<Field
label={translate('::Public.common.userCount')}
error={errors.numberOfUsers}
icon={<FaUsers className="h-5 w-5" />}
>
<input
type="number"
min={0}
name="numberOfUsers"
value={formData.numberOfUsers}
className={inputClass(Boolean(errors.numberOfUsers))}
placeholder={translate('::Public.demo.users')}
onChange={handleInputChange}
rows={3}
className={`w-full pl-11 pr-4 py-2.5 bg-gray-50 border ${
errors.message
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-700"
} rounded-xl text-gray-800 placeholder-gray-500 focus:outline-none transition-all duration-300 resize-none dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400`}
placeholder={translate('::Public.demo.message')}
/>
</div>
</Field>
</div>
{/* Submit Button */}
<Field
label={translate('::Public.common.message')}
error={errors.message}
icon={<FaRegComment className="h-5 w-5" />}
>
<textarea
name="message"
rows={3}
value={formData.message}
className={`${inputClass(Boolean(errors.message))} resize-none`}
placeholder={translate('::Public.demo.message')}
onChange={handleInputChange}
/>
</Field>
<Button
type="submit"
variant="plain"
loading={isSubmitting}
icon={<FaPaperPlane className="h-5 w-5" />}
className="w-full !h-auto !justify-center !rounded-xl bg-gradient-to-r from-blue-600 to-purple-600 !px-6 !py-4 font-semibold text-white transition-all duration-300 hover:scale-[1.02] hover:from-blue-700 hover:to-purple-700 hover:shadow-lg"
>
@ -382,7 +384,7 @@ const Demo: React.FC<DemoModalProps> = ({ isOpen, onClose }) => {
</form>
</div>
</div>
);
};
)
}
export default Demo;
export default Demo

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,28 @@
import React from 'react';
import { FaFrown } from 'react-icons/fa';
import { ROUTES_ENUM } from '@/routes/route.constant';
import { useLocalization } from '@/utils/hooks/useLocalization';
import React from 'react'
import { Link } from 'react-router-dom'
import { FaFrown } from 'react-icons/fa'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization'
const NotFound: React.FC = () => {
const { translate } = useLocalization()
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-white text-gray-700 p-4 dark:bg-gray-950 dark:text-gray-300"> {/* Arka plan ve metin rengi güncellendi, padding eklendi */}
<FaFrown size={128} className="text-blue-600 mb-6" /> {/* İkon boyutu ve rengi güncellendi */}
<h1 className="text-7xl font-bold text-gray-900 mb-4 dark:text-gray-100">404</h1> {/* Başlık boyutu ve rengi güncellendi */}
<p className="text-xl text-gray-600 mb-8 text-center max-w-md dark:text-gray-300"> {/* Metin rengi, margin ve max-width güncellendi */}
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4 text-gray-700 dark:bg-gray-950 dark:text-gray-300">
<FaFrown size={128} className="mb-6 text-blue-600" />
<h1 className="mb-4 text-7xl font-bold text-gray-900 dark:text-gray-100">404</h1>
<p className="mb-8 max-w-md text-center text-xl text-gray-600 dark:text-gray-300">
{translate('::Public.notFound.message')}
</p>
<a href={ROUTES_ENUM.public.home} className="bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition duration-300 text-lg font-semibold"> {/* Buton stili güncellendi */}
{/* `<a href>` tam sayfa yeniden yukleme yapiyordu; SPA yonlendirmesi kullanilir. */}
<Link
to={ROUTES_ENUM.public.home}
className="rounded-lg bg-blue-600 px-6 py-3 text-lg font-semibold text-white transition duration-300 hover:bg-blue-700"
>
{translate('::Public.notFound.button')}
</a>
</Link>
</div>
);
};
)
}
export default NotFound;
export default NotFound

View file

@ -1,6 +1,5 @@
import { Basket } from '@/components/orders/Basket'
import { PaymentForm } from '@/components/orders/PaymentForm'
import { Loading } from '@/components/shared'
import { APP_NAME } from '@/constants/app.constant'
import { CustomTenantDto } from '@/proxy/config/models'
import { OrderDto } from '@/proxy/order/models'
@ -17,6 +16,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import React, { useState, useEffect } from 'react'
import { Helmet } from 'react-helmet'
import { useNavigate } from 'react-router-dom'
import { PublicPageLoader } from './shared'
const Payment: React.FC = () => {
const navigate = useNavigate()
@ -115,13 +115,7 @@ const Payment: React.FC = () => {
}
if (!tenant) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<Loading loading={!tenant} />
</div>
</div>
)
return <PublicPageLoader />
}
return (
@ -133,15 +127,7 @@ const Payment: React.FC = () => {
></Helmet>
{/* Hero Section */}
<div className="relative bg-blue-900 text-white py-12">
<div
className="absolute inset-0 opacity-20"
style={{
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
></div>
</div>
<div className="relative bg-blue-900 py-12 text-white dark:bg-gray-950" />
<div className="container mx-auto px-4 pt-4">
<PaymentForm

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import { OrderSuccess } from '@/components/orders/OrderSuccess'
import { Loading } from '@/components/shared'
import { APP_NAME } from '@/constants/app.constant'
import { PublicPageLoader } from './shared'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization'
import React, { useState, useEffect } from 'react'
@ -30,13 +30,7 @@ const Success: React.FC = () => {
}
if (!orderId) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<Loading loading={!orderId} />
</div>
</div>
)
return <PublicPageLoader />
}
return (
@ -46,15 +40,7 @@ const Success: React.FC = () => {
title={translate('::' + 'App.Platform.Success')}
defaultTitle={APP_NAME}
></Helmet>
<div className="relative bg-blue-900 text-white py-12">
<div
className="absolute inset-0 opacity-20"
style={{
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
></div>
</div>
<div className="relative bg-blue-900 py-12 text-white dark:bg-gray-950" />
<div className="container mx-auto px-4 pt-12">
<OrderSuccess

View file

@ -1,22 +1,17 @@
import React from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import { Button, Input } from '@/components/ui'
import { IconPickerField } from '@/views/shared/MenuAddDialog'
import { DesignerSelection } from './types'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { normalizeLanguageKey, translateLabel } from './localization'
import { DesignerField, DesignerFieldValue, DesignerLanguageOption, DesignerSelection } from './types'
interface DesignerLanguageOption {
key: string
cultureName: string
displayName: string
}
function normalizeLanguageKey(language?: string) {
if (!language) {
return ''
}
return language.toLowerCase().split('-')[0]
const GROUP_ORDER = ['content', 'media', 'style', 'advanced']
const GROUP_FALLBACK_LABELS: Record<string, string> = {
content: 'Icerik',
media: 'Gorsel',
style: 'Stil',
advanced: 'Gelismis',
}
interface DesignerDrawerProps {
@ -25,11 +20,176 @@ interface DesignerDrawerProps {
pageTitle: string
selectedLanguage: string
languages: DesignerLanguageOption[]
isSaving?: boolean
isDirty?: boolean
canUndo?: boolean
canRedo?: boolean
onClose: () => void
onSave: () => void
onLanguageChange: (language: string) => void
onLanguageChange: (cultureName: string) => void
onReset: () => void
onFieldChange: (fieldKey: string, value: string | string[]) => void
onFieldChange: (fieldKey: string, value: DesignerFieldValue) => void
onUndo?: () => void
onRedo?: () => void
}
function toDisplayString(value: DesignerFieldValue) {
if (Array.isArray(value)) {
return value.join('\n')
}
if (typeof value === 'boolean' || typeof value === 'number') {
return String(value)
}
return value ?? ''
}
const StyleClassEditor: React.FC<{
field: DesignerField
onChange: (value: string) => void
}> = ({ field, onChange }) => {
const tokens = toDisplayString(field.value).split(/\s+/).filter(Boolean)
return (
<div className="space-y-2">
<Input
textArea
rows={field.rows ?? 3}
value={toDisplayString(field.value)}
placeholder={field.placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
/>
{tokens.length > 0 && (
<div className="flex flex-wrap gap-1">
{tokens.map((token, index) => (
<button
key={`${token}-${index}`}
type="button"
title="Kaldir"
className="rounded-full bg-slate-100 px-2 py-0.5 font-mono text-[11px] text-slate-600 transition-colors hover:bg-rose-100 hover:text-rose-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-rose-900/40 dark:hover:text-rose-300"
onClick={() => onChange(tokens.filter((_, i) => i !== index).join(' '))}
>
{token}
</button>
))}
</div>
)}
</div>
)
}
const ImageFieldEditor: React.FC<{
field: DesignerField
onChange: (value: string) => void
}> = ({ field, onChange }) => {
const value = toDisplayString(field.value)
return (
<div className="space-y-2">
<Input
value={value}
placeholder={field.placeholder ?? 'https://...'}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
/>
{value && (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-50 dark:border-gray-700 dark:bg-gray-800">
<img
src={value}
alt={field.label}
loading="lazy"
className="h-28 w-full object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
/>
</div>
)}
</div>
)
}
const FieldEditor: React.FC<{
field: DesignerField
onChange: (fieldKey: string, value: DesignerFieldValue) => void
}> = ({ field, onChange }) => {
switch (field.type) {
case 'icon':
return (
<IconPickerField
value={toDisplayString(field.value)}
onChange={(iconKey) => onChange(field.key, iconKey)}
/>
)
case 'image':
return <ImageFieldEditor field={field} onChange={(value) => onChange(field.key, value)} />
case 'style':
return <StyleClassEditor field={field} onChange={(value) => onChange(field.key, value)} />
case 'boolean':
return (
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-600 dark:text-gray-300">
<input
type="checkbox"
checked={field.value === true || field.value === 'true'}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500 dark:border-gray-600"
onChange={(event) => onChange(field.key, event.target.checked)}
/>
{field.placeholder ?? field.label}
</label>
)
case 'number':
return (
<Input
type="number"
value={toDisplayString(field.value)}
placeholder={field.placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = Number(event.target.value)
onChange(field.key, Number.isFinite(parsed) ? parsed : 0)
}}
/>
)
case 'list':
return (
<Input
textArea
rows={field.rows ?? 5}
value={toDisplayString(field.value)}
placeholder={field.placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange(
field.key,
event.target.value
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
)
}
/>
)
case 'textarea':
return (
<Input
textArea
rows={field.rows ?? 4}
value={toDisplayString(field.value)}
placeholder={field.placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange(field.key, event.target.value)
}
/>
)
default:
return (
<Input
value={toDisplayString(field.value)}
placeholder={field.placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange(field.key, event.target.value)
}
/>
)
}
}
const DesignerDrawer: React.FC<DesignerDrawerProps> = ({
@ -38,118 +198,219 @@ const DesignerDrawer: React.FC<DesignerDrawerProps> = ({
pageTitle,
selectedLanguage,
languages,
isSaving = false,
isDirty = false,
canUndo = false,
canRedo = false,
onClose,
onSave,
onLanguageChange,
onReset,
onFieldChange,
onUndo,
onRedo,
}) => {
const { translate } = useLocalization()
const [search, setSearch] = useState('')
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({})
useEffect(() => {
setSearch('')
}, [selection?.id])
const groupedFields = useMemo(() => {
const query = search.trim().toLowerCase()
const fields = (selection?.fields ?? []).filter(
(field) =>
!query ||
field.label.toLowerCase().includes(query) ||
field.key.toLowerCase().includes(query),
)
const groups = new Map<string, DesignerField[]>()
fields.forEach((field) => {
const group = field.group ?? 'content'
groups.set(group, [...(groups.get(group) ?? []), field])
})
return Array.from(groups.entries()).sort(([a], [b]) => {
const indexA = GROUP_ORDER.indexOf(a)
const indexB = GROUP_ORDER.indexOf(b)
return (indexA === -1 ? GROUP_ORDER.length : indexA) - (indexB === -1 ? GROUP_ORDER.length : indexB)
})
}, [search, selection])
const totalFieldCount = selection?.fields.length ?? 0
if (!isOpen) {
return null
}
return (
<div className="fixed inset-y-0 right-0 z-50 w-[420px] border-l border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-2xl">
<div className="flex h-full flex-col bg-white dark:bg-gray-900">
<div className="border-b border-slate-200 dark:border-gray-700 px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-400">
{pageTitle} {translate('::Public.designer.title')}
</p>
<h3 className="mt-2 text-lg font-semibold text-slate-900 dark:text-gray-100">
{selection?.title ?? translate('::Public.designer.noSelection')}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-gray-400">
{selection?.description ?? translate('::Public.designer.selectField')}
</p>
</div>
<Button variant="plain" size="sm" onClick={onClose}>
{translate('::Public.designer.close')}
</Button>
<aside className="fixed inset-y-0 right-0 z-50 flex w-full max-w-[420px] flex-col border-l border-slate-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<header className="border-b border-slate-200 px-5 py-4 dark:border-gray-700">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-400">
{pageTitle} {translateLabel(translate, 'Public.designer.title', 'Tasarim')}
</p>
<h3 className="mt-2 truncate text-lg font-semibold text-slate-900 dark:text-gray-100">
{selection?.title ??
translateLabel(translate, 'Public.designer.noSelection', 'Blok secilmedi')}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-gray-400">
{selection?.description ??
translateLabel(
translate,
'Public.designer.selectField',
'Duzenlemek istediginiz blogu sayfadan secin.',
)}
</p>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{languages.map((language) => (
<Button
key={`${language.cultureName}-${language.key}`}
type="button"
title={language.displayName}
onClick={() => onLanguageChange(language.cultureName)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-xl transition-all
${
normalizeLanguageKey(selectedLanguage) === language.key
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20 ring-2 ring-sky-200 dark:ring-sky-700'
: 'border-slate-200 dark:border-gray-700 hover:border-slate-400 dark:hover:border-gray-500 hover:bg-slate-50 dark:hover:bg-gray-800'
}`}
>
<Avatar
size={22}
shape="circle"
src={`/img/countries/${language.cultureName}.png`}
/>
</Button>
))}
</div>
<Button variant="plain" size="sm" onClick={onClose}>
{translateLabel(translate, 'Public.designer.close', 'Kapat')}
</Button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-5 py-5">
{selection?.fields.map((field) => {
const commonProps = {
value: Array.isArray(field.value) ? field.value.join('\n') : field.value,
placeholder: field.placeholder,
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const nextValue =
field.type === 'list'
? event.target.value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
: event.target.value
onFieldChange(field.key, nextValue)
},
}
return (
<div key={field.key}>
<label className="mb-1 block text-sm font-semibold text-slate-700 dark:text-gray-200">
{field.localizationKey || field.label}
</label>
{field.localizationKey && field.localizationKey !== field.label && (
<p className="mb-2 text-xs text-slate-500 dark:text-gray-400">{field.label}</p>
)}
{field.type === 'icon' ? (
<IconPickerField
value={field.value as string}
onChange={(iconKey) => onFieldChange(field.key, iconKey)}
/>
) : (
<Input
{...commonProps}
textArea={field.type === 'textarea' || field.type === 'list'}
rows={field.rows ?? (field.type === 'list' ? 5 : 4)}
/>
)}
</div>
)
})}
{!selection && (
<div className="rounded-2xl border border-dashed border-slate-300 dark:border-gray-700 bg-slate-50 dark:bg-gray-800 px-4 py-6 text-sm text-slate-500 dark:text-gray-400">
{translate('::Public.designer.noSelectionDetails')}
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-200 dark:border-gray-700 px-5 py-4">
<Button variant="solid" block onClick={onSave}>
{translate('::Save')}
<div className="mt-4 flex flex-wrap items-center gap-1.5">
{languages.map((language) => (
<Button
key={`${language.cultureName}-${language.key}`}
type="button"
variant="plain"
shape="circle"
title={language.displayName}
aria-label={language.displayName}
aria-pressed={normalizeLanguageKey(selectedLanguage) === language.key}
className={`!h-9 !w-9 !px-0 border transition-all ${
normalizeLanguageKey(selectedLanguage) === language.key
? 'border-sky-500 bg-sky-50 ring-2 ring-sky-200 dark:bg-sky-900/20 dark:ring-sky-700'
: 'border-slate-200 hover:border-slate-400 hover:bg-slate-50 dark:border-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-800'
}`}
onClick={() => onLanguageChange(language.cultureName)}
>
<Avatar size={22} shape="circle" src={`/img/countries/${language.cultureName}.png`} />
</Button>
))}
<span className="ml-auto flex items-center gap-1">
<Button
type="button"
variant="plain"
size="sm"
disabled={!canUndo}
title={translateLabel(translate, 'Public.designer.undo', 'Geri al (Ctrl+Z)')}
onClick={onUndo}
>
</Button>
<Button
type="button"
variant="plain"
size="sm"
disabled={!canRedo}
title={translateLabel(translate, 'Public.designer.redo', 'Ileri al (Ctrl+Shift+Z)')}
onClick={onRedo}
>
</Button>
</span>
</div>
{totalFieldCount > 6 && (
<div className="mt-3">
<Input
size="sm"
value={search}
placeholder={translateLabel(translate, 'Public.designer.searchField', 'Alan ara...')}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setSearch(event.target.value)}
/>
</div>
)}
</header>
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-5">
{!selection && (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
{translateLabel(
translate,
'Public.designer.noSelectionDetails',
'Sayfadaki bir blogu tiklayarak duzenlemeye baslayin.',
)}
</div>
)}
{selection && groupedFields.length === 0 && (
<p className="text-sm text-slate-500 dark:text-gray-400">
{translateLabel(translate, 'App.NoDataFound', 'Kayit bulunamadi')}
</p>
)}
{groupedFields.map(([group, fields]) => {
const isCollapsed = collapsedGroups[group] === true
return (
<section key={group}>
<button
type="button"
className="mb-2 flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 transition-colors hover:text-slate-600 dark:text-gray-500 dark:hover:text-gray-300"
onClick={() =>
setCollapsedGroups((previous) => ({ ...previous, [group]: !isCollapsed }))
}
>
<span>
{translateLabel(
translate,
`Public.designer.group.${group}`,
GROUP_FALLBACK_LABELS[group] ?? group,
)}
<span className="ml-2 font-normal normal-case tracking-normal">
({fields.length})
</span>
</span>
<span>{isCollapsed ? '+' : ''}</span>
</button>
{!isCollapsed && (
<div className="space-y-4">
{fields.map((field) => (
<div key={field.key}>
<label className="mb-1 block text-sm font-semibold text-slate-700 dark:text-gray-200">
{field.label}
</label>
{field.helpText && (
<p className="mb-2 text-xs text-slate-500 dark:text-gray-400">
{field.helpText}
</p>
)}
<FieldEditor field={field} onChange={onFieldChange} />
</div>
))}
</div>
)}
</section>
)
})}
</div>
</div>
<footer className="flex items-center gap-2 border-t border-slate-200 px-5 py-4 dark:border-gray-700">
<Button type="button" variant="plain" disabled={!isDirty || isSaving} onClick={onReset}>
{translateLabel(translate, 'Public.designer.reset', 'Geri yukle')}
</Button>
<Button
block
type="button"
variant="solid"
loading={isSaving}
disabled={!isDirty || isSaving}
onClick={onSave}
>
{translate('::Save')}
</Button>
</footer>
</aside>
)
}

View file

@ -0,0 +1,100 @@
import React from 'react'
import { Button } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization'
import DesignerDrawer from './DesignerDrawer'
import { translateLabel } from './localization'
import { DesignerFieldValue, DesignerLanguageOption, DesignerSelection } from './types'
export interface DesignerLayerController {
isDesignMode: boolean
isPanelVisible: boolean
isSaving: boolean
isDirty: boolean
canUndo: boolean
canRedo: boolean
selectedLanguage: string
drawerLanguages: DesignerLanguageOption[]
setIsPanelVisible: (visible: boolean) => void
changeLanguage: (cultureName: string) => void
changeField: (fieldKey: string, value: DesignerFieldValue) => void
resetContent: () => void
exitDesignMode: () => void
save: () => void
undo: () => void
redo: () => void
}
interface DesignerLayerProps {
pageTitle: string
selection: DesignerSelection | null
designer: DesignerLayerController
}
/**
* Tasarim modunun sayfadan bagimsiz arayuzu: durum cubugu ve duzenleme paneli.
* Tasarim modu kapaliyken hicbir sey render etmez.
*/
const DesignerLayer: React.FC<DesignerLayerProps> = ({ pageTitle, selection, designer }) => {
const { translate } = useLocalization()
if (!designer.isDesignMode) {
return null
}
return (
<>
<div className="fixed bottom-6 left-6 z-40 flex items-center gap-2 rounded-full bg-slate-900/95 px-3 py-2 text-sm font-medium text-white shadow-xl backdrop-blur">
<span className="pl-1">{pageTitle}</span>
<span
className={`h-2 w-2 rounded-full ${designer.isDirty ? 'bg-amber-400' : 'bg-emerald-400'}`}
title={
designer.isDirty
? translateLabel(translate, 'Public.designer.unsaved', 'Kaydedilmemis degisiklik var')
: translateLabel(translate, 'Public.designer.saved', 'Tum degisiklikler kayitli')
}
/>
<Button
type="button"
variant="plain"
size="sm"
className="!text-white"
onClick={() => designer.setIsPanelVisible(!designer.isPanelVisible)}
>
{designer.isPanelVisible
? translateLabel(translate, 'Public.designer.hidePanel', 'Paneli gizle')
: translateLabel(translate, 'Public.designer.showPanel', 'Paneli goster')}
</Button>
<Button
type="button"
variant="plain"
size="sm"
className="!text-white"
onClick={designer.exitDesignMode}
>
{translateLabel(translate, 'Public.designer.exit', 'Tasarimdan cik')}
</Button>
</div>
<DesignerDrawer
isOpen={designer.isPanelVisible}
selection={selection}
pageTitle={pageTitle}
selectedLanguage={designer.selectedLanguage}
languages={designer.drawerLanguages}
isSaving={designer.isSaving}
isDirty={designer.isDirty}
canUndo={designer.canUndo}
canRedo={designer.canRedo}
onClose={() => designer.setIsPanelVisible(false)}
onSave={designer.save}
onLanguageChange={designer.changeLanguage}
onReset={designer.resetContent}
onFieldChange={designer.changeField}
onUndo={designer.undo}
onRedo={designer.redo}
/>
</>
)
}
export default DesignerLayer

View file

@ -1,22 +1,40 @@
import classNames from 'classnames'
import React, { PropsWithChildren } from 'react'
import React, { PropsWithChildren, useCallback } from 'react'
interface SelectableBlockProps extends PropsWithChildren {
id: string
isActive: boolean
isDesignMode: boolean
onSelect: (id: string) => void
/** Blok uzerinde gorunen etiket. Verilmezse `id` kullanilir. */
label?: string
className?: string
}
/**
* Tasarim modunda icerigi secilebilir hale getirir.
* Tasarim modu kapaliyken hicbir sarmalayici DOM dugumu uretmez.
*/
const SelectableBlock: React.FC<SelectableBlockProps> = ({
id,
isActive,
isDesignMode,
onSelect,
label,
className,
children,
}) => {
const handleSelect = useCallback(
(event: React.SyntheticEvent) => {
// Tasarim modunda blok icindeki link/buton tiklamalari sayfayi degistirmemeli;
// tiklama yalnizca blogu secmeli.
event.preventDefault()
event.stopPropagation()
onSelect(id)
},
[id, onSelect],
)
if (!isDesignMode) {
return <>{children}</>
}
@ -24,26 +42,33 @@ const SelectableBlock: React.FC<SelectableBlockProps> = ({
return (
<div
className={classNames(
'relative rounded-xl transition-all cursor-pointer',
'group/designer relative rounded-xl transition-all cursor-pointer',
isActive
? 'ring-2 ring-sky-500 ring-offset-4 ring-offset-white dark:ring-offset-gray-950'
: 'ring-1 ring-sky-200/80 hover:ring-sky-400 dark:ring-sky-700/80 dark:hover:ring-sky-500',
className,
)}
onClick={(e) => {
e.stopPropagation()
onSelect(id)
}}
role="button"
tabIndex={0}
aria-pressed={isActive}
aria-label={label ?? id}
onClickCapture={handleSelect}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelect(id)
handleSelect(event)
}
}}
>
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded-full"></div>
<span
className={classNames(
'pointer-events-none absolute -top-2.5 left-3 z-20 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider shadow-sm transition-opacity',
isActive
? 'bg-sky-500 text-white opacity-100'
: 'bg-slate-900/80 text-white opacity-0 group-hover/designer:opacity-100',
)}
>
{label ?? id}
</span>
{children}
</div>
)

View file

@ -0,0 +1,45 @@
import { DesignerField } from './types'
interface FieldExtras {
group?: string
placeholder?: string
helpText?: string
rows?: number
}
function build(
type: DesignerField['type'],
key: string,
label: string,
value: DesignerField['value'],
extras?: FieldExtras,
): DesignerField {
return { key, label, type, value, ...extras }
}
export const textField = (key: string, label: string, value: string, extras?: FieldExtras) =>
build('text', key, label, value, extras)
export const textAreaField = (key: string, label: string, value: string, extras?: FieldExtras) =>
build('textarea', key, label, value, extras)
export const imageField = (key: string, label: string, value: string, extras?: FieldExtras) =>
build('image', key, label, value, extras)
export const iconField = (key: string, label: string, value: string, extras?: FieldExtras) =>
build('icon', key, label, value, extras)
export const numberField = (key: string, label: string, value: number, extras?: FieldExtras) =>
build('number', key, label, value, extras)
export const booleanField = (key: string, label: string, value: boolean, extras?: FieldExtras) =>
build('boolean', key, label, value, extras)
/**
* Tailwind sinif listesi alani. Panelde tek satirlik editor + sinif rozetleri ile gosterilir.
*/
export const styleField = (key: string, label: string, value: string, extras?: FieldExtras) =>
build('style', key, label, value, { group: extras?.group ?? 'style', ...extras })
/** Alan gruplarinin panelde gorunecegi sira. */
export const FIELD_GROUP_ORDER = ['content', 'media', 'style', 'advanced'] as const

View file

@ -0,0 +1,10 @@
export { default as DesignerDrawer } from './DesignerDrawer'
export { default as DesignerLayer } from './DesignerLayer'
export { default as SelectableBlock } from './SelectableBlock'
export * from './fields'
export * from './localization'
export * from './paths'
export * from './types'
export * from './useDesignerLanguages'
export { useDesignerState } from './useDesignerState'
export { usePageDesigner } from './usePageDesigner'

View file

@ -0,0 +1,50 @@
export type TranslateFn = (key: string) => string
const LOCALIZATION_KEY_PATTERN = /^[A-Za-z0-9_.-]+$/
/**
* Bir metnin lokalizasyon anahtari mi yoksa duz metin mi oldugunu tahmin eder.
* Anahtarlar bosluk icermez ve en az bir nokta ile ayrilmis segment tasir.
*/
export function isLikelyLocalizationKey(value?: string) {
return Boolean(value && LOCALIZATION_KEY_PATTERN.test(value) && value.includes('.'))
}
/**
* Deger bir lokalizasyon anahtari ise cevirisini, degilse degerin kendisini dondurur.
* Ceviri bulunamadiginda `fallback` kullanilir.
*/
export function resolveLocalizedValue(
translate: TranslateFn,
keyOrValue: string | undefined,
fallback = '',
) {
if (!keyOrValue) {
return fallback
}
if (!isLikelyLocalizationKey(keyOrValue)) {
return keyOrValue
}
const translatedValue = translate('::' + keyOrValue)
return translatedValue === keyOrValue ? fallback || keyOrValue : translatedValue
}
/**
* Designer arayuz metinleri icin: anahtar tanimli degilse okunabilir bir yedek metin dondurur.
*/
export function translateLabel(translate: TranslateFn, key: string, fallback: string) {
const value = translate('::' + key)
return value === key ? fallback : value
}
export function normalizeLanguageKey(language?: string) {
if (!language) {
return 'tr'
}
return language.toLowerCase().split('-')[0]
}

View file

@ -0,0 +1,61 @@
type AnyRecord = Record<string, unknown>
function splitPath(path: string) {
return path.split('.').filter((segment) => segment.length > 0)
}
function isIndexSegment(segment: string) {
return /^\d+$/.test(segment)
}
/** `slides.0.services.1.icon` gibi bir yolu okur. */
export function getByPath(source: unknown, path: string): unknown {
return splitPath(path).reduce<unknown>((accumulator, segment) => {
if (accumulator === null || typeof accumulator !== 'object') {
return undefined
}
return (accumulator as AnyRecord)[segment]
}, source)
}
/**
* Yol boyunca yalnizca degisen dugumleri kopyalayarak yeni bir nesne dondurur.
* Yol gecersizse (ornegin dizi indeksi yoksa) kaynak nesne degistirilmeden dondurulur.
*/
export function setByPath<T>(source: T, path: string, value: unknown): T {
const segments = splitPath(path)
if (segments.length === 0 || source === null || typeof source !== 'object') {
return source
}
const [segment, ...rest] = segments
const container = source as unknown as AnyRecord
if (Array.isArray(source)) {
if (!isIndexSegment(segment)) {
return source
}
const index = Number(segment)
if (index < 0 || index >= source.length) {
return source
}
const next = [...(source as unknown[])]
next[index] = rest.length === 0 ? value : setByPath(next[index], rest.join('.'), value)
return next as unknown as T
}
if (!(segment in container)) {
return source
}
return {
...container,
[segment]: rest.length === 0 ? value : setByPath(container[segment], rest.join('.'), value),
} as unknown as T
}

View file

@ -1,12 +1,28 @@
export type DesignerFieldType = 'text' | 'textarea' | 'image' | 'list' | 'icon'
export type DesignerFieldType =
| 'text'
| 'textarea'
| 'image'
| 'list'
| 'icon'
| 'style'
| 'number'
| 'boolean'
export type DesignerFieldValue = string | string[] | number | boolean
export interface DesignerField {
/**
* Duzenlenen icerik nesnesine giden nokta yolu.
* Ornek: `heroTitle`, `slides.0.title`, `slides.0.services.2.icon`
*/
key: string
label: string
type: DesignerFieldType
value: string | string[]
localizationKey?: string
value: DesignerFieldValue
/** Panelde alanlari gruplamak icin kullanilir. */
group?: string
placeholder?: string
helpText?: string
rows?: number
}
@ -16,3 +32,9 @@ export interface DesignerSelection {
description?: string
fields: DesignerField[]
}
export interface DesignerLanguageOption {
key: string
cultureName: string
displayName: string
}

View file

@ -0,0 +1,65 @@
import { useMemo } from 'react'
import { useStoreState } from '@/store'
import { normalizeLanguageKey } from './localization'
import { DesignerLanguageOption } from './types'
/**
* ABP yapilandirmasindaki dilleri designer paneli icin normalize eder.
* Dort public sayfada tekrarlanan dil hazirligi burada tek noktada toplanir.
*/
export function useDesignerLanguages() {
const configCultureName = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localeCurrentLang = useStoreState((state) => state.locale?.currentLang)
const abpLanguages = useStoreState((state) => state.abpConfig.config?.localization.languages)
const currentLanguage = configCultureName || localeCurrentLang || 'tr'
const languageOptions = useMemo<DesignerLanguageOption[]>(
() =>
(abpLanguages ?? [])
.filter((language) => Boolean(language.cultureName))
.map((language) => {
const cultureName = language.cultureName as string
return {
key: normalizeLanguageKey(cultureName),
cultureName,
displayName: language.displayName || cultureName,
}
}),
[abpLanguages],
)
const editorLanguages = useMemo(
() =>
Array.from(
new Set(
(languageOptions.length > 0
? languageOptions.map((language) => language.key)
: [normalizeLanguageKey(currentLanguage)]
).filter(Boolean),
),
),
[currentLanguage, languageOptions],
)
return { currentLanguage, languageOptions, editorLanguages }
}
/** Panelde gosterilecek dil listesi; yapilandirma bos ise desteklenen dillerden uretilir. */
export function resolveDrawerLanguages(
languageOptions: DesignerLanguageOption[],
supportedLanguages: string[],
): DesignerLanguageOption[] {
if (languageOptions.length > 0) {
return languageOptions
}
return supportedLanguages.map((language) => ({
key: language,
cultureName: language,
displayName: language.toUpperCase(),
}))
}

View file

@ -1,183 +1,272 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useSearchParams } from 'react-router-dom'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { normalizeLanguageKey } from './localization'
import { setByPath } from './paths'
const DESIGNER_QUERY_KEY = 'design'
const HISTORY_LIMIT = 50
interface UseDesignerStateOptions {
currentLanguage?: string
supportedLanguages?: string[]
currentLanguage?: string
supportedLanguages?: string[]
/** Tasarim modunun acilabilmesi icin yetki kontrolu. Varsayilan: acik. */
enabled?: boolean
}
function serializeValue<T>(value: T | null | undefined) {
if (value === undefined) {
return '__undefined__'
}
if (value === null) {
return '__null__'
}
try {
return JSON.stringify(value)
} catch {
return '__unserializable__'
}
interface LanguageEntry<T> {
current: T
/** Sunucudan gelen son icerigin serilestirilmis hali; "kirli mi" karsilastirmasi icin. */
baseline: string
past: T[]
future: T[]
}
function normalizeLanguageKey(language?: string) {
if (!language) {
return 'tr'
}
return language.toLowerCase().split('-')[0]
function serialize(value: unknown) {
try {
return JSON.stringify(value) ?? '__undefined__'
} catch {
return '__unserializable__'
}
}
function cloneValue<T>(value: T): T {
try {
return JSON.parse(JSON.stringify(value)) as T
} catch {
return value
}
function clone<T>(value: T): T {
try {
return JSON.parse(JSON.stringify(value)) as T
} catch {
return value
}
}
function createEntry<T>(content: T): LanguageEntry<T> {
return { current: clone(content), baseline: serialize(content), past: [], future: [] }
}
export function useDesignerState<T>(
pageKey: string,
initialContent: T | null,
options?: UseDesignerStateOptions,
pageKey: string,
initialContent: T | null,
options?: UseDesignerStateOptions,
) {
const location = useLocation()
const [searchParams] = useSearchParams()
const isDesignMode =
searchParams.get(DESIGNER_QUERY_KEY) === '1' || location.pathname.endsWith('/designer')
const currentLanguage = normalizeLanguageKey(options?.currentLanguage)
const supportedLanguages = useMemo(() => {
const base = (options?.supportedLanguages ?? [currentLanguage]).map((language) =>
normalizeLanguageKey(language),
)
if (!base.includes(currentLanguage)) {
return [currentLanguage, ...base]
}
const location = useLocation()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const isDesignerRequested =
searchParams.get(DESIGNER_QUERY_KEY) === '1' || location.pathname.endsWith('/designer')
const isDesignMode = isDesignerRequested && options?.enabled !== false
return Array.from(new Set(base))
}, [currentLanguage, options?.supportedLanguages])
const currentLanguage = normalizeLanguageKey(options?.currentLanguage)
const supportedLanguages = useMemo(() => {
const base = (options?.supportedLanguages ?? [currentLanguage]).map(normalizeLanguageKey)
const [selectedLanguage, setSelectedLanguage] = useState(currentLanguage)
const [byLanguage, setByLanguage] = useState<Record<string, T>>({})
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null)
const seededDefaultsRef = useRef<Record<string, string>>({})
return Array.from(new Set(base.includes(currentLanguage) ? base : [currentLanguage, ...base]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLanguage, serialize(options?.supportedLanguages)])
const selectedLanguagePrefix = normalizeLanguageKey(selectedLanguage)
const content =
byLanguage[selectedLanguage] ??
byLanguage[selectedLanguagePrefix] ??
byLanguage[currentLanguage] ??
initialContent ??
null
const [entries, setEntries] = useState<Record<string, LanguageEntry<T>>>({})
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null)
const entriesRef = useRef(entries)
entriesRef.current = entries
useEffect(() => {
setSelectedLanguage(normalizeLanguageKey(currentLanguage))
}, [currentLanguage])
const serializedInitial = serialize(initialContent)
useEffect(() => {
if (!initialContent) {
return
}
// Sunucu icerigi degistiginde (ilk yukleme veya dil degisimi) o dilin taslagini tohumla.
// Kullanici o dilde duzenleme yaptiysa taslak korunur, uzerine yazilmaz.
useEffect(() => {
if (!initialContent) {
return
}
const nextSerializedInitial = serializeValue(initialContent)
setEntries((previous) => {
const entry = previous[currentLanguage]
setByLanguage((prev) => {
const currentValue = prev[currentLanguage]
const currentSerialized = serializeValue(currentValue)
const previousSeed = seededDefaultsRef.current[currentLanguage]
if (entry && serialize(entry.current) !== entry.baseline) {
return previous
}
if (currentValue !== undefined && previousSeed !== undefined && currentSerialized !== previousSeed) {
return prev
}
if (entry && entry.baseline === serializedInitial) {
return previous
}
if (currentSerialized === nextSerializedInitial) {
seededDefaultsRef.current[currentLanguage] = nextSerializedInitial
return prev
}
return { ...previous, [currentLanguage]: createEntry(initialContent) }
})
// initialContent yerine serilestirilmis hali izlenir: her render'da yeni referans gelse bile
// efekt yalnizca icerik gercekten degistiginde calisir.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLanguage, serializedInitial])
seededDefaultsRef.current[currentLanguage] = nextSerializedInitial
useEffect(() => {
if (!isDesignMode) {
setSelectedBlockId(null)
}
}, [isDesignMode])
return {
...prev,
[currentLanguage]: cloneValue(initialContent),
}
})
const entry = entries[currentLanguage]
const content = entry?.current ?? initialContent ?? null
const isDirty = Boolean(entry) && serialize(entry.current) !== entry.baseline
if (!isDesignMode && selectedBlockId !== null) {
setSelectedBlockId(null)
}
}, [currentLanguage, initialContent, isDesignMode, selectedBlockId])
const mutate = useCallback(
(updater: (current: T) => T | null) => {
setEntries((previous) => {
const existing = previous[currentLanguage]
useEffect(() => {
if (!isDesignMode || !initialContent) {
return
}
if (!existing) {
return previous
}
const normalizedSelectedLanguage = normalizeLanguageKey(selectedLanguage)
const hasSelectedLanguageContent =
byLanguage[normalizedSelectedLanguage] !== undefined ||
byLanguage[selectedLanguage] !== undefined
const next = updater(existing.current)
if (hasSelectedLanguageContent) {
return
}
if (!next || serialize(next) === serialize(existing.current)) {
return previous
}
// If selected language has no saved value, initialize it from the current translated defaults.
seededDefaultsRef.current[normalizedSelectedLanguage] = serializeValue(initialContent)
setByLanguage((prev) => ({
...prev,
[normalizedSelectedLanguage]: cloneValue(initialContent),
}))
}, [byLanguage, initialContent, isDesignMode, selectedLanguage])
return {
...previous,
[currentLanguage]: {
...existing,
current: next,
past: [...existing.past, existing.current].slice(-HISTORY_LIMIT),
future: [],
},
}
})
},
[currentLanguage],
)
const setContent = (updater: (current: T | null) => T | null) => {
setByLanguage((prev) => {
const current =
prev[selectedLanguage] ??
prev[normalizeLanguageKey(selectedLanguage)] ??
prev[currentLanguage] ??
initialContent ??
null
const next = updater(current)
/** Nokta yolu ile tek bir alani gunceller. */
const setFieldValue = useCallback(
(path: string, value: unknown) => {
mutate((current) => setByPath(current, path, value))
},
[mutate],
)
if (!next) {
return prev
}
const undo = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
return {
...prev,
[normalizeLanguageKey(selectedLanguage)]: cloneValue(next),
}
})
}
if (!existing?.past.length) {
return previous
}
const resetContent = () => {
if (!initialContent) {
return
}
const past = [...existing.past]
const restored = past.pop() as T
const normalizedSelectedLanguage = normalizeLanguageKey(selectedLanguage)
seededDefaultsRef.current[normalizedSelectedLanguage] = serializeValue(initialContent)
return {
...previous,
[currentLanguage]: {
...existing,
current: restored,
past,
future: [existing.current, ...existing.future].slice(0, HISTORY_LIMIT),
},
}
})
}, [currentLanguage])
setByLanguage((prev) => ({
...prev,
[normalizedSelectedLanguage]: cloneValue(initialContent),
}))
}
const redo = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
return {
content,
isDesignMode,
selectedBlockId,
selectedLanguage,
supportedLanguages,
setContent,
setSelectedBlockId,
setSelectedLanguage,
resetContent,
}
if (!existing?.future.length) {
return previous
}
const [restored, ...future] = existing.future
return {
...previous,
[currentLanguage]: {
...existing,
current: restored,
past: [...existing.past, existing.current].slice(-HISTORY_LIMIT),
future,
},
}
})
}, [currentLanguage])
/** Kaydedilmemis degisiklikleri atip sunucudan gelen son icerige doner. */
const resetContent = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing) {
return previous
}
return {
...previous,
[currentLanguage]: {
...existing,
current: JSON.parse(existing.baseline) as T,
past: [...existing.past, existing.current].slice(-HISTORY_LIMIT),
future: [],
},
}
})
}, [currentLanguage])
/** Kaydetme basarili olduktan sonra mevcut icerigi yeni referans nokta yapar. */
const commitContent = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing) {
return previous
}
return {
...previous,
[currentLanguage]: { ...existing, baseline: serialize(existing.current) },
}
})
}, [currentLanguage])
const exitDesignMode = useCallback(() => {
// Tasarim modu iki sekilde acilabiliyor: `?design=1` veya `/<sayfa>/designer` yolu.
if (location.pathname.endsWith('/designer')) {
navigate(location.pathname.replace(/\/designer$/, '') || '/', { replace: true })
return
}
const next = new URLSearchParams(searchParams)
next.delete(DESIGNER_QUERY_KEY)
setSearchParams(next, { replace: true })
}, [location.pathname, navigate, searchParams, setSearchParams])
// Tasarim modunda sekme kapatilirken kaydedilmemis degisiklikler icin uyari.
useEffect(() => {
if (!isDesignMode || !isDirty) {
return
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDesignMode, isDirty])
return {
pageKey,
content,
isDesignMode,
isDirty,
canUndo: Boolean(entry?.past.length),
canRedo: Boolean(entry?.future.length),
selectedBlockId,
selectedLanguage: currentLanguage,
supportedLanguages,
setContent: mutate,
setFieldValue,
setSelectedBlockId,
resetContent,
commitContent,
exitDesignMode,
undo,
redo,
}
}

View file

@ -0,0 +1,188 @@
import { useCallback, useEffect, useState } from 'react'
import { Notification, toast } from '@/components/ui'
import { useStoreActions, useStoreState } from '@/store'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import { translateLabel } from './localization'
import { resolveDrawerLanguages, useDesignerLanguages } from './useDesignerLanguages'
import { useDesignerState } from './useDesignerState'
import { DesignerFieldValue } from './types'
interface UsePageDesignerOptions<T> {
/** Tasarim panelinde gosterilecek sayfa adi ve state anahtari. */
pageKey: string
/**
* Tasarim modunu acmak icin gereken ABP yetkisi (orn. `App.Home.Design`).
* Yetki `PermissionsData.json` icinde tanimlanir ve seeder ile yonetilir.
*/
permission: string
/** Sunucudan uretilen icerik. Referans kararliligi icin `useMemo` ile hesaplanmalidir. */
initialContent: T | null
/** Kaydetme cagrisi. Basarili donerse icerik yeni referans nokta olarak isaretlenir. */
onSave: (content: T, cultureName: string) => Promise<unknown>
}
/**
* Public sayfalarin tasarim modu icin ortak davranis:
* dil listesi, panel gorunurlugu, secim, kaydetme ve klavye kisayollari.
*/
export function usePageDesigner<T>({
pageKey,
permission,
initialContent,
onSave,
}: UsePageDesignerOptions<T>) {
const { translate } = useLocalization()
const { setLang } = useStoreActions((actions) => actions.locale)
const { getConfig } = useStoreActions((actions) => actions.abpConfig)
const isAuthenticated = useStoreState(
(state) => state.abpConfig.config?.currentUser?.isAuthenticated,
)
const { checkPermission } = usePermission()
const { currentLanguage, languageOptions, editorLanguages } = useDesignerLanguages()
// Tasarim modu yalnizca oturum acmis ve ilgili sayfa yetkisi verilmis kullanicilar icin
// acilir; anonim ziyaretci ya da yetkisiz kullanici adres cubugundan `?design=1`
// ekleyerek paneli acamaz. Asil kontrol sunucuda `PublicAppService.Save*PageAsync`
// uzerindeki `[Authorize]` ile yapilir.
const canDesign = Boolean(isAuthenticated) && checkPermission(permission)
const designer = useDesignerState<T>(pageKey, initialContent, {
currentLanguage,
supportedLanguages: editorLanguages,
enabled: canDesign,
})
const [isPanelVisible, setIsPanelVisible] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const {
content,
isDesignMode,
selectedLanguage,
setFieldValue,
setSelectedBlockId,
commitContent,
undo,
redo,
} = designer
const selectBlock = useCallback(
(blockId: string) => {
setSelectedBlockId(blockId)
setIsPanelVisible(true)
},
[setSelectedBlockId],
)
const changeLanguage = useCallback(
(cultureName: string) => {
// Global locale degisimi cevirileri yeniden yukler; designer dili store'u takip eder.
setLang(cultureName)
},
[setLang],
)
const changeField = useCallback(
(path: string, value: DesignerFieldValue) => setFieldValue(path, value),
[setFieldValue],
)
const save = useCallback(async () => {
if (!content || isSaving || !canDesign) {
return
}
setIsSaving(true)
try {
await onSave(content, selectedLanguage)
await getConfig(false)
commitContent()
setSelectedBlockId(null)
toast.push(
<Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
console.error(`${pageKey} tasarimi kaydedilemedi:`, error)
toast.push(
<Notification type="danger" duration={4000}>
{translateLabel(translate, 'Public.designer.saveFailed', 'Tasarim kaydedilemedi.')}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsSaving(false)
}
}, [
canDesign,
commitContent,
content,
getConfig,
isSaving,
onSave,
pageKey,
selectedLanguage,
setSelectedBlockId,
translate,
])
// Tasarim modu kisayollari: Ctrl+S kaydet, Ctrl+Z geri al, Ctrl+Shift+Z ileri al, Esc secimi birak.
useEffect(() => {
if (!isDesignMode) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
const isEditingText =
event.target instanceof HTMLElement &&
['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)
const key = event.key.toLowerCase()
if ((event.ctrlKey || event.metaKey) && key === 's') {
event.preventDefault()
void save()
return
}
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault()
if (event.shiftKey) {
redo()
} else {
undo()
}
return
}
if (event.key === 'Escape' && !isEditingText) {
setSelectedBlockId(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isDesignMode, redo, save, setSelectedBlockId, undo])
return {
...designer,
isSaving,
isPanelVisible,
setIsPanelVisible,
selectBlock,
changeLanguage,
changeField,
save,
translate,
drawerLanguages: resolveDrawerLanguages(languageOptions, designer.supportedLanguages),
}
}

View file

@ -0,0 +1,20 @@
/** Public sayfalarda kart ikonlarinin donusumlu renk paleti. */
const ICON_COLOR_CLASSES = [
'text-blue-600',
'text-red-600',
'text-green-600',
'text-purple-600',
'text-yellow-600',
'text-indigo-600',
]
export function getIconColor(index: number) {
return ICON_COLOR_CLASSES[index % ICON_COLOR_CLASSES.length]
}
/** Tasarim paneli acikken icerigin panel altinda kalmamasi icin sag bosluk sinifi. */
export function designerPageClass(isDesignMode: boolean, isPanelVisible: boolean) {
return `min-h-screen bg-gray-50 dark:bg-gray-950 ${
isDesignMode && isPanelVisible ? 'xl:pr-[420px]' : ''
}`
}

View file

@ -0,0 +1,26 @@
import React from 'react'
import Loading from '@/components/shared/Loading'
import navigationIcon from '@/proxy/menus/navigation-icon.config'
interface PageIconProps {
name?: string
className?: string
size?: number
}
/** Ikon anahtari tanimli degilse hicbir sey render etmez. */
export const PageIcon: React.FC<PageIconProps> = ({ name, className, size }) => {
const IconComponent = name ? navigationIcon[name] : undefined
if (!IconComponent) {
return null
}
return <IconComponent className={className} size={size} />
}
export const PublicPageLoader: React.FC = () => (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
<Loading loading />
</div>
)