About ve Services Designer komponenti tasarlandı
This commit is contained in:
parent
5cc3dc5ab3
commit
4304229079
15 changed files with 2008 additions and 576 deletions
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
<div className="relative bg-blue-900 text-white py-12">
|
<SelectableBlock
|
||||||
<div
|
id="hero"
|
||||||
className="absolute inset-0 opacity-20"
|
isActive={selectedBlockId === 'hero'}
|
||||||
style={{
|
isDesignMode={isDesignMode}
|
||||||
backgroundImage:
|
onSelect={handleSelectBlock}
|
||||||
'url("https://images.pexels.com/photos/3183183/pexels-photo-3183183.jpeg?auto=compress&cs=tinysrgb&w=1920")',
|
>
|
||||||
backgroundSize: 'cover',
|
<div className="relative bg-blue-900 text-white py-12">
|
||||||
backgroundPosition: 'center',
|
<div
|
||||||
}}
|
className="absolute inset-0 opacity-20"
|
||||||
></div>
|
style={{
|
||||||
<div className="container mx-auto pt-20 relative">
|
backgroundImage: `url("${content?.heroImage ?? ABOUT_HERO_IMAGE}")`,
|
||||||
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">
|
backgroundSize: 'cover',
|
||||||
{translate('::App.About')}
|
backgroundPosition: 'center',
|
||||||
</h1>
|
}}
|
||||||
<p className="text-xl max-w-3xl ml-4">{translate('::Public.about.subtitle')}</p>
|
></div>
|
||||||
|
<div className="container mx-auto pt-20 relative">
|
||||||
|
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">
|
||||||
|
{content?.heroTitle}
|
||||||
|
</h1>
|
||||||
|
<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
|
||||||
{IconComponent && (
|
key={index}
|
||||||
<IconComponent className={`w-12 h-12 mx-auto mb-4 ${getRandomColor()}`} />
|
id={`stat-${index}`}
|
||||||
)}
|
isActive={selectedBlockId === `stat-${index}`}
|
||||||
<div className="text-4xl font-bold text-gray-900 mb-2">{displayValue}</div>
|
isDesignMode={isDesignMode}
|
||||||
<div className="text-gray-600">{translate('::' + stat.labelKey)}</div>
|
onSelect={handleSelectBlock}
|
||||||
</div>
|
>
|
||||||
|
<div className="text-center rounded-xl px-4 py-6">
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent
|
||||||
|
className={`w-12 h-12 mx-auto mb-4 ${getIconColor(index)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-4xl font-bold text-gray-900 mb-2">{stat.value}</div>
|
||||||
|
<div className="text-gray-600">{stat.label}</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">
|
||||||
<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">
|
<SelectableBlock
|
||||||
<p>{translate('::' + about?.descriptionsDto[0])}</p>
|
id="descriptions"
|
||||||
<p className="text-center p-5 text-blue-800">
|
isActive={selectedBlockId === 'descriptions'}
|
||||||
{translate('::' + about?.descriptionsDto[1])}
|
isDesignMode={isDesignMode}
|
||||||
</p>
|
onSelect={handleSelectBlock}
|
||||||
<p>{translate('::' + about?.descriptionsDto[2])}</p>
|
>
|
||||||
<p className="text-center p-5 text-blue-800">
|
<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">
|
||||||
{translate('::' + about?.descriptionsDto[3])}
|
<p>{content?.descriptions[0]?.text}</p>
|
||||||
</p>
|
<p className="text-center p-5 text-blue-800">{content?.descriptions[1]?.text}</p>
|
||||||
</div>
|
<p>{content?.descriptions[2]?.text}</p>
|
||||||
|
<p className="text-center p-5 text-blue-800">{content?.descriptions[3]?.text}</p>
|
||||||
|
</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}
|
||||||
</div>
|
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>
|
||||||
|
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
<div className="relative bg-blue-900 text-white py-12">
|
<SelectableBlock
|
||||||
<div
|
id="hero"
|
||||||
className="absolute inset-0 opacity-20"
|
isActive={selectedBlockId === 'hero'}
|
||||||
style={{
|
isDesignMode={isDesignMode}
|
||||||
backgroundImage:
|
onSelect={handleSelectBlock}
|
||||||
'url("https://images.pexels.com/photos/3183173/pexels-photo-3183173.jpeg?auto=compress&cs=tinysrgb&w=1920")',
|
>
|
||||||
backgroundSize: 'cover',
|
<div className="relative bg-blue-900 text-white py-12">
|
||||||
backgroundPosition: 'center',
|
<div
|
||||||
}}
|
className="absolute inset-0 opacity-20"
|
||||||
></div>
|
style={{
|
||||||
<div className="container mx-auto pt-20 relative">
|
backgroundImage: `url("${content?.heroImage ?? SERVICES_HERO_IMAGE}")`,
|
||||||
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">
|
backgroundSize: 'cover',
|
||||||
{translate('::Public.services.title')}
|
backgroundPosition: 'center',
|
||||||
</h1>
|
}}
|
||||||
<p className="text-xl max-w-3xl ml-4">{translate('::Public.services.subtitle')}</p>
|
></div>
|
||||||
|
<div className="container mx-auto pt-20 relative">
|
||||||
|
<h1 className="text-5xl font-bold ml-4 mt-3 mb-2 text-white">{content?.heroTitle}</h1>
|
||||||
|
<p className="text-xl max-w-3xl ml-4">{content?.heroSubtitle}</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="mb-6">
|
<div className="bg-white rounded-xl shadow-lg p-8 hover:shadow-xl transition-shadow">
|
||||||
{IconComponent && (
|
<div className="mb-6">
|
||||||
<IconComponent className={`w-12 h-12 ${getRandomColor()}`} />
|
{IconComponent && (
|
||||||
)}
|
<IconComponent className={`w-12 h-12 ${getIconColor(index)}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900 mb-4">{service.title}</h3>
|
||||||
|
<p className="text-gray-600 mb-6">{service.description}</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(service.features ?? []).map((feature, fIndex) => (
|
||||||
|
<li key={fIndex} className="flex items-center text-gray-700">
|
||||||
|
<span className="w-2 h-2 bg-blue-600 rounded-full mr-2"></span>
|
||||||
|
{feature.value}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
</SelectableBlock>
|
||||||
{translate('::' + service.title)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6">{translate('::' + service.description)}</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{service.features.map((feature, fIndex) => (
|
|
||||||
<li key={fIndex} className="flex items-center text-gray-700">
|
|
||||||
<span className="w-2 h-2 bg-blue-600 rounded-full mr-2"></span>
|
|
||||||
{translate('::' + feature)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,41 +674,49 @@ 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="mb-6">
|
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-200">
|
||||||
{IconComponent && (
|
<div className="mb-6">
|
||||||
<IconComponent className={`w-12 h-12 ${getRandomColor()}`} />
|
{IconComponent && (
|
||||||
)}
|
<IconComponent className={`w-12 h-12 ${getIconColor(index)}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-4">{plan.title}</h3>
|
||||||
|
<ul className="space-y-3 mb-8">
|
||||||
|
{(plan.features ?? []).map((feature, fIndex) => (
|
||||||
|
<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" />
|
||||||
|
<span>{feature.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{content?.supportButtonLabel}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-4">{translate('::' + plan.title)}</h3>
|
</SelectableBlock>
|
||||||
<ul className="space-y-3 mb-8">
|
|
||||||
{plan.features.map((feature, fIndex) => (
|
|
||||||
<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" />
|
|
||||||
<span>{translate('::' + feature)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<Link
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{translate('::Public.services.support.contactButton')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,22 +724,46 @@ const Services: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Call to Action */}
|
{/* Call to Action */}
|
||||||
<div className="bg-blue-900 text-white py-16">
|
<SelectableBlock
|
||||||
<div className="container mx-auto px-4 text-center">
|
id="cta"
|
||||||
<h2 className="text-3xl font-bold mb-6 text-white">
|
isActive={selectedBlockId === 'cta'}
|
||||||
{translate('::Public.services.cta.title')}
|
isDesignMode={isDesignMode}
|
||||||
</h2>
|
onSelect={handleSelectBlock}
|
||||||
<p className="text-xl mb-8 max-w-2xl mx-auto">
|
>
|
||||||
{translate('::Public.services.cta.description')}
|
<div className="bg-blue-900 text-white py-16">
|
||||||
</p>
|
<div className="container mx-auto px-4 text-center">
|
||||||
<Link
|
<h2 className="text-3xl font-bold mb-6 text-white">{content?.ctaTitle}</h2>
|
||||||
to={ROUTES_ENUM.public.contact}
|
<p className="text-xl mb-8 max-w-2xl mx-auto">{content?.ctaDescription}</p>
|
||||||
className="bg-white text-blue-900 px-8 py-3 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
|
<Link
|
||||||
>
|
to={ROUTES_ENUM.public.contact}
|
||||||
{translate('::Public.services.support.contactButton')}
|
className="bg-white text-blue-900 px-8 py-3 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
|
||||||
</Link>
|
>
|
||||||
|
{content?.ctaButtonLabel}
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
ui/src/views/public/designer/DesignerDrawer.tsx
Normal file
156
ui/src/views/public/designer/DesignerDrawer.tsx
Normal 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
|
||||||
49
ui/src/views/public/designer/SelectableBlock.tsx
Normal file
49
ui/src/views/public/designer/SelectableBlock.tsx
Normal 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
|
||||||
18
ui/src/views/public/designer/types.ts
Normal file
18
ui/src/views/public/designer/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
183
ui/src/views/public/designer/useDesignerState.ts
Normal file
183
ui/src/views/public/designer/useDesignerState.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue