About ve Services Designer komponenti tasarlandı

This commit is contained in:
Sedat ÖZTÜRK 2026-03-17 12:54:25 +03:00
parent 5cc3dc5ab3
commit 4304229079
15 changed files with 2008 additions and 576 deletions

View file

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
namespace Sozsoft.Platform.Public;
public class SaveAboutPageInput
{
public string CultureName { get; set; }
public string HeroTitleKey { get; set; }
public string HeroTitleValue { get; set; }
public string HeroSubtitleKey { get; set; }
public string HeroSubtitleValue { get; set; }
public string HeroImageKey { get; set; }
public string HeroImageValue { get; set; }
public List<SaveAboutStatInput> Stats { get; set; } = [];
public List<SaveLocalizedTextInput> Descriptions { get; set; } = [];
public List<SaveAboutSectionInput> Sections { get; set; } = [];
}
public class SaveAboutStatInput
{
public string Icon { get; set; }
public string Value { get; set; }
public string LabelKey { get; set; }
public string LabelValue { get; set; }
public bool? UseCounter { get; set; }
public string? CounterEnd { get; set; }
public string CounterSuffix { get; set; }
public int? CounterDuration { get; set; }
}
public class SaveAboutSectionInput
{
public string TitleKey { get; set; }
public string TitleValue { get; set; }
public string DescriptionKey { get; set; }
public string DescriptionValue { get; set; }
}
public class SaveServicesPageInput
{
public string CultureName { get; set; }
public string HeroTitleKey { get; set; }
public string HeroTitleValue { get; set; }
public string HeroSubtitleKey { get; set; }
public string HeroSubtitleValue { get; set; }
public string HeroImageKey { get; set; }
public string HeroImageValue { get; set; }
public string SupportTitleKey { get; set; }
public string SupportTitleValue { get; set; }
public string SupportButtonLabelKey { get; set; }
public string SupportButtonLabelValue { get; set; }
public string CtaTitleKey { get; set; }
public string CtaTitleValue { get; set; }
public string CtaDescriptionKey { get; set; }
public string CtaDescriptionValue { get; set; }
public string CtaButtonLabelKey { get; set; }
public string CtaButtonLabelValue { get; set; }
public List<SaveServiceItemInput> ServiceItems { get; set; } = [];
public List<SaveServiceItemInput> SupportItems { get; set; } = [];
}
public class SaveServiceItemInput
{
public string? Icon { get; set; }
public string TitleKey { get; set; }
public string TitleValue { get; set; }
public string? DescriptionKey { get; set; }
public string? DescriptionValue { get; set; }
public string Type { get; set; }
public List<SaveLocalizedTextInput> Features { get; set; } = [];
}
public class SaveLocalizedTextInput
{
public string Key { get; set; }
public string Value { get; set; }
}

View file

@ -13,6 +13,8 @@ using System.Linq;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using System.Text.Json; using System.Text.Json;
using Volo.Abp.Identity; using Volo.Abp.Identity;
using Sozsoft.Languages;
using Sozsoft.Languages.Entities;
namespace Sozsoft.Platform.Public; namespace Sozsoft.Platform.Public;
@ -31,6 +33,9 @@ public class PublicAppService : PlatformAppService
private readonly IRepository<About, Guid> _aboutRepository; private readonly IRepository<About, Guid> _aboutRepository;
private readonly IRepository<Contact, Guid> _contactRepository; private readonly IRepository<Contact, Guid> _contactRepository;
private readonly IIdentityUserRepository _identityUserRepository; private readonly IIdentityUserRepository _identityUserRepository;
private readonly IRepository<LanguageKey, Guid> _languageKeyRepository;
private readonly IRepository<LanguageText, Guid> _languageTextRepository;
private readonly LanguageTextAppService _languageTextAppService;
public PublicAppService( public PublicAppService(
IRepository<Service, Guid> serviceRepository, IRepository<Service, Guid> serviceRepository,
@ -45,7 +50,10 @@ public class PublicAppService : PlatformAppService
IRepository<Order, Guid> orderRepository, IRepository<Order, Guid> orderRepository,
IRepository<About, Guid> aboutRepository, IRepository<About, Guid> aboutRepository,
IRepository<Contact, Guid> contactRepository, IRepository<Contact, Guid> contactRepository,
IIdentityUserRepository identityUserRepository IIdentityUserRepository identityUserRepository,
IRepository<LanguageKey, Guid> languageKeyRepository,
IRepository<LanguageText, Guid> languageTextRepository,
LanguageTextAppService languageTextAppService
) )
{ {
_serviceRepository = serviceRepository; _serviceRepository = serviceRepository;
@ -61,15 +69,114 @@ public class PublicAppService : PlatformAppService
_aboutRepository = aboutRepository; _aboutRepository = aboutRepository;
_contactRepository = contactRepository; _contactRepository = contactRepository;
_identityUserRepository = identityUserRepository; _identityUserRepository = identityUserRepository;
_languageKeyRepository = languageKeyRepository;
_languageTextRepository = languageTextRepository;
_languageTextAppService = languageTextAppService;
} }
public async Task<List<ServiceDto>> GetServicesListAsync() public async Task<List<ServiceDto>> GetServicesListAsync()
{ {
var entity = await _serviceRepository.GetListAsync(); var queryable = await _serviceRepository.GetQueryableAsync();
var entity = await AsyncExecuter.ToListAsync(queryable.OrderBy(a => a.CreationTime));
return ObjectMapper.Map<List<Service>, List<ServiceDto>>(entity); return ObjectMapper.Map<List<Service>, List<ServiceDto>>(entity);
} }
public async Task SaveAboutPageAsync(SaveAboutPageInput input)
{
var entity = await _aboutRepository.FirstOrDefaultAsync() ?? throw new EntityNotFoundException(typeof(About));
entity.StatsJson = JsonSerializer.Serialize(input.Stats.Select(stat => new StatDto
{
Icon = stat.Icon,
Value = stat.Value,
LabelKey = stat.LabelKey,
UseCounter = stat.UseCounter,
CounterEnd = stat.CounterEnd,
CounterSuffix = stat.CounterSuffix,
CounterDuration = stat.CounterDuration,
}).ToList());
entity.DescriptionsJson = JsonSerializer.Serialize(input.Descriptions.Select(item => item.Key).ToList());
entity.SectionsJson = JsonSerializer.Serialize(input.Sections.Select(section => new SectionDto
{
Key = section.TitleKey,
DescKey = section.DescriptionKey,
}).ToList());
await UpsertLanguageTextAsync(input.CultureName, input.HeroTitleKey, input.HeroTitleValue);
await UpsertLanguageTextAsync(input.CultureName, input.HeroSubtitleKey, input.HeroSubtitleValue);
await UpsertLanguageTextAsync(input.CultureName, input.HeroImageKey, input.HeroImageValue);
foreach (var stat in input.Stats)
{
await UpsertLanguageTextAsync(input.CultureName, stat.LabelKey, stat.LabelValue);
}
foreach (var description in input.Descriptions)
{
await UpsertLanguageTextAsync(input.CultureName, description.Key, description.Value);
}
foreach (var section in input.Sections)
{
await UpsertLanguageTextAsync(input.CultureName, section.TitleKey, section.TitleValue);
await UpsertLanguageTextAsync(input.CultureName, section.DescriptionKey, section.DescriptionValue);
}
await _aboutRepository.UpdateAsync(entity, autoSave: true);
await _languageTextAppService.ClearRedisCacheAsync();
}
public async Task SaveServicesPageAsync(SaveServicesPageInput input)
{
var existingEntities = await _serviceRepository.GetListAsync();
foreach (var entity in existingEntities)
{
await _serviceRepository.DeleteAsync(entity, autoSave: false);
}
foreach (var item in input.ServiceItems.Concat(input.SupportItems))
{
var entity = new Service
{
Icon = item.Icon,
Title = item.TitleKey,
Description = item.DescriptionKey,
Type = item.Type,
Features = item.Features.Select(feature => feature.Key).ToArray(),
};
await _serviceRepository.InsertAsync(entity, autoSave: false);
await UpsertLanguageTextAsync(input.CultureName, item.TitleKey, item.TitleValue);
if (!item.DescriptionKey.IsNullOrWhiteSpace())
{
await UpsertLanguageTextAsync(input.CultureName, item.DescriptionKey!, item.DescriptionValue ?? string.Empty);
}
foreach (var feature in item.Features)
{
await UpsertLanguageTextAsync(input.CultureName, feature.Key, feature.Value);
}
}
await UpsertLanguageTextAsync(input.CultureName, input.HeroTitleKey, input.HeroTitleValue);
await UpsertLanguageTextAsync(input.CultureName, input.HeroSubtitleKey, input.HeroSubtitleValue);
await UpsertLanguageTextAsync(input.CultureName, input.HeroImageKey, input.HeroImageValue);
await UpsertLanguageTextAsync(input.CultureName, input.SupportTitleKey, input.SupportTitleValue);
await UpsertLanguageTextAsync(input.CultureName, input.SupportButtonLabelKey, input.SupportButtonLabelValue);
await UpsertLanguageTextAsync(input.CultureName, input.CtaTitleKey, input.CtaTitleValue);
await UpsertLanguageTextAsync(input.CultureName, input.CtaDescriptionKey, input.CtaDescriptionValue);
await UpsertLanguageTextAsync(input.CultureName, input.CtaButtonLabelKey, input.CtaButtonLabelValue);
await CurrentUnitOfWork!.SaveChangesAsync();
await _languageTextAppService.ClearRedisCacheAsync();
}
public async Task CreateDemoAsync(DemoDto input) public async Task CreateDemoAsync(DemoDto input)
{ {
var demo = ObjectMapper.Map<DemoDto, Demo>(input); var demo = ObjectMapper.Map<DemoDto, Demo>(input);
@ -298,6 +405,58 @@ public class PublicAppService : PlatformAppService
return ObjectMapper.Map<Contact, ContactDto>(entity); return ObjectMapper.Map<Contact, ContactDto>(entity);
} }
private async Task UpsertLanguageTextAsync(string cultureName, string key, string value)
{
if (key.IsNullOrWhiteSpace())
{
return;
}
var normalizedCultureName = NormalizeCultureName(cultureName);
var resourceName = PlatformConsts.AppName;
var languageKey = await _languageKeyRepository.FirstOrDefaultAsync(a => a.ResourceName == resourceName && a.Key == key);
if (languageKey == null)
{
languageKey = await _languageKeyRepository.InsertAsync(new LanguageKey
{
ResourceName = resourceName,
Key = key,
}, autoSave: false);
}
var languageText = await _languageTextRepository.FirstOrDefaultAsync(a =>
a.ResourceName == resourceName &&
a.Key == languageKey.Key &&
a.CultureName == normalizedCultureName);
if (languageText == null)
{
await _languageTextRepository.InsertAsync(new LanguageText
{
ResourceName = resourceName,
Key = languageKey.Key,
CultureName = normalizedCultureName,
Value = value ?? string.Empty,
}, autoSave: false);
return;
}
languageText.Value = value ?? string.Empty;
await _languageTextRepository.UpdateAsync(languageText, autoSave: false);
}
private static string NormalizeCultureName(string cultureName)
{
if (cultureName.IsNullOrWhiteSpace())
{
return PlatformConsts.DefaultLanguage;
}
return cultureName.Split('-')[0].ToLowerInvariant();
}
} }

View file

@ -7910,7 +7910,7 @@
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Public.services.support.sms.features.Api", "key": "Public.services.support.sms.features.api",
"tr": "Api Desteği", "tr": "Api Desteği",
"en": "Api Support" "en": "Api Support"
}, },
@ -8058,6 +8058,84 @@
"tr": "Müşteri Yorumları", "tr": "Müşteri Yorumları",
"en": "Testimonials" "en": "Testimonials"
}, },
{
"resourceName": "Platform",
"key": "Public.designer.title",
"tr": "Tasarımcı",
"en": "Designer"
},
{
"resourceName": "Platform",
"key": "Public.designer.ikonAnahtari",
"tr": "İkon Anahtarı",
"en": "Icon Key"
},
{
"resourceName": "Platform",
"key": "Public.designer.showPanel",
"tr": "Paneli Göster",
"en": "Show Panel"
},
{
"resourceName": "Platform",
"key": "Public.designer.noSelection",
"tr": "Bir alan seçin",
"en": "Select a field"
},
{
"resourceName": "Platform",
"key": "Public.designer.value",
"tr": "Değer",
"en": "Value"
},
{
"resourceName": "Platform",
"key": "Public.designer.desc1",
"tr": "İkon, Başlık, Açıklama ve Özellik listesini düzenleyin.",
"en": "Edit the icon, title, description, and feature list."
},
{
"resourceName": "Platform",
"key": "Public.designer.desc2",
"tr": "Alt CTA metinlerini ve buton etiketini duzenleyin.",
"en": "Edit the sub-CTA texts and button label."
},
{
"resourceName": "Platform",
"key": "Public.designer.desc3",
"tr": "Destek alanı başlığını ve buton metnini düzenleyin.",
"en": "Edit the support area title and button text."
},
{
"resourceName": "Platform",
"key": "Public.designer.desc4",
"tr": "Başlık, alt başlık ve arka plan görselini düzenleyin.",
"en": "Edit the title, subtitle, and background image."
},
{
"resourceName": "Platform",
"key": "Public.designer.selectField",
"tr": "Sayfa üzerinden bir blok seçerek özelliklerini buradan düzenleyin.",
"en": "Select a block on the page to edit its properties here."
},
{
"resourceName": "Platform",
"key": "Public.designer.close",
"tr": "Gizle",
"en": "Hide"
},
{
"resourceName": "Platform",
"key": "Public.designer.noSelectionDetails",
"tr": "Seçili bölümün düzenlenebilir alanları burada gösterilir.",
"en": "Select a block on the page to edit its properties here."
},
{
"resourceName": "Platform",
"key": "Public.designer.save",
"tr": "Kaydet",
"en": "Save"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKit.Component.Description", "key": "App.DeveloperKit.Component.Description",

View file

@ -5613,325 +5613,6 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
} }
#endregion #endregion
#region About Us
listFormName = AppCodes.About;
if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName))
{
var listForm = await _listFormRepository.InsertAsync(
new ListForm
{
ListFormType = ListFormTypeEnum.List,
PageSize = 10,
ExportJson = DefaultExportJson,
IsSubForm = false,
ShowNote = true,
LayoutJson = DefaultLayoutJson(),
CultureName = LanguageCodes.En,
ListFormCode = listFormName,
Name = listFormName,
Title = listFormName,
DataSourceCode = SeedConsts.DataSources.DefaultCode,
IsTenant = false,
IsBranch = false,
IsOrganizationUnit = false,
Description = listFormName,
SelectCommandType = SelectCommandTypeEnum.Table,
SelectCommand = TableNameResolver.GetFullTableName(nameof(TableNameEnum.About)),
KeyFieldName = "Id",
DefaultFilter = DefaultFilterJson,
KeyFieldDbSourceType = DbType.Guid,
SortMode = GridOptions.SortModeSingle,
FilterRowJson = DefaultFilterRowJson,
HeaderFilterJson = DefaultHeaderFilterJson,
SearchPanelJson = DefaultSearchPanelJson,
GroupPanelJson = JsonSerializer.Serialize(new { Visible = false }),
SelectionJson = DefaultSelectionSingleJson,
ColumnOptionJson = DefaultColumnOptionJson(),
PermissionJson = DefaultPermissionJson(listFormName),
PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 800, 720, true, true, true, true, false),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>
{
new() {
Order = 1, ColCount = 1, ColSpan = 1, ItemType = "group", Items =
[
new EditingFormItemDto { Order = 1, DataField = "StatsJson", ColSpan = 1, IsRequired = true, EditorType2=EditorTypes.dxTextArea, EditorOptions="{\"height\":200}" },
new EditingFormItemDto { Order = 2, DataField = "DescriptionsJson", ColSpan = 1, IsRequired = true, EditorType2=EditorTypes.dxTextArea, EditorOptions="{\"height\":200}" },
new EditingFormItemDto { Order = 3, DataField = "SectionsJson", ColSpan = 1, IsRequired = true, EditorType2=EditorTypes.dxTextArea, EditorOptions="{\"height\":200}" },
]
}
}),
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.About)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(),
});
#region About Fields
await _listFormFieldRepository.InsertManyAsync([
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.Guid,
FieldName = "Id",
CaptionName = "App.Listform.ListformField.Id",
Width = 100,
ListOrderNo = 1,
Visible = false,
IsActive = true,
IsDeleted = false,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "StatsJson",
CaptionName = "App.Listform.ListformField.StatsJson",
Width = 700,
ListOrderNo = 2,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "DescriptionsJson",
CaptionName = "App.Listform.ListformField.DescriptionsJson",
Width = 400,
ListOrderNo = 3,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "SectionsJson",
CaptionName = "App.Listform.ListformField.SectionsJson",
Width = 400,
ListOrderNo = 4,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
]);
#endregion
}
#endregion
#region Services
listFormName = AppCodes.Services;
if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName))
{
var listForm = await _listFormRepository.InsertAsync(
new ListForm
{
ListFormType = ListFormTypeEnum.List,
PageSize = 10,
ExportJson = DefaultExportJson,
IsSubForm = false,
ShowNote = true,
LayoutJson = DefaultLayoutJson(),
CultureName = LanguageCodes.En,
ListFormCode = listFormName,
Name = listFormName,
Title = listFormName,
DataSourceCode = SeedConsts.DataSources.DefaultCode,
IsTenant = false,
IsBranch = false,
IsOrganizationUnit = false,
Description = listFormName,
SelectCommandType = SelectCommandTypeEnum.Table,
SelectCommand = TableNameResolver.GetFullTableName(nameof(TableNameEnum.Service)),
KeyFieldName = "Id",
DefaultFilter = DefaultFilterJson,
KeyFieldDbSourceType = DbType.Guid,
SortMode = GridOptions.SortModeSingle,
FilterRowJson = DefaultFilterRowJson,
HeaderFilterJson = DefaultHeaderFilterJson,
SearchPanelJson = DefaultSearchPanelJson,
GroupPanelJson = JsonSerializer.Serialize(new { Visible = false }),
SelectionJson = DefaultSelectionSingleJson,
ColumnOptionJson = DefaultColumnOptionJson(),
PermissionJson = DefaultPermissionJson(listFormName),
PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 500, 400, true, true, true, true, false),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>
{
new() { Order = 1, ColCount = 1, ColSpan = 1, ItemType = "group", Items =
[
new EditingFormItemDto { Order = 1, DataField = "Title", ColSpan = 1, IsRequired = true, EditorType2 = EditorTypes.dxAutocomplete, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 2, DataField = "Icon", ColSpan = 1, EditorType2 = EditorTypes.dxTextBox },
new EditingFormItemDto { Order = 3, DataField = "Description", ColSpan = 1, IsRequired = true, EditorType2 = EditorTypes.dxAutocomplete, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 4, DataField = "Type", ColSpan = 1, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 5, DataField = "Features", ColSpan = 1, EditorType2 = EditorTypes.dxTextArea },
]
}
}),
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.Service)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(),
});
#region Services Fields
await _listFormFieldRepository.InsertManyAsync([
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.Guid,
FieldName = "Id",
CaptionName = "App.Listform.ListformField.Id",
Width = 100,
ListOrderNo = 1,
Visible = false,
IsActive = true,
IsDeleted = false,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Title",
CaptionName = "App.Listform.ListformField.Title",
Width = 400,
ListOrderNo = 2,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = false,
LookupJson = JsonSerializer.Serialize(new LookupDto {
DataSourceType = UiLookupDataSourceTypeEnum.Query,
DisplayExpr = "Name",
ValueExpr = "Key",
LookupQuery = LookupQueryValues.LanguageKeyValues
}),
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Icon",
CaptionName = "App.Listform.ListformField.Icon",
Width = 100,
ListOrderNo = 3,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Description",
CaptionName = "App.Listform.ListformField.Description",
Width = 500,
ListOrderNo = 4,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = false,
LookupJson = JsonSerializer.Serialize(new LookupDto {
DataSourceType = UiLookupDataSourceTypeEnum.Query,
DisplayExpr = "Name",
ValueExpr = "Key",
LookupQuery = LookupQueryValues.LanguageKeyValues
}),
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Type",
CaptionName = "App.Listform.ListformField.Type",
Width = 100,
ListOrderNo = 5,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
LookupJson = JsonSerializer.Serialize(new LookupDto
{
DataSourceType = UiLookupDataSourceTypeEnum.StaticData,
DisplayExpr = "name",
ValueExpr = "key",
LookupQuery = JsonSerializer.Serialize(new LookupDataDto[] {
new () { Key="service", Name="Service" },
new () { Key="support", Name="Support" },
}),
}),
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Features",
CaptionName = "App.Listform.ListformField.Features",
Width = 500,
ListOrderNo = 6,
Visible = true,
IsActive = true,
IsDeleted = false,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson,
}
]);
#endregion
}
#endregion
#region Products #region Products
listFormName = AppCodes.Orders.Products; listFormName = AppCodes.Orders.Products;
if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName)) if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName))

View file

@ -21,6 +21,13 @@
"routeType": "public", "routeType": "public",
"authority": [] "authority": []
}, },
{
"key": "about",
"path": "/about",
"componentPath": "@/views/public/About",
"routeType": "public",
"authority": []
},
{ {
"key": "products", "key": "products",
"path": "/products", "path": "/products",
@ -363,6 +370,20 @@
"componentPath": "@/views/report/DevexpressReportDesigner", "componentPath": "@/views/report/DevexpressReportDesigner",
"routeType": "protected", "routeType": "protected",
"authority": [] "authority": []
},
{
"key": "servicesDesigner",
"path": "/admin/public/services/designer",
"componentPath": "@/views/public/Services",
"routeType": "protected",
"authority": ["App.Services"]
},
{
"key": "aboutDesigner",
"path": "/admin/public/about/designer",
"componentPath": "@/views/public/About",
"routeType": "protected",
"authority": ["App.About"]
} }
], ],
"Menus": [ "Menus": [
@ -623,7 +644,7 @@
"Code": "App.About", "Code": "App.About",
"DisplayName": "App.About", "DisplayName": "App.About",
"Order": 1, "Order": 1,
"Url": "/admin/list/App.About", "Url": "/admin/public/about/designer",
"Icon": "FcAbout", "Icon": "FcAbout",
"RequiredPermissionName": "App.About", "RequiredPermissionName": "App.About",
"IsDisabled": false "IsDisabled": false
@ -633,7 +654,7 @@
"Code": "App.Services", "Code": "App.Services",
"DisplayName": "App.Services", "DisplayName": "App.Services",
"Order": 2, "Order": 2,
"Url": "/admin/list/App.Services", "Url": "/admin/public/services/designer",
"Icon": "FcServices", "Icon": "FcServices",
"RequiredPermissionName": "App.Services", "RequiredPermissionName": "App.Services",
"IsDisabled": false "IsDisabled": false

View file

@ -1560,60 +1560,6 @@
"MultiTenancySide": 2, "MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs" "MenuGroup": "Erp|Kurs"
}, },
{
"GroupName": "App.Saas",
"Name": "App.About.Create",
"ParentName": "App.About",
"DisplayName": "Create",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About.Delete",
"ParentName": "App.About",
"DisplayName": "Delete",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About.Export",
"ParentName": "App.About",
"DisplayName": "Export",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About.Import",
"ParentName": "App.About",
"DisplayName": "Import",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About.Note",
"ParentName": "App.About",
"DisplayName": "Note",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.About.Update",
"ParentName": "App.About",
"DisplayName": "Update",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{ {
"GroupName": "App.Saas", "GroupName": "App.Saas",
"Name": "App.Services", "Name": "App.Services",
@ -1623,60 +1569,7 @@
"MultiTenancySide": 2, "MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs" "MenuGroup": "Erp|Kurs"
}, },
{
"GroupName": "App.Saas",
"Name": "App.Services.Create",
"ParentName": "App.Services",
"DisplayName": "Create",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services.Delete",
"ParentName": "App.Services",
"DisplayName": "Delete",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services.Export",
"ParentName": "App.Services",
"DisplayName": "Export",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services.Import",
"ParentName": "App.Services",
"DisplayName": "Import",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services.Note",
"ParentName": "App.Services",
"DisplayName": "Note",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Services.Update",
"ParentName": "App.Services",
"DisplayName": "Update",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{ {
"GroupName": "App.Saas", "GroupName": "App.Saas",
"Name": "App.Orders.Products", "Name": "App.Orders.Products",

View file

@ -2,11 +2,13 @@ export const ROUTES_ENUM = {
public: { public: {
home: '/home', home: '/home',
about: '/about', about: '/about',
aboutDesigner: '/about/designer',
products: '/products', products: '/products',
checkout: '/checkout', checkout: '/checkout',
payment: '/payment', payment: '/payment',
success: '/success', success: '/success',
services: '/services', services: '/services',
servicesDesigner: '/services/designer',
blog: '/blog', blog: '/blog',
blogDetail: '/blog/:id', blogDetail: '/blog/:id',
demo: '/demo', demo: '/demo',

View file

@ -1,6 +1,42 @@
import { AboutDto } from '@/proxy/about/models' import { AboutDto } from '@/proxy/about/models'
import apiService from './api.service' import apiService from './api.service'
export interface SaveLocalizedTextInput {
key: string
value: string
}
export interface SaveAboutStatInput {
icon: string
value: string
labelKey: string
labelValue: string
useCounter?: boolean
counterEnd?: string
counterSuffix?: string
counterDuration?: number
}
export interface SaveAboutSectionInput {
titleKey: string
titleValue: string
descriptionKey: string
descriptionValue: string
}
export interface SaveAboutPageInput {
cultureName: string
heroTitleKey: string
heroTitleValue: string
heroSubtitleKey: string
heroSubtitleValue: string
heroImageKey: string
heroImageValue: string
stats: SaveAboutStatInput[]
descriptions: SaveLocalizedTextInput[]
sections: SaveAboutSectionInput[]
}
export function getAbout() { export function getAbout() {
return apiService.fetchData<AboutDto>( return apiService.fetchData<AboutDto>(
{ {
@ -10,3 +46,14 @@ export function getAbout() {
{ apiName: 'Default' }, { apiName: 'Default' },
) )
} }
export function saveAboutPage(input: SaveAboutPageInput) {
return apiService.fetchData<void, SaveAboutPageInput>(
{
method: 'POST',
url: '/api/app/public/save-about-page',
data: input,
},
{ apiName: 'Default' },
)
}

View file

@ -1,6 +1,43 @@
import apiService from './api.service' import apiService from './api.service'
import { ServiceDto } from '@/proxy/services/models' import { ServiceDto } from '@/proxy/services/models'
export interface SaveLocalizedTextInput {
key: string
value: string
}
export interface SaveServiceItemInput {
icon?: string
titleKey: string
titleValue: string
descriptionKey?: string
descriptionValue?: string
type: 'service' | 'support'
features: SaveLocalizedTextInput[]
}
export interface SaveServicesPageInput {
cultureName: string
heroTitleKey: string
heroTitleValue: string
heroSubtitleKey: string
heroSubtitleValue: string
heroImageKey: string
heroImageValue: string
supportTitleKey: string
supportTitleValue: string
supportButtonLabelKey: string
supportButtonLabelValue: string
ctaTitleKey: string
ctaTitleValue: string
ctaDescriptionKey: string
ctaDescriptionValue: string
ctaButtonLabelKey: string
ctaButtonLabelValue: string
serviceItems: SaveServiceItemInput[]
supportItems: SaveServiceItemInput[]
}
export function getServices() { export function getServices() {
return apiService.fetchData<ServiceDto[]>( return apiService.fetchData<ServiceDto[]>(
{ {
@ -10,3 +47,14 @@ export function getServices() {
{ apiName: 'Default' }, { apiName: 'Default' },
) )
} }
export function saveServicesPage(input: SaveServicesPageInput) {
return apiService.fetchData<void, SaveServicesPageInput>(
{
method: 'POST',
url: '/api/app/public/save-services-page',
data: input,
},
{ apiName: 'Default' },
)
}

View file

@ -2,14 +2,154 @@ import React, { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import navigationIcon from '@/proxy/menus/navigation-icon.config' import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { AboutDto } from '@/proxy/about/models' import { AboutDto } from '@/proxy/about/models'
import { getAbout } from '@/services/about' import { getAbout, saveAboutPage } from '@/services/about'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import Loading from '@/components/shared/Loading' import Loading from '@/components/shared/Loading'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useStoreState } from '@/store'
import { useStoreActions } from '@/store'
import { useNavigate } from 'react-router-dom'
import DesignerDrawer from './designer/DesignerDrawer'
import SelectableBlock from './designer/SelectableBlock'
import { DesignerSelection } from './designer/types'
import { useDesignerState } from './designer/useDesignerState'
interface AboutStatContent {
icon: string
value: string
label: string
labelKey: string
useCounter?: boolean
counterEnd?: string
counterSuffix?: string
counterDuration?: number
}
interface AboutDescriptionContent {
key: string
text: string
}
interface AboutSectionContent {
title: string
description: string
titleKey: string
descriptionKey: string
}
interface AboutContent {
heroTitle: string
heroTitleKey: string
heroSubtitle: string
heroSubtitleKey: string
heroImage: string
heroImageKey: string
stats: AboutStatContent[]
descriptions: AboutDescriptionContent[]
sections: AboutSectionContent[]
}
const ABOUT_HERO_IMAGE =
'https://images.pexels.com/photos/3183183/pexels-photo-3183183.jpeg?auto=compress&cs=tinysrgb&w=1920'
const ABOUT_HERO_TITLE_KEY = 'App.About'
const ABOUT_HERO_SUBTITLE_KEY = 'Public.about.subtitle'
const ABOUT_HERO_IMAGE_KEY = 'Public.about.heroImage'
function isLikelyLocalizationKey(value?: string) {
return Boolean(value && /^[A-Za-z0-9_.-]+$/.test(value) && value.includes('.'))
}
function resolveLocalizedValue(
translate: (key: string) => string,
keyOrValue: string | undefined,
fallback = '',
) {
if (!keyOrValue) {
return fallback
}
if (!isLikelyLocalizationKey(keyOrValue)) {
return keyOrValue
}
const translatedValue = translate('::' + keyOrValue)
return translatedValue === keyOrValue ? fallback || keyOrValue : translatedValue
}
function buildAboutContent(
about: AboutDto | undefined,
translate: (key: string) => string,
): AboutContent {
return {
heroTitle: resolveLocalizedValue(translate, ABOUT_HERO_TITLE_KEY, 'About'),
heroTitleKey: ABOUT_HERO_TITLE_KEY,
heroSubtitle: resolveLocalizedValue(translate, ABOUT_HERO_SUBTITLE_KEY),
heroSubtitleKey: ABOUT_HERO_SUBTITLE_KEY,
heroImage: resolveLocalizedValue(translate, ABOUT_HERO_IMAGE_KEY, ABOUT_HERO_IMAGE),
heroImageKey: ABOUT_HERO_IMAGE_KEY,
stats:
about?.statsDto.map((stat) => ({
icon: stat.icon || '',
value: stat.value,
label: resolveLocalizedValue(translate, stat.labelKey, stat.labelKey),
labelKey:
(isLikelyLocalizationKey(stat.labelKey) ? stat.labelKey : undefined) ||
`Public.about.dynamic.stat.${stat.value}.label`,
useCounter: stat.useCounter,
counterEnd: stat.counterEnd,
counterSuffix: stat.counterSuffix,
counterDuration: stat.counterDuration,
})) ?? [],
descriptions:
about?.descriptionsDto.map((item, index) => ({
key:
(isLikelyLocalizationKey(item) ? item : undefined) ||
`Public.about.dynamic.description.${index + 1}`,
text: resolveLocalizedValue(translate, item, item),
})) ?? [],
sections:
about?.sectionsDto.map((section) => ({
title: resolveLocalizedValue(translate, section.key, section.key),
description: resolveLocalizedValue(translate, section.descKey, section.descKey),
titleKey:
(isLikelyLocalizationKey(section.key) ? section.key : undefined) ||
`Public.about.dynamic.section.${section.key}.title`,
descriptionKey:
(isLikelyLocalizationKey(section.descKey) ? section.descKey : undefined) ||
`Public.about.dynamic.section.${section.key}.description`,
})) ?? [],
}
}
const About: React.FC = () => { const About: React.FC = () => {
const { translate } = useLocalization() const { translate } = useLocalization()
const navigate = useNavigate()
const { setLang } = useStoreActions((actions) => actions.locale)
const { getConfig } = useStoreActions((actions) => actions.abpConfig)
const configCultureName = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localeCurrentLang = useStoreState((state) => state.locale?.currentLang)
const currentLanguage = configCultureName || localeCurrentLang || 'tr'
const abpLanguages = useStoreState((state) => state.abpConfig.config?.localization.languages) || []
const languageOptions = abpLanguages
.filter((language) => Boolean(language.cultureName))
.map((language) => {
const cultureName = language.cultureName || 'tr'
return {
key: cultureName.toLowerCase().split('-')[0],
cultureName,
displayName: language.displayName || cultureName,
}
})
const languagesFromConfig = languageOptions.map((language) => language.key)
const editorLanguages = Array.from(
new Set((languagesFromConfig.length > 0 ? languagesFromConfig : [currentLanguage]).filter(Boolean)),
)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isPanelVisible, setIsPanelVisible] = useState(true)
const [about, setAbout] = useState<AboutDto>() const [about, setAbout] = useState<AboutDto>()
const iconColors = [ const iconColors = [
@ -21,10 +161,25 @@ const About: React.FC = () => {
'text-indigo-600', 'text-indigo-600',
] ]
function getRandomColor() { function getIconColor(index: number) {
return iconColors[Math.floor(Math.random() * iconColors.length)] return iconColors[index % iconColors.length]
} }
const initialContent = !loading ? buildAboutContent(about, translate) : null
const {
content,
isDesignMode,
selectedBlockId,
selectedLanguage,
supportedLanguages,
setContent,
setSelectedBlockId,
resetContent,
} = useDesignerState<AboutContent>('about', initialContent, {
currentLanguage,
supportedLanguages: editorLanguages,
})
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
const fetchServices = async () => { const fetchServices = async () => {
@ -40,6 +195,249 @@ const About: React.FC = () => {
fetchServices() fetchServices()
}, []) }, [])
const updateContent = (updater: (current: AboutContent) => AboutContent) => {
setContent((current) => {
if (!current) {
return current
}
return updater(current)
})
}
const handleFieldChange = (fieldKey: string, value: string | string[]) => {
updateContent((current) => {
if (fieldKey === 'heroTitle' || fieldKey === 'heroSubtitle' || fieldKey === 'heroImage') {
return {
...current,
[fieldKey]: value as string,
}
}
if (fieldKey.startsWith('description-')) {
const index = Number(fieldKey.replace('description-', ''))
const descriptions = [...current.descriptions]
descriptions[index] = {
...descriptions[index],
text: value as string,
}
return {
...current,
descriptions,
}
}
if (selectedBlockId?.startsWith('stat-')) {
const index = Number(selectedBlockId.replace('stat-', ''))
const stats = [...current.stats]
stats[index] = {
...stats[index],
[fieldKey]: value as string,
}
return {
...current,
stats,
}
}
if (selectedBlockId?.startsWith('section-')) {
const index = Number(selectedBlockId.replace('section-', ''))
const sections = [...current.sections]
sections[index] = {
...sections[index],
[fieldKey]: value as string,
}
return {
...current,
sections,
}
}
return current
})
}
const selectedSelection: DesignerSelection | null = React.useMemo(() => {
if (!content || !selectedBlockId) {
return null
}
if (selectedBlockId === 'hero') {
return {
id: 'hero',
title: content.heroTitleKey,
description: 'Baslik, alt baslik ve arka plan gorselini guncelleyin.',
fields: [
{
key: 'heroTitle',
label: content.heroTitleKey,
type: 'text',
value: content.heroTitle,
},
{
key: 'heroSubtitle',
label: content.heroSubtitleKey,
type: 'textarea',
value: content.heroSubtitle,
},
{
key: 'heroImage',
label: content.heroImageKey,
type: 'image',
value: content.heroImage,
},
],
}
}
if (selectedBlockId === 'descriptions') {
return {
id: 'descriptions',
title: 'Public.about.description.*',
description: 'Orta bolumdeki aciklama metinlerini duzenleyin.',
fields: content.descriptions.map((item, index) => ({
key: `description-${index}`,
label: item.key || `Public.about.dynamic.description.${index + 1}`,
type: 'textarea',
value: item.text,
rows: index % 2 === 0 ? 4 : 3,
})),
}
}
if (selectedBlockId.startsWith('stat-')) {
const index = Number(selectedBlockId.replace('stat-', ''))
const stat = content.stats[index]
if (!stat) {
return null
}
return {
id: selectedBlockId,
title: stat.labelKey,
description: translate('::Public.designer.desc1'),
fields: [
{
key: 'icon',
label: translate('::Public.designer.ikonAnahtari'),
type: 'icon',
value: stat.icon,
placeholder: 'Ornek: FaUsers',
},
{
key: 'value',
label: translate('::Public.designer.value'),
type: 'text',
value: stat.value
},
{
key: 'label',
label: translate('::' + stat.labelKey),
type: 'text',
value: stat.label,
},
],
}
}
if (selectedBlockId.startsWith('section-')) {
const index = Number(selectedBlockId.replace('section-', ''))
const section = content.sections[index]
if (!section) {
return null
}
return {
id: selectedBlockId,
title: section.titleKey,
description: 'Kart basligi ve aciklama metnini duzenleyin.',
fields: [
{
key: 'title',
label: section.titleKey,
type: 'text',
value: section.title,
},
{
key: 'description',
label: section.descriptionKey,
type: 'textarea',
value: section.description,
},
],
}
}
return null
}, [content, selectedBlockId])
const handleSaveAndExit = async () => {
if (!content || isSaving) {
return
}
setIsSaving(true)
try {
await saveAboutPage({
cultureName: selectedLanguage,
heroTitleKey: content.heroTitleKey,
heroTitleValue: content.heroTitle,
heroSubtitleKey: content.heroSubtitleKey,
heroSubtitleValue: content.heroSubtitle,
heroImageKey: content.heroImageKey,
heroImageValue: content.heroImage,
stats: content.stats.map((stat, index) => ({
icon: stat.icon,
value: stat.value,
labelKey: stat.labelKey || `Public.about.dynamic.stat.${index + 1}.label`,
labelValue: stat.label,
useCounter: stat.useCounter,
counterEnd: stat.counterEnd,
counterSuffix: stat.counterSuffix,
counterDuration: stat.counterDuration,
})),
descriptions: content.descriptions.map((item, index) => ({
key: item.key || `Public.about.dynamic.description.${index + 1}`,
value: item.text,
})),
sections: content.sections.map((section, index) => ({
titleKey: section.titleKey || `Public.about.dynamic.section.${index + 1}.title`,
titleValue: section.title,
descriptionKey:
section.descriptionKey || `Public.about.dynamic.section.${index + 1}.description`,
descriptionValue: section.description,
})),
})
await getConfig(false)
setSelectedBlockId(null)
navigate(ROUTES_ENUM.public.about, { replace: true })
} catch (error) {
console.error('About tasarimi kaydedilemedi:', error)
} finally {
setIsSaving(false)
}
}
const handleLanguageChange = (language: string) => {
// Global locale changes asynchronously fetch fresh localization texts.
// Keep designer language synced from store after that refresh.
setLang(language)
}
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId)
if (!isPanelVisible) {
setIsPanelVisible(true)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50"> <div className="flex items-center justify-center min-h-screen bg-gray-50">
@ -58,44 +456,67 @@ const About: React.FC = () => {
defaultTitle={APP_NAME} defaultTitle={APP_NAME}
></Helmet> ></Helmet>
<div className="min-h-screen bg-gray-50"> <div className={`min-h-screen bg-gray-50 ${isDesignMode && isPanelVisible ? 'xl:pr-[420px]' : ''}`}>
{isDesignMode && !isPanelVisible && (
<button
type="button"
onClick={() => setIsPanelVisible(true)}
className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl"
>
{translate('::Public.designer.showPanel')}
</button>
)}
{/* Hero Section */} {/* Hero Section */}
<SelectableBlock
id="hero"
isActive={selectedBlockId === 'hero'}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<div className="relative bg-blue-900 text-white py-12"> <div className="relative bg-blue-900 text-white py-12">
<div <div
className="absolute inset-0 opacity-20" className="absolute inset-0 opacity-20"
style={{ style={{
backgroundImage: backgroundImage: `url("${content?.heroImage ?? ABOUT_HERO_IMAGE}")`,
'url("https://images.pexels.com/photos/3183183/pexels-photo-3183183.jpeg?auto=compress&cs=tinysrgb&w=1920")',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
></div> ></div>
<div className="container mx-auto pt-20 relative"> <div className="container mx-auto pt-20 relative">
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white"> <h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">
{translate('::App.About')} {content?.heroTitle}
</h1> </h1>
<p className="text-xl max-w-3xl ml-4">{translate('::Public.about.subtitle')}</p> <p className="text-xl max-w-3xl ml-4">{content?.heroSubtitle}</p>
</div> </div>
</div> </div>
</SelectableBlock>
{/* Stats Section */} {/* Stats Section */}
<div className="py-10 bg-white"> <div className="py-10 bg-white">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{about?.statsDto.map((stat, index) => { {content?.stats.map((stat, index) => {
const IconComponent = navigationIcon[stat.icon || ''] const IconComponent = navigationIcon[stat.icon || '']
let displayValue = stat.value
let elementRef = undefined
return ( return (
<div key={index} className="text-center"> <SelectableBlock
key={index}
id={`stat-${index}`}
isActive={selectedBlockId === `stat-${index}`}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<div className="text-center rounded-xl px-4 py-6">
{IconComponent && ( {IconComponent && (
<IconComponent className={`w-12 h-12 mx-auto mb-4 ${getRandomColor()}`} /> <IconComponent
className={`w-12 h-12 mx-auto mb-4 ${getIconColor(index)}`}
/>
)} )}
<div className="text-4xl font-bold text-gray-900 mb-2">{displayValue}</div> <div className="text-4xl font-bold text-gray-900 mb-2">{stat.value}</div>
<div className="text-gray-600">{translate('::' + stat.labelKey)}</div> <div className="text-gray-600">{stat.label}</div>
</div> </div>
</SelectableBlock>
) )
})} })}
</div> </div>
@ -106,30 +527,60 @@ const About: React.FC = () => {
<div className="py-6"> <div className="py-6">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="mb-6"> <div className="mb-6">
<SelectableBlock
id="descriptions"
isActive={selectedBlockId === 'descriptions'}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<div className="p-5 mx-auto mx-auto text-gray-800 text-lg leading-relaxed shadow-md bg-white border-l-4 border-blue-600"> <div className="p-5 mx-auto mx-auto text-gray-800 text-lg leading-relaxed shadow-md bg-white border-l-4 border-blue-600">
<p>{translate('::' + about?.descriptionsDto[0])}</p> <p>{content?.descriptions[0]?.text}</p>
<p className="text-center p-5 text-blue-800"> <p className="text-center p-5 text-blue-800">{content?.descriptions[1]?.text}</p>
{translate('::' + about?.descriptionsDto[1])} <p>{content?.descriptions[2]?.text}</p>
</p> <p className="text-center p-5 text-blue-800">{content?.descriptions[3]?.text}</p>
<p>{translate('::' + about?.descriptionsDto[2])}</p>
<p className="text-center p-5 text-blue-800">
{translate('::' + about?.descriptionsDto[3])}
</p>
</div> </div>
</SelectableBlock>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{about?.sectionsDto.map((section, index) => ( {content?.sections.map((section, index) => (
<div key={index} className="bg-white p-8 rounded-xl shadow-lg"> <SelectableBlock
<h3 className="text-2xl font-bold text-gray-900 mb-4"> key={index}
{translate('::' + section.key)} id={`section-${index}`}
</h3> isActive={selectedBlockId === `section-${index}`}
<p className="text-gray-700">{translate('::' + section.descKey)}</p> isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<div className="bg-white p-8 rounded-xl shadow-lg">
<h3 className="text-2xl font-bold text-gray-900 mb-4">{section.title}</h3>
<p className="text-gray-700">{section.description}</p>
</div> </div>
</SelectableBlock>
))} ))}
</div> </div>
</div> </div>
</div> </div>
<DesignerDrawer
isOpen={isDesignMode && isPanelVisible}
selection={selectedSelection}
pageTitle="About"
selectedLanguage={selectedLanguage}
languages={
languageOptions.length > 0
? languageOptions
: supportedLanguages.map((language) => ({
key: language,
cultureName: language,
displayName: language.toUpperCase(),
}))
}
onClose={() => setIsPanelVisible(false)}
onSave={handleSaveAndExit}
onLanguageChange={handleLanguageChange}
onReset={resetContent}
onFieldChange={handleFieldChange}
/>
</div> </div>
</> </>
) )

View file

@ -1,27 +1,183 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { import { FaCheckCircle } from 'react-icons/fa'
FaCode, import { Link, useNavigate } from 'react-router-dom'
FaGlobe,
FaServer,
FaUsers,
FaShieldAlt,
FaCog,
FaCheckCircle,
} from 'react-icons/fa'
import { Link } from 'react-router-dom'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { ServiceDto } from '@/proxy/services/models' import { ServiceDto } from '@/proxy/services/models'
import { getServices } from '@/services/service.service' import { getServices, saveServicesPage } from '@/services/service.service'
import navigationIcon from '@/proxy/menus/navigation-icon.config' import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { Loading } from '@/components/shared' import { Loading } from '@/components/shared'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { useStoreState } from '@/store'
import { useStoreActions } from '@/store'
import DesignerDrawer from './designer/DesignerDrawer'
import SelectableBlock from './designer/SelectableBlock'
import { DesignerSelection } from './designer/types'
import { useDesignerState } from './designer/useDesignerState'
interface ServiceCardContent {
icon: string
title: string
titleKey: string
description: string
descriptionKey?: string
features: Array<{
key: string
value: string
}>
}
interface SupportCardContent {
icon: string
title: string
titleKey: string
features: Array<{
key: string
value: string
}>
}
interface ServicesContent {
heroTitle: string
heroTitleKey: string
heroSubtitle: string
heroSubtitleKey: string
heroImage: string
heroImageKey: string
serviceItems: ServiceCardContent[]
supportTitle: string
supportTitleKey: string
supportItems: SupportCardContent[]
supportButtonLabel: string
supportButtonLabelKey: string
ctaTitle: string
ctaTitleKey: string
ctaDescription: string
ctaDescriptionKey: string
ctaButtonLabel: string
ctaButtonLabelKey: string
}
const SERVICES_HERO_IMAGE =
'https://images.pexels.com/photos/3183173/pexels-photo-3183173.jpeg?auto=compress&cs=tinysrgb&w=1920'
const SERVICES_HERO_TITLE_KEY = 'Public.services.title'
const SERVICES_HERO_SUBTITLE_KEY = 'Public.services.subtitle'
const SERVICES_HERO_IMAGE_KEY = 'Public.services.heroImage'
const SERVICES_SUPPORT_TITLE_KEY = 'Public.services.support.title'
const SERVICES_SUPPORT_BUTTON_KEY = 'Public.services.support.contactButton'
const SERVICES_CTA_TITLE_KEY = 'Public.services.cta.title'
const SERVICES_CTA_DESCRIPTION_KEY = 'Public.services.cta.description'
const SERVICES_CTA_BUTTON_KEY = 'Public.services.support.contactButton'
function isLikelyLocalizationKey(value?: string) {
return Boolean(value && /^[A-Za-z0-9_.-]+$/.test(value) && value.includes('.'))
}
function resolveLocalizedValue(
translate: (key: string) => string,
keyOrValue: string | undefined,
fallback = '',
) {
if (!keyOrValue) {
return fallback
}
if (!isLikelyLocalizationKey(keyOrValue)) {
return keyOrValue
}
const translatedValue = translate('::' + keyOrValue)
return translatedValue === keyOrValue ? fallback || keyOrValue : translatedValue
}
function buildServicesContent(
services: ServiceDto[],
translate: (key: string) => string,
): ServicesContent {
return {
heroTitle: resolveLocalizedValue(translate, SERVICES_HERO_TITLE_KEY, 'Services'),
heroTitleKey: SERVICES_HERO_TITLE_KEY,
heroSubtitle: resolveLocalizedValue(translate, SERVICES_HERO_SUBTITLE_KEY),
heroSubtitleKey: SERVICES_HERO_SUBTITLE_KEY,
heroImage: resolveLocalizedValue(translate, SERVICES_HERO_IMAGE_KEY, SERVICES_HERO_IMAGE),
heroImageKey: SERVICES_HERO_IMAGE_KEY,
serviceItems: services
.filter((item) => item.type === 'service')
.map((item, index) => ({
icon: item.icon || '',
title: resolveLocalizedValue(translate, item.title, item.title),
titleKey:
(isLikelyLocalizationKey(item.title) ? item.title : undefined) ||
`Public.services.dynamic.service.${index + 1}.title`,
description: resolveLocalizedValue(translate, item.description, item.description || ''),
descriptionKey:
(item.description && isLikelyLocalizationKey(item.description) ? item.description : undefined) ||
`Public.services.dynamic.service.${index + 1}.description`,
features: (item.features ?? []).map((feature, featureIndex) => ({
key:
(isLikelyLocalizationKey(feature) ? feature : undefined) ||
`Public.services.dynamic.service.${index + 1}.feature.${featureIndex + 1}`,
value: resolveLocalizedValue(translate, feature, feature),
})),
})),
supportTitle: resolveLocalizedValue(translate, SERVICES_SUPPORT_TITLE_KEY),
supportTitleKey: SERVICES_SUPPORT_TITLE_KEY,
supportItems: services
.filter((item) => item.type === 'support')
.map((item, index) => ({
icon: item.icon || '',
title: resolveLocalizedValue(translate, item.title, item.title),
titleKey:
(isLikelyLocalizationKey(item.title) ? item.title : undefined) ||
`Public.services.dynamic.support.${index + 1}.title`,
features: (item.features ?? []).map((feature, featureIndex) => ({
key:
(isLikelyLocalizationKey(feature) ? feature : undefined) ||
`Public.services.dynamic.support.${index + 1}.feature.${featureIndex + 1}`,
value: resolveLocalizedValue(translate, feature, feature),
})),
})),
supportButtonLabel: resolveLocalizedValue(translate, SERVICES_SUPPORT_BUTTON_KEY),
supportButtonLabelKey: SERVICES_SUPPORT_BUTTON_KEY,
ctaTitle: resolveLocalizedValue(translate, SERVICES_CTA_TITLE_KEY),
ctaTitleKey: SERVICES_CTA_TITLE_KEY,
ctaDescription: resolveLocalizedValue(translate, SERVICES_CTA_DESCRIPTION_KEY),
ctaDescriptionKey: SERVICES_CTA_DESCRIPTION_KEY,
ctaButtonLabel: resolveLocalizedValue(translate, SERVICES_CTA_BUTTON_KEY),
ctaButtonLabelKey: SERVICES_CTA_BUTTON_KEY,
}
}
const Services: React.FC = () => { const Services: React.FC = () => {
const { translate } = useLocalization() const { translate } = useLocalization()
const navigate = useNavigate()
const { setLang } = useStoreActions((actions) => actions.locale)
const { getConfig } = useStoreActions((actions) => actions.abpConfig)
const configCultureName = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localeCurrentLang = useStoreState((state) => state.locale?.currentLang)
const currentLanguage = configCultureName || localeCurrentLang || 'tr'
const abpLanguages = useStoreState((state) => state.abpConfig.config?.localization.languages) || []
const languageOptions = abpLanguages
.filter((language) => Boolean(language.cultureName))
.map((language) => {
const cultureName = language.cultureName || 'tr'
return {
key: cultureName.toLowerCase().split('-')[0],
cultureName,
displayName: language.displayName || cultureName,
}
})
const languagesFromConfig = languageOptions.map((language) => language.key)
const editorLanguages = Array.from(
new Set((languagesFromConfig.length > 0 ? languagesFromConfig : [currentLanguage]).filter(Boolean)),
)
const [services, setServices] = useState<ServiceDto[]>([]) const [services, setServices] = useState<ServiceDto[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isPanelVisible, setIsPanelVisible] = useState(true)
const iconColors = [ const iconColors = [
'text-blue-600', 'text-blue-600',
@ -32,10 +188,25 @@ const Services: React.FC = () => {
'text-indigo-600', 'text-indigo-600',
] ]
function getRandomColor() { function getIconColor(index: number) {
return iconColors[Math.floor(Math.random() * iconColors.length)] return iconColors[index % iconColors.length]
} }
const initialContent = !loading ? buildServicesContent(services, translate) : null
const {
content,
isDesignMode,
selectedBlockId,
selectedLanguage,
supportedLanguages,
setContent,
setSelectedBlockId,
resetContent,
} = useDesignerState<ServicesContent>('services', initialContent, {
currentLanguage,
supportedLanguages: editorLanguages,
})
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
@ -47,7 +218,7 @@ const Services: React.FC = () => {
title: service.title, title: service.title,
description: service.description, description: service.description,
type: service.type, type: service.type,
features: service.features, features: service.features ?? [],
})) }))
setServices(items) setServices(items)
@ -61,6 +232,356 @@ const Services: React.FC = () => {
fetchServices() fetchServices()
}, []) }, [])
const updateContent = (updater: (current: ServicesContent) => ServicesContent) => {
setContent((current) => {
if (!current) {
return current
}
return updater(current)
})
}
const handleFieldChange = (fieldKey: string, value: string | string[]) => {
updateContent((current) => {
if (
fieldKey === 'heroTitle' ||
fieldKey === 'heroSubtitle' ||
fieldKey === 'heroImage' ||
fieldKey === 'supportTitle' ||
fieldKey === 'supportButtonLabel' ||
fieldKey === 'ctaTitle' ||
fieldKey === 'ctaDescription' ||
fieldKey === 'ctaButtonLabel'
) {
return {
...current,
[fieldKey]: value as string,
}
}
if (selectedBlockId?.startsWith('service-')) {
const index = Number(selectedBlockId.replace('service-', ''))
const serviceItems = [...current.serviceItems]
const nextItem = { ...serviceItems[index] }
if (fieldKey.startsWith('feature-')) {
const featureIndex = Number(fieldKey.replace('feature-', ''))
const features = [...(nextItem.features ?? [])]
const existingFeature = features[featureIndex]
if (!Number.isNaN(featureIndex)) {
features[featureIndex] = {
key:
existingFeature?.key ||
`Public.services.dynamic.service.${index + 1}.feature.${featureIndex + 1}`,
value: value as string,
}
}
nextItem.features = features
} else if (fieldKey === 'features') {
nextItem.features = (value as string[]).map((feature, featureIndex) => ({
key:
nextItem.features[featureIndex]?.key ||
`Public.services.dynamic.service.${index + 1}.feature.${featureIndex + 1}`,
value: feature,
}))
} else {
nextItem[fieldKey as keyof ServiceCardContent] = value as never
}
serviceItems[index] = nextItem
return {
...current,
serviceItems,
}
}
if (selectedBlockId?.startsWith('support-')) {
const index = Number(selectedBlockId.replace('support-', ''))
const supportItems = [...current.supportItems]
const nextItem = { ...supportItems[index] }
if (fieldKey.startsWith('feature-')) {
const featureIndex = Number(fieldKey.replace('feature-', ''))
const features = [...(nextItem.features ?? [])]
const existingFeature = features[featureIndex]
if (!Number.isNaN(featureIndex)) {
features[featureIndex] = {
key:
existingFeature?.key ||
`Public.services.dynamic.support.${index + 1}.feature.${featureIndex + 1}`,
value: value as string,
}
}
nextItem.features = features
} else if (fieldKey === 'features') {
nextItem.features = (value as string[]).map((feature, featureIndex) => ({
key:
nextItem.features[featureIndex]?.key ||
`Public.services.dynamic.support.${index + 1}.feature.${featureIndex + 1}`,
value: feature,
}))
} else {
nextItem[fieldKey as keyof SupportCardContent] = value as never
}
supportItems[index] = nextItem
return {
...current,
supportItems,
}
}
return current
})
}
const selectedSelection: DesignerSelection | null = React.useMemo(() => {
if (!content || !selectedBlockId) {
return null
}
if (selectedBlockId === 'hero') {
return {
id: 'hero',
title: content.heroTitleKey,
description: translate('::Public.designer.desc4'),
fields: [
{
key: 'heroTitle',
label: content.heroTitleKey,
type: 'text',
value: content.heroTitle,
},
{
key: 'heroSubtitle',
label: content.heroSubtitleKey,
type: 'textarea',
value: content.heroSubtitle,
},
{
key: 'heroImage',
label: content.heroImageKey,
type: 'image',
value: content.heroImage,
},
],
}
}
if (selectedBlockId === 'support-heading') {
return {
id: 'support-heading',
title: content.supportTitleKey,
description: translate('::Public.designer.desc3'),
fields: [
{
key: 'supportTitle',
label: content.supportTitleKey,
type: 'text',
value: content.supportTitle,
},
{
key: 'supportButtonLabel',
label: content.supportButtonLabelKey,
type: 'text',
value: content.supportButtonLabel,
},
],
}
}
if (selectedBlockId === 'cta') {
return {
id: 'cta',
title: content.ctaTitleKey,
description: translate('::Public.designer.desc2'),
fields: [
{
key: 'ctaTitle',
label: content.ctaTitleKey,
type: 'text',
value: content.ctaTitle,
},
{
key: 'ctaDescription',
label: content.ctaDescriptionKey,
type: 'textarea',
value: content.ctaDescription,
},
{
key: 'ctaButtonLabel',
label: content.ctaButtonLabelKey,
type: 'text',
value: content.ctaButtonLabel,
},
],
}
}
if (selectedBlockId.startsWith('service-')) {
const index = Number(selectedBlockId.replace('service-', ''))
const item = content.serviceItems[index]
if (!item) {
return null
}
return {
id: selectedBlockId,
title: item.titleKey,
description: translate('::Public.designer.desc1'),
fields: [
{
key: 'icon',
label: translate('::Public.designer.ikonAnahtari'),
type: 'icon',
value: item.icon,
placeholder: 'Ornek: FaServer',
},
{
key: 'title',
label: item.titleKey,
type: 'text',
value: item.title,
},
{
key: 'description',
label: item.descriptionKey || `Public.services.dynamic.service.${index + 1}.description`,
type: 'textarea',
value: item.description,
},
...item.features.map((feature, featureIndex) => ({
key: `feature-${featureIndex}`,
label:
feature.key || `Public.services.dynamic.service.${index + 1}.feature.${featureIndex + 1}`,
type: 'text' as const,
value: feature.value,
})),
],
}
}
if (selectedBlockId.startsWith('support-')) {
const index = Number(selectedBlockId.replace('support-', ''))
const item = content.supportItems[index]
if (!item) {
return null
}
return {
id: selectedBlockId,
title: item.titleKey,
description: translate('::Public.designer.desc1'),
fields: [
{
key: 'icon',
label: translate('::Public.designer.ikonAnahtari'),
type: 'icon',
value: item.icon,
placeholder: 'Ornek: FaLifeRing',
},
{
key: 'title',
label: item.titleKey,
type: 'text',
value: item.title,
},
...item.features.map((feature, featureIndex) => ({
key: `feature-${featureIndex}`,
label:
feature.key || `Public.services.dynamic.support.${index + 1}.feature.${featureIndex + 1}`,
type: 'text' as const,
value: feature.value,
})),
],
}
}
return null
}, [content, selectedBlockId])
const handleSaveAndExit = async () => {
if (!content || isSaving) {
return
}
setIsSaving(true)
try {
await saveServicesPage({
cultureName: selectedLanguage,
heroTitleKey: content.heroTitleKey,
heroTitleValue: content.heroTitle,
heroSubtitleKey: content.heroSubtitleKey,
heroSubtitleValue: content.heroSubtitle,
heroImageKey: content.heroImageKey,
heroImageValue: content.heroImage,
supportTitleKey: content.supportTitleKey,
supportTitleValue: content.supportTitle,
supportButtonLabelKey: content.supportButtonLabelKey,
supportButtonLabelValue: content.supportButtonLabel,
ctaTitleKey: content.ctaTitleKey,
ctaTitleValue: content.ctaTitle,
ctaDescriptionKey: content.ctaDescriptionKey,
ctaDescriptionValue: content.ctaDescription,
ctaButtonLabelKey: content.ctaButtonLabelKey,
ctaButtonLabelValue: content.ctaButtonLabel,
serviceItems: content.serviceItems.map((item, index) => ({
icon: item.icon,
titleKey: item.titleKey || `Public.services.dynamic.service.${index + 1}.title`,
titleValue: item.title,
descriptionKey:
item.descriptionKey || `Public.services.dynamic.service.${index + 1}.description`,
descriptionValue: item.description,
type: 'service' as const,
features: item.features.map((feature, featureIndex) => ({
key:
feature.key || `Public.services.dynamic.service.${index + 1}.feature.${featureIndex + 1}`,
value: feature.value,
})),
})),
supportItems: content.supportItems.map((item, index) => ({
icon: item.icon,
titleKey: item.titleKey || `Public.services.dynamic.support.${index + 1}.title`,
titleValue: item.title,
type: 'support' as const,
features: item.features.map((feature, featureIndex) => ({
key:
feature.key || `Public.services.dynamic.support.${index + 1}.feature.${featureIndex + 1}`,
value: feature.value,
})),
})),
})
await getConfig(false)
setSelectedBlockId(null)
} catch (error) {
console.error('Services tasarimi kaydedilemedi:', error)
} finally {
setIsSaving(false)
}
}
const handleLanguageChange = (language: string) => {
// Wait for global locale/config refresh; designer language follows store state.
setLang(language)
}
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId)
if (!isPanelVisible) {
setIsPanelVisible(true)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50"> <div className="flex items-center justify-center min-h-screen bg-gray-50">
@ -72,63 +593,78 @@ const Services: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className={`min-h-screen bg-gray-50 ${isDesignMode && isPanelVisible ? 'xl:pr-[420px]' : ''}`}>
<Helmet <Helmet
titleTemplate={`%s | ${APP_NAME}`} titleTemplate={`%s | ${APP_NAME}`}
title={translate('::App.Services')} title={translate('::App.Services')}
defaultTitle={APP_NAME} defaultTitle={APP_NAME}
></Helmet> ></Helmet>
{isDesignMode && !isPanelVisible && (
<button
type="button"
onClick={() => setIsPanelVisible(true)}
className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl"
>
{translate('::Public.designer.showPanel')}
</button>
)}
{/* Hero Section */} {/* Hero Section */}
<SelectableBlock
id="hero"
isActive={selectedBlockId === 'hero'}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<div className="relative bg-blue-900 text-white py-12"> <div className="relative bg-blue-900 text-white py-12">
<div <div
className="absolute inset-0 opacity-20" className="absolute inset-0 opacity-20"
style={{ style={{
backgroundImage: backgroundImage: `url("${content?.heroImage ?? SERVICES_HERO_IMAGE}")`,
'url("https://images.pexels.com/photos/3183173/pexels-photo-3183173.jpeg?auto=compress&cs=tinysrgb&w=1920")',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
></div> ></div>
<div className="container mx-auto pt-20 relative"> <div className="container mx-auto pt-20 relative">
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white"> <h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">{content?.heroTitle}</h1>
{translate('::Public.services.title')} <p className="text-xl max-w-3xl ml-4">{content?.heroSubtitle}</p>
</h1>
<p className="text-xl max-w-3xl ml-4">{translate('::Public.services.subtitle')}</p>
</div> </div>
</div> </div>
</SelectableBlock>
{/* Services Grid */} {/* Services Grid */}
<div className="py-16"> <div className="py-16">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{services {content?.serviceItems.map((service, index) => {
.filter((a) => a.type === 'service')
.map((service, index) => {
const IconComponent = navigationIcon[service.icon || ''] const IconComponent = navigationIcon[service.icon || '']
return ( return (
<div <SelectableBlock
key={index} key={index}
className="bg-white rounded-xl shadow-lg p-8 hover:shadow-xl transition-shadow" id={`service-${index}`}
isActive={selectedBlockId === `service-${index}`}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
> >
<div className="bg-white rounded-xl shadow-lg p-8 hover:shadow-xl transition-shadow">
<div className="mb-6"> <div className="mb-6">
{IconComponent && ( {IconComponent && (
<IconComponent className={`w-12 h-12 ${getRandomColor()}`} /> <IconComponent className={`w-12 h-12 ${getIconColor(index)}`} />
)} )}
</div> </div>
<h3 className="text-2xl font-bold text-gray-900 mb-4"> <h3 className="text-2xl font-bold text-gray-900 mb-4">{service.title}</h3>
{translate('::' + service.title)} <p className="text-gray-600 mb-6">{service.description}</p>
</h3>
<p className="text-gray-600 mb-6">{translate('::' + service.description)}</p>
<ul className="space-y-2"> <ul className="space-y-2">
{service.features.map((feature, fIndex) => ( {(service.features ?? []).map((feature, fIndex) => (
<li key={fIndex} className="flex items-center text-gray-700"> <li key={fIndex} className="flex items-center text-gray-700">
<span className="w-2 h-2 bg-blue-600 rounded-full mr-2"></span> <span className="w-2 h-2 bg-blue-600 rounded-full mr-2"></span>
{translate('::' + feature)} {feature.value}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
</SelectableBlock>
) )
})} })}
</div> </div>
@ -138,31 +674,38 @@ const Services: React.FC = () => {
{/* Support Plans */} {/* Support Plans */}
<div className="bg-white py-10"> <div className="bg-white py-10">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-10"> <SelectableBlock
{translate('::Public.services.support.title')} id="support-heading"
</h2> isActive={selectedBlockId === 'support-heading'}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<h2 className="text-3xl font-bold text-center mb-10">{content?.supportTitle}</h2>
</SelectableBlock>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{services {content?.supportItems.map((plan, index) => {
.filter((a) => a.type === 'support')
.map((plan, index) => {
const IconComponent = navigationIcon[plan.icon || ''] const IconComponent = navigationIcon[plan.icon || '']
return ( return (
<div <SelectableBlock
key={index} key={index}
className="bg-white rounded-xl shadow-lg p-8 border border-gray-200" id={`support-${index}`}
isActive={selectedBlockId === `support-${index}`}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
> >
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-200">
<div className="mb-6"> <div className="mb-6">
{IconComponent && ( {IconComponent && (
<IconComponent className={`w-12 h-12 ${getRandomColor()}`} /> <IconComponent className={`w-12 h-12 ${getIconColor(index)}`} />
)} )}
</div> </div>
<h3 className="text-xl font-bold mb-4">{translate('::' + plan.title)}</h3> <h3 className="text-xl font-bold mb-4">{plan.title}</h3>
<ul className="space-y-3 mb-8"> <ul className="space-y-3 mb-8">
{plan.features.map((feature, fIndex) => ( {(plan.features ?? []).map((feature, fIndex) => (
<li key={fIndex} className="flex items-center space-x-2 text-gray-700"> <li key={fIndex} className="flex items-center space-x-2 text-gray-700">
<FaCheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" /> <FaCheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
<span>{translate('::' + feature)}</span> <span>{feature.value}</span>
</li> </li>
))} ))}
</ul> </ul>
@ -170,9 +713,10 @@ const Services: React.FC = () => {
to={ROUTES_ENUM.public.contact} to={ROUTES_ENUM.public.contact}
className="block text-center bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors" className="block text-center bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
> >
{translate('::Public.services.support.contactButton')} {content?.supportButtonLabel}
</Link> </Link>
</div> </div>
</SelectableBlock>
) )
})} })}
</div> </div>
@ -180,22 +724,46 @@ const Services: React.FC = () => {
</div> </div>
{/* Call to Action */} {/* Call to Action */}
<SelectableBlock
id="cta"
isActive={selectedBlockId === 'cta'}
isDesignMode={isDesignMode}
onSelect={handleSelectBlock}
>
<div className="bg-blue-900 text-white py-16"> <div className="bg-blue-900 text-white py-16">
<div className="container mx-auto px-4 text-center"> <div className="container mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-6 text-white"> <h2 className="text-3xl font-bold mb-6 text-white">{content?.ctaTitle}</h2>
{translate('::Public.services.cta.title')} <p className="text-xl mb-8 max-w-2xl mx-auto">{content?.ctaDescription}</p>
</h2>
<p className="text-xl mb-8 max-w-2xl mx-auto">
{translate('::Public.services.cta.description')}
</p>
<Link <Link
to={ROUTES_ENUM.public.contact} to={ROUTES_ENUM.public.contact}
className="bg-white text-blue-900 px-8 py-3 rounded-lg font-semibold hover:bg-blue-50 transition-colors" className="bg-white text-blue-900 px-8 py-3 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
> >
{translate('::Public.services.support.contactButton')} {content?.ctaButtonLabel}
</Link> </Link>
</div> </div>
</div> </div>
</SelectableBlock>
<DesignerDrawer
isOpen={isDesignMode && isPanelVisible}
selection={selectedSelection}
pageTitle="Services"
selectedLanguage={selectedLanguage}
languages={
languageOptions.length > 0
? languageOptions
: supportedLanguages.map((language) => ({
key: language,
cultureName: language,
displayName: language.toUpperCase(),
}))
}
onClose={() => setIsPanelVisible(false)}
onSave={handleSaveAndExit}
onLanguageChange={handleLanguageChange}
onReset={resetContent}
onFieldChange={handleFieldChange}
/>
</div> </div>
) )
} }

View file

@ -0,0 +1,156 @@
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'
interface DesignerLanguageOption {
key: string
cultureName: string
displayName: string
}
function normalizeLanguageKey(language?: string) {
if (!language) {
return ''
}
return language.toLowerCase().split('-')[0]
}
interface DesignerDrawerProps {
isOpen: boolean
selection: DesignerSelection | null
pageTitle: string
selectedLanguage: string
languages: DesignerLanguageOption[]
onClose: () => void
onSave: () => void
onLanguageChange: (language: string) => void
onReset: () => void
onFieldChange: (fieldKey: string, value: string | string[]) => void
}
const DesignerDrawer: React.FC<DesignerDrawerProps> = ({
isOpen,
selection,
pageTitle,
selectedLanguage,
languages,
onClose,
onSave,
onLanguageChange,
onReset,
onFieldChange,
}) => {
const { translate } = useLocalization()
if (!isOpen) {
return null
}
return (
<div className="fixed inset-y-0 right-0 z-50 w-[420px] border-l border-slate-200 bg-white shadow-2xl">
<div className="flex h-full flex-col bg-white">
<div className="border-b border-slate-200 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">
{pageTitle} {translate('::Public.designer.title')}
</p>
<h3 className="mt-2 text-lg font-semibold text-slate-900">
{selection?.title ?? translate('::Public.designer.noSelection')}
</h3>
<p className="mt-1 text-sm text-slate-500">
{selection?.description ?? translate('::Public.designer.selectField')}
</p>
</div>
<Button variant="plain" size="sm" onClick={onClose}>
{translate('::Public.designer.close')}
</Button>
</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 ring-2 ring-sky-200'
: 'border-slate-200 hover:border-slate-400 hover:bg-slate-50'
}`}
>
<Avatar
size={22}
shape="circle"
src={`/img/countries/${language.cultureName}.png`}
/>
</button>
))}
</div>
</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">
{field.localizationKey || field.label}
</label>
{field.localizationKey && field.localizationKey !== field.label && (
<p className="mb-2 text-xs text-slate-500">{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 bg-slate-50 px-4 py-6 text-sm text-slate-500">
{translate('::Public.designer.noSelectionDetails')}
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-200 px-5 py-4">
<Button variant="solid" block={true} onClick={onSave}>
{translate('::Public.designer.save')}
</Button>
</div>
</div>
</div>
)
}
export default DesignerDrawer

View file

@ -0,0 +1,49 @@
import classNames from 'classnames'
import { PropsWithChildren } from 'react'
interface SelectableBlockProps extends PropsWithChildren {
id: string
isActive: boolean
isDesignMode: boolean
onSelect: (id: string) => void
className?: string
}
const SelectableBlock: React.FC<SelectableBlockProps> = ({
id,
isActive,
isDesignMode,
onSelect,
className,
children,
}) => {
if (!isDesignMode) {
return <>{children}</>
}
return (
<div
className={classNames(
'relative rounded-xl transition-all cursor-pointer',
isActive
? 'ring-2 ring-sky-500 ring-offset-4 ring-offset-white'
: 'ring-1 ring-sky-200/80 hover:ring-sky-400',
className,
)}
onClick={() => onSelect(id)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelect(id)
}
}}
>
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded-full"></div>
{children}
</div>
)
}
export default SelectableBlock

View file

@ -0,0 +1,18 @@
export type DesignerFieldType = 'text' | 'textarea' | 'image' | 'list' | 'icon'
export interface DesignerField {
key: string
label: string
type: DesignerFieldType
value: string | string[]
localizationKey?: string
placeholder?: string
rows?: number
}
export interface DesignerSelection {
id: string
title: string
description?: string
fields: DesignerField[]
}

View file

@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useSearchParams } from 'react-router-dom'
const DESIGNER_QUERY_KEY = 'design'
interface UseDesignerStateOptions {
currentLanguage?: string
supportedLanguages?: string[]
}
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__'
}
}
function normalizeLanguageKey(language?: string) {
if (!language) {
return 'tr'
}
return language.toLowerCase().split('-')[0]
}
function cloneValue<T>(value: T): T {
try {
return JSON.parse(JSON.stringify(value)) as T
} catch {
return value
}
}
export function useDesignerState<T>(
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]
}
return Array.from(new Set(base))
}, [currentLanguage, options?.supportedLanguages])
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>>({})
const selectedLanguagePrefix = normalizeLanguageKey(selectedLanguage)
const content =
byLanguage[selectedLanguage] ??
byLanguage[selectedLanguagePrefix] ??
byLanguage[currentLanguage] ??
initialContent ??
null
useEffect(() => {
setSelectedLanguage(normalizeLanguageKey(currentLanguage))
}, [currentLanguage])
useEffect(() => {
if (!initialContent) {
return
}
const nextSerializedInitial = serializeValue(initialContent)
setByLanguage((prev) => {
const currentValue = prev[currentLanguage]
const currentSerialized = serializeValue(currentValue)
const previousSeed = seededDefaultsRef.current[currentLanguage]
if (currentValue !== undefined && previousSeed !== undefined && currentSerialized !== previousSeed) {
return prev
}
if (currentSerialized === nextSerializedInitial) {
seededDefaultsRef.current[currentLanguage] = nextSerializedInitial
return prev
}
seededDefaultsRef.current[currentLanguage] = nextSerializedInitial
return {
...prev,
[currentLanguage]: cloneValue(initialContent),
}
})
if (!isDesignMode && selectedBlockId !== null) {
setSelectedBlockId(null)
}
}, [currentLanguage, initialContent, isDesignMode, selectedBlockId])
useEffect(() => {
if (!isDesignMode || !initialContent) {
return
}
const normalizedSelectedLanguage = normalizeLanguageKey(selectedLanguage)
const hasSelectedLanguageContent =
byLanguage[normalizedSelectedLanguage] !== undefined ||
byLanguage[selectedLanguage] !== undefined
if (hasSelectedLanguageContent) {
return
}
// 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])
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)
if (!next) {
return prev
}
return {
...prev,
[normalizeLanguageKey(selectedLanguage)]: cloneValue(next),
}
})
}
const resetContent = () => {
if (!initialContent) {
return
}
const normalizedSelectedLanguage = normalizeLanguageKey(selectedLanguage)
seededDefaultsRef.current[normalizedSelectedLanguage] = serializeValue(initialContent)
setByLanguage((prev) => ({
...prev,
[normalizedSelectedLanguage]: cloneValue(initialContent),
}))
}
return {
content,
isDesignMode,
selectedBlockId,
selectedLanguage,
supportedLanguages,
setContent,
setSelectedBlockId,
setSelectedLanguage,
resetContent,
}
}