Claude güncellemesi Public sayfaları güncellemesi
This commit is contained in:
parent
a42776e9c9
commit
bbfa1cee0d
30 changed files with 3455 additions and 3646 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
← {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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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}
|
||||
>
|
||||
×
|
||||
</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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
100
ui/src/views/public/designer/DesignerLayer.tsx
Normal file
100
ui/src/views/public/designer/DesignerLayer.tsx
Normal 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
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
45
ui/src/views/public/designer/fields.ts
Normal file
45
ui/src/views/public/designer/fields.ts
Normal 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
|
||||
10
ui/src/views/public/designer/index.ts
Normal file
10
ui/src/views/public/designer/index.ts
Normal 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'
|
||||
50
ui/src/views/public/designer/localization.ts
Normal file
50
ui/src/views/public/designer/localization.ts
Normal 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]
|
||||
}
|
||||
61
ui/src/views/public/designer/paths.ts
Normal file
61
ui/src/views/public/designer/paths.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
65
ui/src/views/public/designer/useDesignerLanguages.ts
Normal file
65
ui/src/views/public/designer/useDesignerLanguages.ts
Normal 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(),
|
||||
}))
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
188
ui/src/views/public/designer/usePageDesigner.tsx
Normal file
188
ui/src/views/public/designer/usePageDesigner.tsx
Normal 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),
|
||||
}
|
||||
}
|
||||
20
ui/src/views/public/pageStyles.ts
Normal file
20
ui/src/views/public/pageStyles.ts
Normal 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]' : ''
|
||||
}`
|
||||
}
|
||||
26
ui/src/views/public/shared.tsx
Normal file
26
ui/src/views/public/shared.tsx
Normal 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>
|
||||
)
|
||||
Loading…
Reference in a new issue