Skip to content

Add Start and End time to ProductPart#713

Open
infofromca wants to merge 3 commits intoOrchardCMS:mainfrom
infofromca:StartTime
Open

Add Start and End time to ProductPart#713
infofromca wants to merge 3 commits intoOrchardCMS:mainfrom
infofromca:StartTime

Conversation

@infofromca
Copy link
Copy Markdown
Contributor

Fix #684

Copy link
Copy Markdown
Contributor

@sarahelsaig sarahelsaig left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is a new feature, please create a UI test to verify expired products.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting concept, but this is not a core product feature, so it shouldn't be in ProductPart

Move these fields into a new content part. As this is related to availability, it should be in OrchardCore.Commerce.Inventory. Also create a documentation file docs/features/*-part.md and link it in the mkdocs.yml.

Comment on lines +47 to +51
// Filter products by start and end time
var utcNow = DateTime.UtcNow;
query = query.With<ProductPartIndex>(index =>
(index.StartTimeUtc == null || index.StartTimeUtc <= utcNow) &&
(index.EndTimeUtc == null || index.EndTimeUtc >= utcNow));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a new IProductListFilterProvider implementation and move this code into its BuildQueryAsync method.

Comment on lines +43 to +48
var utcNow = DateTime.UtcNow;
var contentItemIds = (await _session
.QueryIndex<ProductPartIndex>(index => index.Sku.IsIn(trimmedSkus))
.QueryIndex<ProductPartIndex>(index =>
index.Sku.IsIn(trimmedSkus) &&
(index.StartTimeUtc == null || index.StartTimeUtc <= utcNow) &&
(index.EndTimeUtc == null || index.EndTimeUtc >= utcNow))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetProductsAsync is used in a lot of places, in some cases fetching an expired product is valid.
I think you just want to disable expired products, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary. Content fields like DateTimeField already create an index on their own (in this case DateTimeFieldIndex) which you can join in your query. Please revert the changes in ProductPartIndex and ProductMigrations

@Skrypt
Copy link
Copy Markdown
Contributor

Skrypt commented Mar 11, 2026

I did the same in my customized code. I ended up creating a ProductRentalPart for these. Also needed to add a date range picker frontend option DateRangeProductAttributeValue.cs.

using System;
using System.Globalization;
using OrchardCore.Commerce.Abstractions.ProductAttributeValues;

namespace OrchardCore.Commerce.ProductAttributeValues;

public class DateRangeProductAttributeValue : BaseProductAttributeValue<string>
{
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public string HalfDayMode { get; set; } // "am" | "pm" | null

    public DateRangeProductAttributeValue(string attributeName, string value)
        : base(attributeName, NormalizeToUtcString(value))
    {
        ParseDateRange(Value);
    }

    private static string NormalizeToUtcString(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var parts = value.Split('|');
        if (parts.Length < 2)
        {
            return value;
        }

        DateTime? start = ParseToUtc(parts[0]);
        DateTime? end = ParseToUtc(parts[1]);

        // Preserve optional am/pm suffix (3rd segment) for half-day bookings.
        var suffix = parts.Length >= 3 ? parts[2].Trim().ToLowerInvariant() : null;
        var validSuffix = suffix is "am" or "pm" ? suffix : null;

        if (start.HasValue && end.HasValue)
        {
            // Store as ISO 8601 UTC without fractional seconds for compactness.
            var normalized = string.Concat(
                start.Value.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"),
                "|",
                end.Value.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
            );
            return validSuffix is not null ? normalized + "|" + validSuffix : normalized;
        }

        return value;
    }

    private static DateTime? ParseToUtc(string s)
    {
        if (string.IsNullOrWhiteSpace(s)) return null;

        // Exact date-only in local form: yyyy-MM-dd → treat as UTC midnight on that calendar day
        if (DateTime.TryParseExact(s, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
        {
            return new DateTime(dateOnly.Year, dateOnly.Month, dateOnly.Day, 0, 0, 0, DateTimeKind.Utc);
        }

        // Otherwise, parse and convert to UTC
        if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var dt))
        {
            return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
        }

        return null;
    }

    private void ParseDateRange(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return;
        }

        var dates = value.Split('|');
        if (dates.Length >= 2)
        {
            StartDate = ParseToUtc(dates[0]);
            EndDate = ParseToUtc(dates[1]);

            if (dates.Length >= 3)
            {
                var suffix = dates[2].Trim().ToLowerInvariant();
                if (suffix is "am" or "pm")
                {
                    HalfDayMode = suffix;
                }
            }
        }
    }

    public override string Display(CultureInfo culture = null)
    {
        if (StartDate.HasValue && EndDate.HasValue)
        {
            var cultureToUse = culture ?? CultureInfo.CurrentCulture;

            // Half-day booking: show single date with AM/PM label.
            if (!string.IsNullOrEmpty(HalfDayMode))
            {
                var period = HalfDayMode == "am" ? "AM" : "PM";
                return $"{StartDate.Value.ToString("d", cultureToUse)} ({period})";
            }

            return $"{StartDate.Value.ToString("d", cultureToUse)} - {EndDate.Value.ToString("d", cultureToUse)}";
        }

        return string.Empty;
    }
}

@sarahelsaig
Copy link
Copy Markdown
Contributor

I did the same in my customized code. I ended up creating a ProductRentalPart for these.

I don't think that's the same. The changes in this PR seem to be about offer availability (similar to the BeginningUtc and ExpirationUtc in DiscountPart just for the whole product), not for a date range in the product. Both are useful features, but different.

Also needed to add a date range picker frontend option DateRangeProductAttributeValue.cs.

This looks very cool. Would you be interested in adding it to OCC? Though if you do, instead of the HalfDayMode please allow setting date and time too. This way, customizations like a half day rental can be implemented in the editor shape.

@Skrypt
Copy link
Copy Markdown
Contributor

Skrypt commented Mar 11, 2026

I wanted to avoid timezone conversions by adding AM/PM only for now to be honest. But will surely do later on when I have a version that works with DateTime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Available Begin on , Available Until date to the product (OCC-408)

3 participants