From 4304229079b442d07eedc7c4dc07a54c1684a3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:54:25 +0300 Subject: [PATCH] =?UTF-8?q?About=20ve=20Services=20Designer=20komponenti?= =?UTF-8?q?=20tasarland=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Public/SavePublicPageContentInput.cs | 78 ++ .../Public/PublicAppService.cs | 163 +++- .../Seeds/LanguagesData.json | 80 +- .../Seeds/ListFormSeeder_Saas.cs | 319 -------- .../Seeds/MenusData.json | 25 +- .../Seeds/PermissionsData.json | 109 +-- ui/src/routes/route.constant.ts | 2 + ui/src/services/about.ts | 47 ++ ui/src/services/service.service.ts | 48 ++ ui/src/views/public/About.tsx | 547 +++++++++++-- ui/src/views/public/Services.tsx | 760 +++++++++++++++--- .../views/public/designer/DesignerDrawer.tsx | 156 ++++ .../views/public/designer/SelectableBlock.tsx | 49 ++ ui/src/views/public/designer/types.ts | 18 + .../views/public/designer/useDesignerState.ts | 183 +++++ 15 files changed, 2008 insertions(+), 576 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/Public/SavePublicPageContentInput.cs create mode 100644 ui/src/views/public/designer/DesignerDrawer.tsx create mode 100644 ui/src/views/public/designer/SelectableBlock.tsx create mode 100644 ui/src/views/public/designer/types.ts create mode 100644 ui/src/views/public/designer/useDesignerState.ts diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Public/SavePublicPageContentInput.cs b/api/src/Sozsoft.Platform.Application.Contracts/Public/SavePublicPageContentInput.cs new file mode 100644 index 0000000..85178d3 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Public/SavePublicPageContentInput.cs @@ -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 Stats { get; set; } = []; + public List Descriptions { get; set; } = []; + public List 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 ServiceItems { get; set; } = []; + public List 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 Features { get; set; } = []; +} + +public class SaveLocalizedTextInput +{ + public string Key { get; set; } + public string Value { get; set; } +} \ No newline at end of file diff --git a/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs b/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs index 5a24d7b..acbd24a 100644 --- a/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs +++ b/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs @@ -13,6 +13,8 @@ using System.Linq; using Volo.Abp.Application.Dtos; using System.Text.Json; using Volo.Abp.Identity; +using Sozsoft.Languages; +using Sozsoft.Languages.Entities; namespace Sozsoft.Platform.Public; @@ -31,6 +33,9 @@ public class PublicAppService : PlatformAppService private readonly IRepository _aboutRepository; private readonly IRepository _contactRepository; private readonly IIdentityUserRepository _identityUserRepository; + private readonly IRepository _languageKeyRepository; + private readonly IRepository _languageTextRepository; + private readonly LanguageTextAppService _languageTextAppService; public PublicAppService( IRepository serviceRepository, @@ -45,7 +50,10 @@ public class PublicAppService : PlatformAppService IRepository orderRepository, IRepository aboutRepository, IRepository contactRepository, - IIdentityUserRepository identityUserRepository + IIdentityUserRepository identityUserRepository, + IRepository languageKeyRepository, + IRepository languageTextRepository, + LanguageTextAppService languageTextAppService ) { _serviceRepository = serviceRepository; @@ -61,15 +69,114 @@ public class PublicAppService : PlatformAppService _aboutRepository = aboutRepository; _contactRepository = contactRepository; _identityUserRepository = identityUserRepository; + _languageKeyRepository = languageKeyRepository; + _languageTextRepository = languageTextRepository; + _languageTextAppService = languageTextAppService; } public async Task> 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>(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) { var demo = ObjectMapper.Map(input); @@ -298,6 +405,58 @@ public class PublicAppService : PlatformAppService return ObjectMapper.Map(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(); + } } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 45a08b8..26e6fa1 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -7910,7 +7910,7 @@ }, { "resourceName": "Platform", - "key": "Public.services.support.sms.features.Api", + "key": "Public.services.support.sms.features.api", "tr": "Api Desteği", "en": "Api Support" }, @@ -8058,6 +8058,84 @@ "tr": "Müşteri Yorumları", "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", "key": "App.DeveloperKit.Component.Description", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs index f364338..ae39585 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs @@ -5613,325 +5613,6 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency } #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 - { - 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 - { - 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 listFormName = AppCodes.Orders.Products; if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName)) diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json index c7e2151..6cdae0f 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json @@ -21,6 +21,13 @@ "routeType": "public", "authority": [] }, + { + "key": "about", + "path": "/about", + "componentPath": "@/views/public/About", + "routeType": "public", + "authority": [] + }, { "key": "products", "path": "/products", @@ -363,6 +370,20 @@ "componentPath": "@/views/report/DevexpressReportDesigner", "routeType": "protected", "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": [ @@ -623,7 +644,7 @@ "Code": "App.About", "DisplayName": "App.About", "Order": 1, - "Url": "/admin/list/App.About", + "Url": "/admin/public/about/designer", "Icon": "FcAbout", "RequiredPermissionName": "App.About", "IsDisabled": false @@ -633,7 +654,7 @@ "Code": "App.Services", "DisplayName": "App.Services", "Order": 2, - "Url": "/admin/list/App.Services", + "Url": "/admin/public/services/designer", "Icon": "FcServices", "RequiredPermissionName": "App.Services", "IsDisabled": false diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json index 6600e72..c060a4f 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json @@ -1560,60 +1560,6 @@ "MultiTenancySide": 2, "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", "Name": "App.Services", @@ -1623,60 +1569,7 @@ "MultiTenancySide": 2, "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", "Name": "App.Orders.Products", diff --git a/ui/src/routes/route.constant.ts b/ui/src/routes/route.constant.ts index dd76532..7f1cc99 100644 --- a/ui/src/routes/route.constant.ts +++ b/ui/src/routes/route.constant.ts @@ -2,11 +2,13 @@ export const ROUTES_ENUM = { public: { home: '/home', about: '/about', + aboutDesigner: '/about/designer', products: '/products', checkout: '/checkout', payment: '/payment', success: '/success', services: '/services', + servicesDesigner: '/services/designer', blog: '/blog', blogDetail: '/blog/:id', demo: '/demo', diff --git a/ui/src/services/about.ts b/ui/src/services/about.ts index bbdade5..c020b32 100644 --- a/ui/src/services/about.ts +++ b/ui/src/services/about.ts @@ -1,6 +1,42 @@ import { AboutDto } from '@/proxy/about/models' 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() { return apiService.fetchData( { @@ -10,3 +46,14 @@ export function getAbout() { { apiName: 'Default' }, ) } + +export function saveAboutPage(input: SaveAboutPageInput) { + return apiService.fetchData( + { + method: 'POST', + url: '/api/app/public/save-about-page', + data: input, + }, + { apiName: 'Default' }, + ) +} diff --git a/ui/src/services/service.service.ts b/ui/src/services/service.service.ts index 58cc781..88b9383 100644 --- a/ui/src/services/service.service.ts +++ b/ui/src/services/service.service.ts @@ -1,6 +1,43 @@ import apiService from './api.service' 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() { return apiService.fetchData( { @@ -10,3 +47,14 @@ export function getServices() { { apiName: 'Default' }, ) } + +export function saveServicesPage(input: SaveServicesPageInput) { + return apiService.fetchData( + { + method: 'POST', + url: '/api/app/public/save-services-page', + data: input, + }, + { apiName: 'Default' }, + ) +} diff --git a/ui/src/views/public/About.tsx b/ui/src/views/public/About.tsx index 4cd25e6..d8b16af 100644 --- a/ui/src/views/public/About.tsx +++ b/ui/src/views/public/About.tsx @@ -2,14 +2,154 @@ import React, { useEffect, useState } from 'react' import { Helmet } from 'react-helmet' import navigationIcon from '@/proxy/menus/navigation-icon.config' import { AboutDto } from '@/proxy/about/models' -import { getAbout } from '@/services/about' +import { getAbout, saveAboutPage } from '@/services/about' import { useLocalization } from '@/utils/hooks/useLocalization' import Loading from '@/components/shared/Loading' 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 { 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 [isSaving, setIsSaving] = useState(false) + const [isPanelVisible, setIsPanelVisible] = useState(true) const [about, setAbout] = useState() const iconColors = [ @@ -21,10 +161,25 @@ const About: React.FC = () => { 'text-indigo-600', ] - function getRandomColor() { - return iconColors[Math.floor(Math.random() * iconColors.length)] + function getIconColor(index: number) { + return iconColors[index % iconColors.length] } + const initialContent = !loading ? buildAboutContent(about, translate) : null + const { + content, + isDesignMode, + selectedBlockId, + selectedLanguage, + supportedLanguages, + setContent, + setSelectedBlockId, + resetContent, + } = useDesignerState('about', initialContent, { + currentLanguage, + supportedLanguages: editorLanguages, + }) + useEffect(() => { setLoading(true) const fetchServices = async () => { @@ -40,6 +195,249 @@ const About: React.FC = () => { 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) { return (
@@ -58,44 +456,67 @@ const About: React.FC = () => { defaultTitle={APP_NAME} > -
+
+ {isDesignMode && !isPanelVisible && ( + + )} + {/* Hero Section */} -
-
-
-

- {translate('::App.About')} -

-

{translate('::Public.about.subtitle')}

+ +
+
+
+

+ {content?.heroTitle} +

+

{content?.heroSubtitle}

+
-
+ {/* Stats Section */}
- {about?.statsDto.map((stat, index) => { + {content?.stats.map((stat, index) => { const IconComponent = navigationIcon[stat.icon || ''] - let displayValue = stat.value - let elementRef = undefined - return ( -
- {IconComponent && ( - - )} -
{displayValue}
-
{translate('::' + stat.labelKey)}
-
+ +
+ {IconComponent && ( + + )} +
{stat.value}
+
{stat.label}
+
+
) })}
@@ -106,30 +527,60 @@ const About: React.FC = () => {
-
-

{translate('::' + about?.descriptionsDto[0])}

-

- {translate('::' + about?.descriptionsDto[1])} -

-

{translate('::' + about?.descriptionsDto[2])}

-

- {translate('::' + about?.descriptionsDto[3])} -

-
+ +
+

{content?.descriptions[0]?.text}

+

{content?.descriptions[1]?.text}

+

{content?.descriptions[2]?.text}

+

{content?.descriptions[3]?.text}

+
+
- {about?.sectionsDto.map((section, index) => ( -
-

- {translate('::' + section.key)} -

-

{translate('::' + section.descKey)}

-
+ {content?.sections.map((section, index) => ( + +
+

{section.title}

+

{section.description}

+
+
))}
+ + 0 + ? languageOptions + : supportedLanguages.map((language) => ({ + key: language, + cultureName: language, + displayName: language.toUpperCase(), + })) + } + onClose={() => setIsPanelVisible(false)} + onSave={handleSaveAndExit} + onLanguageChange={handleLanguageChange} + onReset={resetContent} + onFieldChange={handleFieldChange} + />
) diff --git a/ui/src/views/public/Services.tsx b/ui/src/views/public/Services.tsx index 06437ec..401d6b1 100644 --- a/ui/src/views/public/Services.tsx +++ b/ui/src/views/public/Services.tsx @@ -1,27 +1,183 @@ import React, { useEffect, useState } from 'react' -import { - FaCode, - FaGlobe, - FaServer, - FaUsers, - FaShieldAlt, - FaCog, - FaCheckCircle, -} from 'react-icons/fa' -import { Link } from 'react-router-dom' +import { FaCheckCircle } from 'react-icons/fa' +import { Link, useNavigate } from 'react-router-dom' import { useLocalization } from '@/utils/hooks/useLocalization' import { ROUTES_ENUM } from '@/routes/route.constant' import { Helmet } from 'react-helmet' 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 { Loading } from '@/components/shared' 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 { 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([]) const [loading, setLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [isPanelVisible, setIsPanelVisible] = useState(true) const iconColors = [ 'text-blue-600', @@ -32,10 +188,25 @@ const Services: React.FC = () => { 'text-indigo-600', ] - function getRandomColor() { - return iconColors[Math.floor(Math.random() * iconColors.length)] + function getIconColor(index: number) { + return iconColors[index % iconColors.length] } + const initialContent = !loading ? buildServicesContent(services, translate) : null + const { + content, + isDesignMode, + selectedBlockId, + selectedLanguage, + supportedLanguages, + setContent, + setSelectedBlockId, + resetContent, + } = useDesignerState('services', initialContent, { + currentLanguage, + supportedLanguages: editorLanguages, + }) + useEffect(() => { setLoading(true) @@ -47,7 +218,7 @@ const Services: React.FC = () => { title: service.title, description: service.description, type: service.type, - features: service.features, + features: service.features ?? [], })) setServices(items) @@ -61,6 +232,356 @@ const Services: React.FC = () => { 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) { return (
@@ -72,63 +593,78 @@ const Services: React.FC = () => { } return ( -
+
+ {isDesignMode && !isPanelVisible && ( + + )} + {/* Hero Section */} -
-
-
-

- {translate('::Public.services.title')} -

-

{translate('::Public.services.subtitle')}

+ +
+
+
+

{content?.heroTitle}

+

{content?.heroSubtitle}

+
-
+ {/* Services Grid */}
- {services - .filter((a) => a.type === 'service') - .map((service, index) => { + {content?.serviceItems.map((service, index) => { const IconComponent = navigationIcon[service.icon || ''] return ( -
-
- {IconComponent && ( - - )} +
+
+ {IconComponent && ( + + )} +
+

{service.title}

+

{service.description}

+
    + {(service.features ?? []).map((feature, fIndex) => ( +
  • + + {feature.value} +
  • + ))} +
-

- {translate('::' + service.title)} -

-

{translate('::' + service.description)}

-
    - {service.features.map((feature, fIndex) => ( -
  • - - {translate('::' + feature)} -
  • - ))} -
-
+ ) })}
@@ -138,41 +674,49 @@ const Services: React.FC = () => { {/* Support Plans */}
-

- {translate('::Public.services.support.title')} -

+ +

{content?.supportTitle}

+
- {services - .filter((a) => a.type === 'support') - .map((plan, index) => { + {content?.supportItems.map((plan, index) => { const IconComponent = navigationIcon[plan.icon || ''] return ( -
-
- {IconComponent && ( - - )} +
+
+ {IconComponent && ( + + )} +
+

{plan.title}

+
    + {(plan.features ?? []).map((feature, fIndex) => ( +
  • + + {feature.value} +
  • + ))} +
+ + {content?.supportButtonLabel} +
-

{translate('::' + plan.title)}

-
    - {plan.features.map((feature, fIndex) => ( -
  • - - {translate('::' + feature)} -
  • - ))} -
- - {translate('::Public.services.support.contactButton')} - -
+ ) })}
@@ -180,22 +724,46 @@ const Services: React.FC = () => {
{/* Call to Action */} -
-
-

- {translate('::Public.services.cta.title')} -

-

- {translate('::Public.services.cta.description')} -

- - {translate('::Public.services.support.contactButton')} - + +
+
+

{content?.ctaTitle}

+

{content?.ctaDescription}

+ + {content?.ctaButtonLabel} + +
-
+ + + 0 + ? languageOptions + : supportedLanguages.map((language) => ({ + key: language, + cultureName: language, + displayName: language.toUpperCase(), + })) + } + onClose={() => setIsPanelVisible(false)} + onSave={handleSaveAndExit} + onLanguageChange={handleLanguageChange} + onReset={resetContent} + onFieldChange={handleFieldChange} + />
) } diff --git a/ui/src/views/public/designer/DesignerDrawer.tsx b/ui/src/views/public/designer/DesignerDrawer.tsx new file mode 100644 index 0000000..0773f06 --- /dev/null +++ b/ui/src/views/public/designer/DesignerDrawer.tsx @@ -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 = ({ + isOpen, + selection, + pageTitle, + selectedLanguage, + languages, + onClose, + onSave, + onLanguageChange, + onReset, + onFieldChange, +}) => { + const { translate } = useLocalization() + + if (!isOpen) { + return null + } + + return ( +
+
+
+
+
+

+ {pageTitle} {translate('::Public.designer.title')} +

+

+ {selection?.title ?? translate('::Public.designer.noSelection')} +

+

+ {selection?.description ?? translate('::Public.designer.selectField')} +

+
+ +
+ +
+ {languages.map((language) => ( + + ))} +
+
+ +
+ {selection?.fields.map((field) => { + const commonProps = { + value: Array.isArray(field.value) ? field.value.join('\n') : field.value, + placeholder: field.placeholder, + onChange: (event: React.ChangeEvent) => { + const nextValue = + field.type === 'list' + ? event.target.value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + : event.target.value + + onFieldChange(field.key, nextValue) + }, + } + + return ( +
+ + {field.localizationKey && field.localizationKey !== field.label && ( +

{field.label}

+ )} + {field.type === 'icon' ? ( + onFieldChange(field.key, iconKey)} + /> + ) : ( + + )} +
+ ) + })} + + {!selection && ( +
+ {translate('::Public.designer.noSelectionDetails')} +
+ )} +
+ +
+ +
+
+
+ ) +} + +export default DesignerDrawer diff --git a/ui/src/views/public/designer/SelectableBlock.tsx b/ui/src/views/public/designer/SelectableBlock.tsx new file mode 100644 index 0000000..f733e73 --- /dev/null +++ b/ui/src/views/public/designer/SelectableBlock.tsx @@ -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 = ({ + id, + isActive, + isDesignMode, + onSelect, + className, + children, +}) => { + if (!isDesignMode) { + return <>{children} + } + + return ( +
onSelect(id)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelect(id) + } + }} + > +
+ {children} +
+ ) +} + +export default SelectableBlock diff --git a/ui/src/views/public/designer/types.ts b/ui/src/views/public/designer/types.ts new file mode 100644 index 0000000..7f6e6b5 --- /dev/null +++ b/ui/src/views/public/designer/types.ts @@ -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[] +} diff --git a/ui/src/views/public/designer/useDesignerState.ts b/ui/src/views/public/designer/useDesignerState.ts new file mode 100644 index 0000000..da00295 --- /dev/null +++ b/ui/src/views/public/designer/useDesignerState.ts @@ -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(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(value: T): T { + try { + return JSON.parse(JSON.stringify(value)) as T + } catch { + return value + } +} + +export function useDesignerState( + 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>({}) + const [selectedBlockId, setSelectedBlockId] = useState(null) + const seededDefaultsRef = useRef>({}) + + 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, + } +}