diff --git a/.gitattributes b/.gitattributes
index b175f24ba..4689f6b54 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,3 +5,15 @@
# Keep LF line endings in webroot assets files. Otherwise, building them under Windows would change the line endings to CLRF and cause changes without actually editing the source files.
**/wwwroot/**/*.js text eol=lf
**/wwwroot/**/*.css text eol=lf
+
+# Ensure binary files are never treated as text.
+*.webp binary
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.woff binary
+*.woff2 binary
+*.ttf binary
+*.eot binary
diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx
index 5fe63b98f..9fad68722 100644
--- a/CrestApps.OrchardCore.slnx
+++ b/CrestApps.OrchardCore.slnx
@@ -60,6 +60,7 @@
+
diff --git a/gulpfile.js b/gulpfile.js
index 8e7b94969..496688fc4 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -352,7 +352,7 @@ function buildJsPipeline(assetGroup, doConcat, doRebuild) {
}
function buildCopyPipeline(assetGroup, doRebuild) {
- var stream = gulp.src(assetGroup.inputPaths);
+ var stream = gulp.src(assetGroup.inputPaths, { encoding: false });
if (!doRebuild) {
stream = stream.pipe(newer(assetGroup.outputDir))
diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/CrestApps.OrchardCore.Omnichannel.Core.csproj b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/CrestApps.OrchardCore.Omnichannel.Core.csproj
index 999f54a6a..7d1be6727 100644
--- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/CrestApps.OrchardCore.Omnichannel.Core.csproj
+++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/CrestApps.OrchardCore.Omnichannel.Core.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberInfoPart.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberInfoPart.cs
index 07c453600..ea9b8502b 100644
--- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberInfoPart.cs
+++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberInfoPart.cs
@@ -1,4 +1,5 @@
-using OrchardCore.ContentFields.Fields;
+using CrestApps.OrchardCore.ContentFields.Fields;
+using OrchardCore.ContentFields.Fields;
using OrchardCore.ContentManagement;
namespace CrestApps.OrchardCore.Omnichannel.Core.Models;
@@ -11,7 +12,7 @@ public sealed class PhoneNumberInfoPart : ContentPart
///
/// Gets or sets the number.
///
- public TextField Number { get; set; }
+ public PhoneField Number { get; set; }
///
/// Gets or sets the extension.
diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md
index 7fe438958..237f3b5f6 100644
--- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md
+++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md
@@ -111,6 +111,9 @@ Large parts of the reusable AI infrastructure are no longer implemented only ins
- Azure AI Search index editors for **AI Documents** and **AI Memory** now normalize their built-in managed mappings on load and update so repeated edits do not keep appending duplicate `Content`, `Embedding`, and related managed fields, while custom mappings remain preserved
- The **Clear saved AI memory** action in the current user's profile editor now uses Orchard Core's standard admin confirmation dialog and clears the user's saved memory entries by filtering the store's persisted records for the current user before removing the corresponding indexed AI memory documents
- Content Transfer content-type settings now default **Allow Bulk Import** and **Allow Bulk Export** to enabled, so content types participate by default and can explicitly opt out by turning either setting off
+- Added a new `CrestApps.OrchardCore.ContentFields` module with a `PhoneField` content field that stores a phone number in E.164 format together with the ISO country code and national number, uses `intl-tel-input` for country-aware editing, and validates input through `IPhoneNumberService`
+- Omnichannel Management now migrates `PhoneNumberInfoPart.Number` from a `TextField` to a `PhoneField` so phone numbers persist with the correct country flag
+- Moved the shared `intl-tel-input` library assets and Orchard resource-manager registration into `CrestApps.OrchardCore.Resources`, while `CrestApps.OrchardCore.ContentFields` depends on that feature for the phone editor
- Content Transfer now keeps CSV support in the base `CrestApps.OrchardCore.ContentTransfer` feature and moves optional `.xlsx` support into `CrestApps.OrchardCore.ContentTransfer.OpenXml`, with the import and export UI showing only the file extensions enabled by the current tenant feature set
- Content Transfer now welds lazily created parts onto the parent content item during column discovery, import, and export so `.xlsx` exports no longer fail with a `System.Text.Json` node-cycle exception when a content item does not already materialize one of its configured parts
- Content Transfer imports now commit inline status changes before their deferred background jobs run, use **Pending**, **Paused**, and **Deleting** import states in the admin list, surface **Resume import** and **Pause import** actions instead of the earlier cancel/process wording, and migrate older canceled import rows to the paused state
diff --git a/src/CrestApps.Docs/docs/modules/content-fields.md b/src/CrestApps.Docs/docs/modules/content-fields.md
new file mode 100644
index 000000000..b481c4da8
--- /dev/null
+++ b/src/CrestApps.Docs/docs/modules/content-fields.md
@@ -0,0 +1,78 @@
+---
+sidebar_label: Content Fields
+sidebar_position: 2
+title: Content Fields
+description: Adds custom Orchard Core content fields maintained by CrestApps.
+---
+
+| | |
+| --- | --- |
+| **Feature Name** | CrestApps Content Fields |
+| **Feature ID** | `CrestApps.OrchardCore.ContentFields` |
+
+Provides custom Orchard Core content fields maintained by CrestApps.
+
+## Overview
+
+This module adds custom content fields for Orchard Core that extend the built-in field library with additional functionality. Each field ships with its own display driver, settings, edit and display views.
+
+## Included fields
+
+### PhoneField
+
+A content field that stores an international phone number together with its ISO country code so the correct country flag is always displayed when the field is edited again.
+
+The field uses the [intl-tel-input](https://intl-tel-input.com/) library (provided by `CrestApps.OrchardCore.Resources`) to give editors a country-aware phone number input with flag dropdown and automatic formatting.
+
+#### Stored properties
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `PhoneNumber` | `string` | The full phone number in E.164 format (e.g. `+14155552671`). |
+| `CountryCode` | `string` | ISO 3166-1 alpha-2 country code (e.g. `US`, `CA`). Stored separately because some countries share a calling code (e.g. US and CA both use `+1`). |
+| `NationalNumber` | `string` | The national (local) portion of the number without the country calling code (e.g. `4155552671`). |
+
+#### Settings
+
+| Setting | Type | Default | Description |
+| --- | --- | --- | --- |
+| `Hint` | `string` | `null` | Help text displayed below the field. |
+| `Required` | `bool` | `false` | Whether the field is required. |
+| `InitialCountryMode` | `InitialCountryMode` | `Globe` | Controls which country flag is pre-selected when the field is empty. See [Initial country modes](#initial-country-modes). |
+| `SpecificCountryCode` | `string` | `null` | ISO country code used when `InitialCountryMode` is `Specific` (e.g. `US`). |
+
+#### Initial country modes
+
+| Mode | Behavior |
+| --- | --- |
+| **Globe** | Shows the globe icon without pre-selecting any country. This is the default. |
+| **Current culture** | Resolves the country from the current request culture's region (e.g. `en-US` resolves to `US`). |
+| **Specific** | Always pre-selects the country configured in the **Country** dropdown. |
+
+#### Adding PhoneField via migration
+
+```csharp
+await _contentDefinitionManager.AlterPartDefinitionAsync("MyPart", part => part
+ .WithField("Phone", field => field
+ .OfType("PhoneField")
+ .WithDisplayName("Phone Number")
+ .WithPosition("1")
+ .WithSettings(new PhoneFieldSettings
+ {
+ Required = true,
+ InitialCountryMode = InitialCountryMode.Specific,
+ SpecificCountryCode = "US",
+ Hint = "Enter a phone number with country code.",
+ })
+ )
+);
+```
+
+#### Server-side validation
+
+When the field value is submitted, the display driver uses `IPhoneNumberService` (from `CrestApps.OrchardCore.PhoneNumbers`) to validate that the entered number is a well-formed phone number. Invalid numbers produce a model-state error and the editor is re-displayed.
+
+## Notes
+
+- The shared `intl-tel-input` script and stylesheet are registered by `CrestApps.OrchardCore.Resources`.
+- The Omnichannel Management module depends on this feature for `PhoneNumberInfoPart.Number`.
diff --git a/src/CrestApps.Docs/docs/modules/index.md b/src/CrestApps.Docs/docs/modules/index.md
index 8f8064d32..fc9ad9e18 100644
--- a/src/CrestApps.Docs/docs/modules/index.md
+++ b/src/CrestApps.Docs/docs/modules/index.md
@@ -14,6 +14,7 @@ CrestApps provides a set of standard modules that enhance core Orchard Core CMS
| Module | Feature ID | Description |
|--------|-----------|-------------|
| [Content Access Control](content-access-control) | `CrestApps.OrchardCore.ContentAccessControl` | Role-based content access restrictions |
+| [Content Fields](content-fields) | `CrestApps.OrchardCore.ContentFields` | Custom Orchard Core content field editors |
| [Content Transfer](content-transfer) | `CrestApps.OrchardCore.ContentTransfer` | Bulk Excel import and export for content items |
| [DNC Registry](dnc-registry) | `CrestApps.OrchardCore.DncRegistry` | National do-not-call registry integrations and import compliance settings |
| [Recipes](recipes) | `CrestApps.OrchardCore.Recipes` | JSON-Schema support for Orchard Core recipes |
diff --git a/src/CrestApps.Docs/docs/modules/resources.md b/src/CrestApps.Docs/docs/modules/resources.md
index 737794bcd..e7fcae1bc 100644
--- a/src/CrestApps.Docs/docs/modules/resources.md
+++ b/src/CrestApps.Docs/docs/modules/resources.md
@@ -17,3 +17,11 @@ Provides shared resources and libraries used by various CrestApps modules.
This module provides shared frontend resources (CSS and JavaScript) that are used by other CrestApps modules. It acts as a central resource library, ensuring consistent styling and behavior across the CrestApps module ecosystem.
Other CrestApps modules declare a dependency on this feature to leverage common scripts and stylesheets without duplicating assets.
+
+## Shared libraries
+
+This feature registers reusable Orchard resource-manager assets that can be consumed by other CrestApps modules.
+
+Current shared libraries include:
+
+- `intl-tel-input` script and stylesheet resources, backed by local copied assets with CDN fallbacks
diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md
index d51408bb9..2954444ef 100644
--- a/src/CrestApps.Docs/docs/omnichannel/management.md
+++ b/src/CrestApps.Docs/docs/omnichannel/management.md
@@ -90,6 +90,8 @@ In Orchard Core Admin:
4. Add any fields/parts you need (phone number, email, lead status, custom fields, etc.).
5. Create/import contact items.
+If you use the built-in `PhoneNumberInfoPart`, the `Number` field is a `PhoneField` (from `CrestApps.OrchardCore.ContentFields`) that stores the phone number in E.164 format alongside the ISO country code, so the correct country flag is always displayed when the field is edited again.
+
When a content type includes `OmnichannelContactPart`, the module now enforces two code-controlled omnichannel surfaces:
- `OmnichannelContactPart` stores the contact-level communication compliance flags (`DoNotCall`, `DoNotSms`, `DoNotEmail`, `DoNotChat`) and their UTC timestamps.
diff --git a/src/CrestApps.Docs/sidebars.js b/src/CrestApps.Docs/sidebars.js
index 3b0a2fc5d..e416dbfb9 100644
--- a/src/CrestApps.Docs/sidebars.js
+++ b/src/CrestApps.Docs/sidebars.js
@@ -100,6 +100,7 @@ const sidebars = {
items: [
'modules/index',
'modules/content-access-control',
+ 'modules/content-fields',
'modules/recipes',
'modules/resources',
'modules/roles',
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Assets.json b/src/Modules/CrestApps.OrchardCore.ContentFields/Assets.json
new file mode 100644
index 000000000..53b99adfe
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Assets.json
@@ -0,0 +1,14 @@
+[
+ {
+ "inputs": [
+ "Assets/js/international-telephone-editor.js"
+ ],
+ "output": "wwwroot/scripts/international-telephone-editor.js"
+ },
+ {
+ "inputs": [
+ "Assets/css/international-telephone-editor.css"
+ ],
+ "output": "wwwroot/styles/international-telephone-editor.css"
+ }
+]
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Assets/css/international-telephone-editor.css b/src/Modules/CrestApps.OrchardCore.ContentFields/Assets/css/international-telephone-editor.css
new file mode 100644
index 000000000..49a6ca2c4
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Assets/css/international-telephone-editor.css
@@ -0,0 +1,13 @@
+.content-field-international-telephone .iti,
+.content-field-international-telephone .iti--inline-dropdown,
+.content-field-international-telephone .iti__tel-input {
+ width: 100%;
+}
+
+.content-field-international-telephone .iti {
+ display: block;
+}
+
+.content-field-international-telephone .iti__tel-input {
+ min-height: calc(1.5em + 0.75rem + 2px);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Assets/js/international-telephone-editor.js b/src/Modules/CrestApps.OrchardCore.ContentFields/Assets/js/international-telephone-editor.js
new file mode 100644
index 000000000..3a9bd7468
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Assets/js/international-telephone-editor.js
@@ -0,0 +1,116 @@
+(function () {
+ var selector = '[data-phone-field]';
+
+ function initializeField(wrapper) {
+ var telInput = wrapper.querySelector('input[data-intl-tel-input="true"]');
+
+ if (!window.intlTelInput || !telInput || telInput.dataset.intlTelInputInitialized === 'true') {
+ return;
+ }
+
+ telInput.dataset.intlTelInputInitialized = 'true';
+
+ var e164Input = wrapper.querySelector('[data-phone-e164]');
+ var countryInput = wrapper.querySelector('[data-phone-country]');
+ var nationalInput = wrapper.querySelector('[data-phone-national]');
+
+ var options = {
+ containerClass: 'w-100',
+ dropdownParent: document.body,
+ numberDisplayFormat: 'INTERNATIONAL',
+ strictMode: true
+ };
+
+ var initialCountry = telInput.dataset.initialCountry;
+
+ if (initialCountry) {
+ options.initialCountry = initialCountry;
+ }
+
+ var telephoneInput = window.intlTelInput(telInput, options);
+
+ if (telInput.disabled) {
+ telephoneInput.setDisabled(true);
+ }
+ else if (telInput.readOnly) {
+ telephoneInput.setReadonly(true);
+ }
+
+ if (telInput.form) {
+ telInput.form.addEventListener('submit', function () {
+ if (!telInput.value) {
+ if (e164Input) {
+ e164Input.value = '';
+ }
+
+ if (countryInput) {
+ countryInput.value = '';
+ }
+
+ if (nationalInput) {
+ nationalInput.value = '';
+ }
+
+ return;
+ }
+
+ var e164Number = telephoneInput.getNumber();
+ var countryData = telephoneInput.getSelectedCountryData();
+
+ if (e164Input) {
+ e164Input.value = e164Number || '';
+ }
+
+ if (countryInput) {
+ countryInput.value = (countryData.iso2 || '').toUpperCase();
+ }
+
+ if (nationalInput) {
+ nationalInput.value = telInput.value || '';
+ }
+ });
+ }
+ }
+
+ function initialize() {
+ document.querySelectorAll(selector).forEach(initializeField);
+ }
+
+ function observeDynamicAdditions() {
+ var observer = new MutationObserver(function (mutations) {
+ for (var i = 0; i < mutations.length; i++) {
+ var addedNodes = mutations[i].addedNodes;
+
+ for (var j = 0; j < addedNodes.length; j++) {
+ var node = addedNodes[j];
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ continue;
+ }
+
+ if (node.matches(selector)) {
+ initializeField(node);
+ }
+ else {
+ var fields = node.querySelectorAll(selector);
+ fields.forEach(initializeField);
+ }
+ }
+ }
+ });
+
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', function () {
+ initialize();
+ observeDynamicAdditions();
+ }, { once: true });
+
+ return;
+ }
+
+ initialize();
+ observeDynamicAdditions();
+})();
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/CrestApps.OrchardCore.ContentFields.csproj b/src/Modules/CrestApps.OrchardCore.ContentFields/CrestApps.OrchardCore.ContentFields.csproj
new file mode 100644
index 000000000..a8ee53134
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/CrestApps.OrchardCore.ContentFields.csproj
@@ -0,0 +1,33 @@
+
+
+
+ $(MSBuildProjectName)
+ true
+ CrestApps OrchardCore Content Fields Module
+
+ $(CrestAppsDescription)
+
+ Adds custom Orchard Core content fields maintained by CrestApps.
+
+ $(PackageTags) OrchardCoreCMS Content Fields
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Drivers/PhoneFieldDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Drivers/PhoneFieldDisplayDriver.cs
new file mode 100644
index 000000000..9c4579c6d
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Drivers/PhoneFieldDisplayDriver.cs
@@ -0,0 +1,155 @@
+using System.Globalization;
+using CrestApps.OrchardCore.ContentFields.Fields;
+using CrestApps.OrchardCore.ContentFields.Settings;
+using CrestApps.OrchardCore.ContentFields.ViewModels;
+using CrestApps.OrchardCore.PhoneNumbers;
+using Microsoft.Extensions.Localization;
+using OrchardCore.ContentManagement.Display.ContentDisplay;
+using OrchardCore.ContentManagement.Display.Models;
+using OrchardCore.ContentManagement.Metadata.Models;
+using OrchardCore.DisplayManagement.Views;
+using OrchardCore.Mvc.ModelBinding;
+
+namespace CrestApps.OrchardCore.ContentFields.Drivers;
+
+///
+/// Display driver for the content field.
+///
+public sealed class PhoneFieldDisplayDriver : ContentFieldDisplayDriver
+{
+ private readonly IPhoneNumberService _phoneNumberService;
+
+ internal readonly IStringLocalizer S;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The phone number service.
+ /// The string localizer.
+ public PhoneFieldDisplayDriver(
+ IPhoneNumberService phoneNumberService,
+ IStringLocalizer stringLocalizer)
+ {
+ _phoneNumberService = phoneNumberService;
+ S = stringLocalizer;
+ }
+
+ ///
+ /// Builds the display shape for the phone field.
+ ///
+ /// The phone field instance.
+ /// The display context.
+ /// The display result.
+ public override IDisplayResult Display(PhoneField field, BuildFieldDisplayContext context)
+ {
+ return Initialize(GetDisplayShapeType(context), model =>
+ {
+ model.Field = field;
+ model.Part = context.ContentPart;
+ model.PartFieldDefinition = context.PartFieldDefinition;
+ }).Location("Detail", "Content")
+ .Location("Summary", "Content");
+ }
+
+ ///
+ /// Builds the edit shape for the phone field.
+ ///
+ /// The phone field instance.
+ /// The editor context.
+ /// The display result.
+ public override IDisplayResult Edit(PhoneField field, BuildFieldEditorContext context)
+ {
+ return Initialize(GetEditorShapeType(context), model =>
+ {
+ var settings = context.PartFieldDefinition.GetSettings();
+
+ model.PhoneNumber = field.PhoneNumber;
+ model.CountryCode = field.CountryCode;
+ model.NationalNumber = field.NationalNumber;
+ model.Field = field;
+ model.Part = context.ContentPart;
+ model.PartFieldDefinition = context.PartFieldDefinition;
+
+ if (string.IsNullOrEmpty(model.CountryCode))
+ {
+ model.CountryCode = ResolveInitialCountryCode(settings);
+ }
+ });
+ }
+
+ ///
+ /// Updates the phone field from the editor form submission.
+ ///
+ /// The phone field instance to update.
+ /// The update context.
+ /// The display result.
+ public override async Task UpdateAsync(PhoneField field, UpdateFieldEditorContext context)
+ {
+ var viewModel = new EditPhoneFieldViewModel();
+
+ await context.Updater.TryUpdateModelAsync(viewModel, Prefix, m => m.PhoneNumber, m => m.CountryCode, m => m.NationalNumber);
+
+ var settings = context.PartFieldDefinition.GetSettings();
+
+ if (settings.Required && string.IsNullOrWhiteSpace(viewModel.PhoneNumber))
+ {
+ context.Updater.ModelState.AddModelError(
+ Prefix,
+ nameof(viewModel.PhoneNumber),
+ S["The {0} field is required.", context.PartFieldDefinition.DisplayName()]);
+ }
+ else if (!string.IsNullOrWhiteSpace(viewModel.PhoneNumber))
+ {
+ var regionCode = viewModel.CountryCode;
+
+ if (!_phoneNumberService.IsValidNumber(viewModel.PhoneNumber, regionCode))
+ {
+ context.Updater.ModelState.AddModelError(
+ Prefix,
+ nameof(viewModel.PhoneNumber),
+ S["The {0} field does not contain a valid phone number.", context.PartFieldDefinition.DisplayName()]);
+ }
+ else if (_phoneNumberService.TryFormatToE164(viewModel.PhoneNumber, regionCode, out var e164Number))
+ {
+ viewModel.PhoneNumber = e164Number;
+ }
+ }
+
+ field.PhoneNumber = viewModel.PhoneNumber;
+ field.CountryCode = viewModel.CountryCode?.ToUpperInvariant();
+ field.NationalNumber = viewModel.NationalNumber;
+
+ return Edit(field, context);
+ }
+
+ private static string ResolveInitialCountryCode(PhoneFieldSettings settings)
+ {
+ return settings.InitialCountryMode switch
+ {
+ InitialCountryMode.CurrentCulture => GetCountryCodeFromCulture(),
+ InitialCountryMode.Specific => settings.SpecificCountryCode,
+ _ => null,
+ };
+ }
+
+ private static string GetCountryCodeFromCulture()
+ {
+ var culture = CultureInfo.CurrentCulture;
+
+ if (culture.IsNeutralCulture || culture == CultureInfo.InvariantCulture)
+ {
+ return null;
+ }
+
+ try
+ {
+ var regionInfo = new RegionInfo(culture.Name);
+
+ return regionInfo.TwoLetterISORegionName;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Drivers/PhoneFieldSettingsDriver.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Drivers/PhoneFieldSettingsDriver.cs
new file mode 100644
index 000000000..ec3eb1a9b
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Drivers/PhoneFieldSettingsDriver.cs
@@ -0,0 +1,125 @@
+using System.Globalization;
+using CrestApps.OrchardCore.ContentFields.Fields;
+using CrestApps.OrchardCore.ContentFields.Settings;
+using CrestApps.OrchardCore.ContentFields.ViewModels;
+using CrestApps.OrchardCore.PhoneNumbers;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.Extensions.Localization;
+using OrchardCore.ContentManagement.Metadata.Models;
+using OrchardCore.ContentTypes.Editors;
+using OrchardCore.DisplayManagement.Handlers;
+using OrchardCore.DisplayManagement.Views;
+
+namespace CrestApps.OrchardCore.ContentFields.Drivers;
+
+///
+/// Display driver for the configuration.
+///
+public sealed class PhoneFieldSettingsDriver : ContentPartFieldDefinitionDisplayDriver
+{
+ private readonly IPhoneNumberService _phoneNumberService;
+
+ internal readonly IStringLocalizer S;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The phone number service.
+ /// The string localizer.
+ public PhoneFieldSettingsDriver(
+ IPhoneNumberService phoneNumberService,
+ IStringLocalizer stringLocalizer)
+ {
+ _phoneNumberService = phoneNumberService;
+ S = stringLocalizer;
+ }
+
+ ///
+ /// Builds the edit shape for phone field settings.
+ ///
+ /// The part field definition.
+ /// The build editor context.
+ /// The display result.
+ public override IDisplayResult Edit(ContentPartFieldDefinition model, BuildEditorContext context)
+ {
+ return Initialize("PhoneFieldSettings_Edit", viewModel =>
+ {
+ var settings = model.GetSettings();
+
+ viewModel.Hint = settings.Hint;
+ viewModel.Required = settings.Required;
+ viewModel.InitialCountryMode = settings.InitialCountryMode;
+ viewModel.SpecificCountryCode = settings.SpecificCountryCode;
+ viewModel.InitialCountryModeOptions = GetInitialCountryModeOptions(settings.InitialCountryMode);
+ viewModel.CountryOptions = GetCountryOptions(settings.SpecificCountryCode);
+ }).Location("Content");
+ }
+
+ ///
+ /// Updates the phone field settings from the form submission.
+ ///
+ /// The part field definition.
+ /// The update context.
+ /// The display result.
+ public override async Task UpdateAsync(ContentPartFieldDefinition model, UpdatePartFieldEditorContext context)
+ {
+ var viewModel = new PhoneFieldSettingsViewModel();
+
+ await context.Updater.TryUpdateModelAsync(viewModel, Prefix);
+
+ var settings = new PhoneFieldSettings
+ {
+ Hint = viewModel.Hint,
+ Required = viewModel.Required,
+ InitialCountryMode = viewModel.InitialCountryMode,
+ SpecificCountryCode = viewModel.InitialCountryMode == InitialCountryMode.Specific
+ ? viewModel.SpecificCountryCode?.ToUpperInvariant()
+ : null,
+ };
+
+ context.Builder.WithSettings(settings);
+
+ return Edit(model, context);
+ }
+
+ private List GetInitialCountryModeOptions(InitialCountryMode selected)
+ {
+ return
+ [
+ new SelectListItem(S["Globe"], nameof(InitialCountryMode.Globe), selected == InitialCountryMode.Globe),
+ new SelectListItem(S["Current culture"], nameof(InitialCountryMode.CurrentCulture), selected == InitialCountryMode.CurrentCulture),
+ new SelectListItem(S["Specific"], nameof(InitialCountryMode.Specific), selected == InitialCountryMode.Specific),
+ ];
+ }
+
+ private List GetCountryOptions(string selectedCode)
+ {
+ var regions = _phoneNumberService.GetSupportedRegions();
+ var items = new List(regions.Count + 1)
+ {
+ new(S["Select a country"], string.Empty),
+ };
+
+ foreach (var regionCode in regions.OrderBy(r => r, StringComparer.OrdinalIgnoreCase))
+ {
+ string displayName;
+
+ try
+ {
+ var regionInfo = new RegionInfo(regionCode);
+ displayName = $"{regionInfo.EnglishName} ({regionCode})";
+ }
+ catch
+ {
+ displayName = regionCode;
+ }
+
+ items.Add(new SelectListItem(
+ displayName,
+ regionCode,
+ string.Equals(regionCode, selectedCode, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ return items;
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Fields/PhoneField.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Fields/PhoneField.cs
new file mode 100644
index 000000000..86ff45b31
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Fields/PhoneField.cs
@@ -0,0 +1,28 @@
+using OrchardCore.ContentManagement;
+
+namespace CrestApps.OrchardCore.ContentFields.Fields;
+
+///
+/// A content field that stores a phone number with its country code for
+/// correct flag display and E.164 formatting.
+///
+public sealed class PhoneField : ContentField
+{
+ ///
+ /// Gets or sets the phone number in E.164 format (e.g., "+14155552671").
+ ///
+ public string PhoneNumber { get; set; }
+
+ ///
+ /// Gets or sets the ISO 3166-1 alpha-2 country code (e.g., "US", "CA").
+ /// This is stored separately so the correct country flag can be displayed
+ /// even when multiple countries share the same calling code.
+ ///
+ public string CountryCode { get; set; }
+
+ ///
+ /// Gets or sets the national phone number without the country calling code
+ /// (e.g., "4155552671").
+ ///
+ public string NationalNumber { get; set; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Manifest.cs
new file mode 100644
index 000000000..0bb09d892
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Manifest.cs
@@ -0,0 +1,27 @@
+using CrestApps.OrchardCore;
+using CrestApps.OrchardCore.PhoneNumbers;
+using OrchardCore.Modules.Manifest;
+
+[assembly: Module(
+ Name = "CrestApps Content Fields",
+ Author = CrestAppsManifestConstants.Author,
+ Website = CrestAppsManifestConstants.Website,
+ Version = CrestAppsManifestConstants.Version,
+ Description = "Adds custom Orchard Core content fields maintained by CrestApps.",
+ Category = "Content",
+ IsAlwaysEnabled = false
+)]
+
+[assembly: Feature(
+ Name = "CrestApps Content Fields",
+ Id = "CrestApps.OrchardCore.ContentFields",
+ Category = "Content",
+ Description = "Adds custom Orchard Core content fields maintained by CrestApps.",
+ Dependencies =
+ [
+ "CrestApps.OrchardCore.Resources",
+ PhoneNumbersConstants.Features.Area,
+ "OrchardCore.ContentFields",
+ "OrchardCore.ContentTypes",
+ ]
+)]
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/README.md b/src/Modules/CrestApps.OrchardCore.ContentFields/README.md
new file mode 100644
index 000000000..af3fe2c2c
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/README.md
@@ -0,0 +1,13 @@
+# CrestApps.OrchardCore.ContentFields
+
+Provides custom Orchard Core content fields maintained by CrestApps.
+
+## Features
+
+- Adds `PhoneField`, a content field that stores a phone number together with its ISO country code so the correct country flag is always displayed
+- Uses the `intl-tel-input` library (from `CrestApps.OrchardCore.Resources`) for country-aware phone number entry
+- Server-side validation via `IPhoneNumberService`
+
+## Usage
+
+Enable the **CrestApps Content Fields** feature, then add a `PhoneField` to any content part through the content definition UI or a data migration with `.OfType("PhoneField")`.
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/ResourceManagementOptionsConfiguration.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/ResourceManagementOptionsConfiguration.cs
new file mode 100644
index 000000000..7245ac70b
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/ResourceManagementOptionsConfiguration.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.Options;
+using OrchardCore.ResourceManagement;
+
+namespace CrestApps.OrchardCore.ContentFields;
+
+internal sealed class ResourceManagementOptionsConfiguration : IConfigureOptions
+{
+ private static readonly ResourceManifest _manifest;
+
+ static ResourceManagementOptionsConfiguration()
+ {
+ _manifest = new ResourceManifest();
+
+ _manifest
+ .DefineStyle("international-telephone-editor")
+ .SetUrl(
+ "~/CrestApps.OrchardCore.ContentFields/styles/international-telephone-editor.min.css",
+ "~/CrestApps.OrchardCore.ContentFields/styles/international-telephone-editor.css")
+ .SetVersion("1.0.0");
+
+ _manifest
+ .DefineScript("international-telephone-editor")
+ .SetUrl(
+ "~/CrestApps.OrchardCore.ContentFields/scripts/international-telephone-editor.min.js",
+ "~/CrestApps.OrchardCore.ContentFields/scripts/international-telephone-editor.js")
+ .SetDependencies("intl-tel-input")
+ .SetVersion("1.0.0");
+ }
+
+ ///
+ /// Configures the resource manifests for this feature.
+ ///
+ /// The resource management options.
+ public void Configure(ResourceManagementOptions options)
+ {
+ options.ResourceManifests.Add(_manifest);
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Settings/InitialCountryMode.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Settings/InitialCountryMode.cs
new file mode 100644
index 000000000..cdda9499e
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Settings/InitialCountryMode.cs
@@ -0,0 +1,23 @@
+namespace CrestApps.OrchardCore.ContentFields.Settings;
+
+///
+/// Determines how the initial country flag is selected when the phone field is empty.
+///
+public enum InitialCountryMode
+{
+ ///
+ /// Shows the globe icon without pre-selecting any country.
+ /// This is the default behavior.
+ ///
+ Globe,
+
+ ///
+ /// Resolves the country from the current culture's region information.
+ ///
+ CurrentCulture,
+
+ ///
+ /// Uses a specific country code configured in the field settings.
+ ///
+ Specific,
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Settings/PhoneFieldSettings.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Settings/PhoneFieldSettings.cs
new file mode 100644
index 000000000..1081e8d60
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Settings/PhoneFieldSettings.cs
@@ -0,0 +1,31 @@
+namespace CrestApps.OrchardCore.ContentFields.Settings;
+
+///
+/// Stores the settings for a .
+///
+public sealed class PhoneFieldSettings
+{
+ ///
+ /// Gets or sets the hint text displayed below the field.
+ ///
+ public string Hint { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the field is required.
+ ///
+ public bool Required { get; set; }
+
+ ///
+ /// Gets or sets how the initial country flag is selected when no
+ /// country has been stored for the field value.
+ /// Defaults to .
+ ///
+ public InitialCountryMode InitialCountryMode { get; set; }
+
+ ///
+ /// Gets or sets the ISO 3166-1 alpha-2 country code used when
+ /// is
+ /// (e.g., "US").
+ ///
+ public string SpecificCountryCode { get; set; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/Startup.cs
new file mode 100644
index 000000000..668255e23
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Startup.cs
@@ -0,0 +1,25 @@
+using CrestApps.OrchardCore.ContentFields.Drivers;
+using CrestApps.OrchardCore.ContentFields.Fields;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.ContentManagement;
+using OrchardCore.ContentManagement.Display.ContentDisplay;
+using OrchardCore.ContentTypes.Editors;
+using OrchardCore.Modules;
+
+namespace CrestApps.OrchardCore.ContentFields;
+
+///
+/// Registers services and configuration for this feature.
+///
+public sealed class Startup : StartupBase
+{
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ services.AddResourceConfiguration();
+
+ services.AddContentField()
+ .UseDisplayDriver();
+
+ services.AddScoped();
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/DisplayPhoneFieldViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/DisplayPhoneFieldViewModel.cs
new file mode 100644
index 000000000..bdd72ce5e
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/DisplayPhoneFieldViewModel.cs
@@ -0,0 +1,26 @@
+using CrestApps.OrchardCore.ContentFields.Fields;
+using OrchardCore.ContentManagement;
+using OrchardCore.ContentManagement.Metadata.Models;
+
+namespace CrestApps.OrchardCore.ContentFields.ViewModels;
+
+///
+/// View model used when displaying a .
+///
+public class DisplayPhoneFieldViewModel
+{
+ ///
+ /// Gets or sets the phone field instance being displayed.
+ ///
+ public PhoneField Field { get; set; }
+
+ ///
+ /// Gets or sets the content part containing this field.
+ ///
+ public ContentPart Part { get; set; }
+
+ ///
+ /// Gets or sets the field definition metadata.
+ ///
+ public ContentPartFieldDefinition PartFieldDefinition { get; set; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/EditPhoneFieldViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/EditPhoneFieldViewModel.cs
new file mode 100644
index 000000000..fb66e2aee
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/EditPhoneFieldViewModel.cs
@@ -0,0 +1,41 @@
+using CrestApps.OrchardCore.ContentFields.Fields;
+using OrchardCore.ContentManagement;
+using OrchardCore.ContentManagement.Metadata.Models;
+
+namespace CrestApps.OrchardCore.ContentFields.ViewModels;
+
+///
+/// View model used when editing a .
+///
+public class EditPhoneFieldViewModel
+{
+ ///
+ /// Gets or sets the phone number in E.164 format.
+ ///
+ public string PhoneNumber { get; set; }
+
+ ///
+ /// Gets or sets the ISO 3166-1 alpha-2 country code.
+ ///
+ public string CountryCode { get; set; }
+
+ ///
+ /// Gets or sets the national phone number without the country calling code.
+ ///
+ public string NationalNumber { get; set; }
+
+ ///
+ /// Gets or sets the phone field instance being edited.
+ ///
+ public PhoneField Field { get; set; }
+
+ ///
+ /// Gets or sets the content part containing this field.
+ ///
+ public ContentPart Part { get; set; }
+
+ ///
+ /// Gets or sets the field definition metadata.
+ ///
+ public ContentPartFieldDefinition PartFieldDefinition { get; set; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/PhoneFieldSettingsViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/PhoneFieldSettingsViewModel.cs
new file mode 100644
index 000000000..72488020f
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/ViewModels/PhoneFieldSettingsViewModel.cs
@@ -0,0 +1,41 @@
+using CrestApps.OrchardCore.ContentFields.Settings;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace CrestApps.OrchardCore.ContentFields.ViewModels;
+
+///
+/// View model used when editing .
+///
+public class PhoneFieldSettingsViewModel
+{
+ ///
+ /// Gets or sets the hint text displayed below the field.
+ ///
+ public string Hint { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the field is required.
+ ///
+ public bool Required { get; set; }
+
+ ///
+ /// Gets or sets the initial country mode.
+ ///
+ public InitialCountryMode InitialCountryMode { get; set; }
+
+ ///
+ /// Gets or sets the specific country code when
+ /// is .
+ ///
+ public string SpecificCountryCode { get; set; }
+
+ ///
+ /// Gets or sets the available initial country mode choices.
+ ///
+ public List InitialCountryModeOptions { get; set; }
+
+ ///
+ /// Gets or sets the available country choices for the specific mode.
+ ///
+ public List CountryOptions { get; set; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.ContentFields/Views/PhoneField.Display.cshtml b/src/Modules/CrestApps.OrchardCore.ContentFields/Views/PhoneField.Display.cshtml
new file mode 100644
index 000000000..29489f9e0
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.ContentFields/Views/PhoneField.Display.cshtml
@@ -0,0 +1,8 @@
+@model CrestApps.OrchardCore.ContentFields.ViewModels.DisplayPhoneFieldViewModel
+@using OrchardCore.ContentManagement.Metadata.Models
+
+@if (!string.IsNullOrEmpty(Model.Field.PhoneNumber))
+{
+