292 lines
16 KiB
Plaintext
292 lines
16 KiB
Plaintext
@page "/product/{Id:guid}"
|
|
@using System.Globalization
|
|
@using Microsoft.Extensions.Localization
|
|
@using Common
|
|
@inject ProductAPIConsumer ProductService
|
|
@inject IStringLocalizer<SharedRes> Loc
|
|
|
|
<div class="flex flex-col gap-8 pb-20">
|
|
<!-- Hero Banner - overlaps with nav menu -->
|
|
<div class="relative w-full h-80 overflow-hidden shadow-2xl -mt-16 md:-mt-20">
|
|
<div class="absolute inset-0 bg-cover bg-center"
|
|
style="background-image: url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp);">
|
|
</div>
|
|
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
|
<div class="relative z-10 h-full flex flex-col justify-center items-center text-center text-white py-16 px-6">
|
|
<h1 class="text-4xl md:text-6xl font-extrabold mb-4 tracking-tight">@(GetTranslation(_product)?.Info?.Name ?? Loc["Product_Details"])</h1>
|
|
<p class="text-lg md:text-xl max-w-2xl" style="color: #cbd5e1 !important;">@Loc["Product_Banner_Subtitle"]</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container mx-auto px-4">
|
|
@if (_loading)
|
|
{
|
|
<div class="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
|
<div class="loading loading-spinner loading-lg text-primary"></div>
|
|
<p class="animate-pulse" style="color: #94a3b8 !important;">@Loc["Loading_Product"]...</p>
|
|
</div>
|
|
}
|
|
else if (_product == null)
|
|
{
|
|
<div class="flex flex-col items-center justify-center min-h-[50vh] gap-6 text-center">
|
|
<div class="bg-red-50 p-6 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-red-500" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9.172 9.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-2xl font-bold text-white">@Loc["Product_Not_Found"]</h2>
|
|
<a href="/shop" class="btn bg-orange-600 hover:bg-orange-700 text-white border-orange-600">@Loc["Back_to_Shop"]</a>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
|
<!-- Left: Image Viewer -->
|
|
<div class="w-full">
|
|
<Generic.ImageViewer.ImageViewer Width="5" Height="4" ThumbSize="100"
|
|
MediaUrls="@(_product.Photos ?? Array.Empty<string>())" Class="sticky top-24" />
|
|
</div>
|
|
|
|
<!-- Right: Product Info -->
|
|
<div class="flex flex-col gap-8">
|
|
<div>
|
|
@if (!string.IsNullOrEmpty(_product.CategoryName))
|
|
{
|
|
<span
|
|
class="inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-bold uppercase tracking-widest rounded-full mb-4">
|
|
@_product.CategoryName
|
|
</span>
|
|
}
|
|
<h1 class="text-4xl md:text-5xl font-black text-white leading-tight mb-4">
|
|
@(GetTranslation(_product)?.Info?.Name ?? string.Empty)</h1>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-3xl font-bold text-primary">
|
|
@FormatPrice(_product.Price * (1 - _product.Discount / 100)) $
|
|
</span>
|
|
@if (_product.Discount > 0)
|
|
{
|
|
<span class="text-xl line-through" style="color: #94a3b8 !important;">@FormatPrice(_product.Price) $</span>
|
|
<span class="bg-green-500 text-white text-xs font-bold px-2 py-1 rounded">
|
|
-@_product.Discount%
|
|
</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs tabs-bordered mb-4">
|
|
<button class="tab tab-lg text-white/70 hover:text-white @(_activeTab == "description" ? "tab-active font-bold text-primary !text-primary" : "")"
|
|
@onclick='() => _activeTab = "description"'>
|
|
@Loc["Description"]
|
|
</button>
|
|
<button class="tab tab-lg text-white/70 hover:text-white @(_activeTab == "technical" ? "tab-active font-bold text-primary !text-primary" : "")"
|
|
@onclick='() => _activeTab = "technical"'>
|
|
@Loc["Technical_Details"]
|
|
</button>
|
|
</div>
|
|
|
|
<div class="min-h-[200px]">
|
|
@if (_activeTab == "description")
|
|
{
|
|
<div class="prose prose-lg max-w-none animate-in fade-in duration-300" style="color: #cbd5e1 !important;">
|
|
<p>@(GetTranslation(_product)?.Info?.Description ?? string.Empty)</p>
|
|
</div>
|
|
}
|
|
else if (_activeTab == "technical")
|
|
{
|
|
<!-- Technical Details -->
|
|
@if (GetTranslation(_product)?.Info?.TechnicalDetails != null &&
|
|
GetTranslation(_product)?.Info?.TechnicalDetails?.Any() == true)
|
|
{
|
|
<div class="bg-gray-50 rounded-2xl p-6 md:p-8 animate-in fade-in duration-300">
|
|
<h3 class="text-xl font-bold mb-6 flex items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none"
|
|
viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
@Loc["Technical_Specifications"]
|
|
</h3>
|
|
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
|
@foreach (var detail in GetTranslation(_product)?.Info?.TechnicalDetails ?? new())
|
|
{
|
|
<div class="flex flex-col border-b border-gray-200 pb-2">
|
|
<dt class="text-sm font-semibold" style="color: #94a3b8 !important;">@detail.Key</dt>
|
|
<dd class="text-base text-white font-medium">@detail.Value</dd>
|
|
</div>
|
|
}
|
|
</dl>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="italic" style="color: #94a3b8 !important;">@Loc["No_Technical_Details_Available"]</p>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<div class="pt-6 flex flex-wrap gap-4">
|
|
<a href="/contact" class="btn bg-orange-600 hover:bg-orange-700 text-white border-orange-600 btn-lg flex-1 md:flex-none md:min-w-[200px] shadow-xl shadow-orange-500/20">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
@Loc["Add_to_Cart"]
|
|
</a>
|
|
<button class="btn bg-orange-600/10 hover:bg-orange-600/20 text-orange-400 border-orange-600/30 btn-lg">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
|
</svg>
|
|
@Loc["Favorite"]
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (_product.IsBundle && _product.ChildProducts?.Any() == true)
|
|
{
|
|
<div class="mt-20 pt-16 border-t border-gray-100 animate-in slide-in-from-bottom-10 duration-700">
|
|
<div class="flex flex-col gap-10">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-3xl font-black text-white tracking-tight flex items-center gap-3">
|
|
<div class="p-2 bg-primary/10 rounded-xl text-primary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
</svg>
|
|
</div>
|
|
@Loc["Included_in_this_Bundle"]
|
|
</h2>
|
|
<div class="flex items-center gap-3">
|
|
<span
|
|
class="text-sm font-bold text-gray-400 uppercase tracking-widest">@Loc["Consisting_of"]:</span>
|
|
<div class="badge badge-md badge-primary font-bold opacity-80 shadow-lg shadow-primary/10">
|
|
@(_product.ChildProducts.Count) @Loc["Items"]</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
|
@foreach (var child in _product.ChildProducts)
|
|
{
|
|
<div
|
|
class="group flex flex-col bg-white border border-gray-100 rounded-3xl overflow-hidden shadow-sm hover:shadow-2xl hover:border-primary/20 transition-all duration-500 hover:-translate-y-2">
|
|
<div class="h-48 overflow-hidden bg-gray-50 relative">
|
|
@if (child.Photos?.Length > 0)
|
|
{
|
|
<img src="@child.Photos[0]"
|
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
|
alt="@(GetTranslation(child)?.Info?.Name ?? string.Empty)" />
|
|
}
|
|
else
|
|
{
|
|
<div class="w-full h-full flex items-center justify-center text-gray-200">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="p-6 flex flex-col gap-4 flex-1">
|
|
<div class="flex-1">
|
|
<h4
|
|
class="font-bold text-lg text-gray-900 group-hover:text-primary transition-colors truncate">
|
|
@(GetTranslation(child)?.Info?.Name ?? string.Empty)
|
|
</h4>
|
|
<p class="text-sm text-gray-600 group-hover:text-gray-700 line-clamp-2 leading-relaxed mt-2 transition-colors">
|
|
@(GetTranslation(child)?.Info?.Description ?? string.Empty)</p>
|
|
</div>
|
|
|
|
<a href="/product/@child.Id"
|
|
class="btn bg-orange-600 hover:bg-orange-700 text-white border-orange-600 btn-sm rounded-xl w-full gap-2 shadow-lg shadow-orange-500/10">
|
|
@Loc["View_Details"]
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
[Parameter] public Guid Id { get; set; }
|
|
|
|
private ProductVM? _product;
|
|
private bool _loading = true;
|
|
private string _activeTab = "description";
|
|
|
|
private string FormatPrice(double? price)
|
|
{
|
|
if (!price.HasValue) return "0.00";
|
|
return price.Value.ToString("F2");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the translation for the current UI culture.
|
|
/// Falls back to English, then to the first available translation.
|
|
/// </summary>
|
|
private ProductTranslationVM? GetTranslation(ProductVM? product)
|
|
{
|
|
if (product?.Translations == null || !product.Translations.Any())
|
|
return null;
|
|
|
|
var currentLang = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName ?? "en";
|
|
|
|
// Try current culture first
|
|
var translation = product.Translations.FirstOrDefault(t =>
|
|
string.Equals(t.Language, currentLang, StringComparison.OrdinalIgnoreCase));
|
|
|
|
// Fall back to English
|
|
if (translation == null)
|
|
{
|
|
translation = product.Translations.FirstOrDefault(t =>
|
|
string.Equals(t.Language, "en", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// Fall back to first available
|
|
if (translation == null)
|
|
{
|
|
translation = product.Translations.FirstOrDefault();
|
|
}
|
|
|
|
return translation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the translation for a child product.
|
|
/// </summary>
|
|
private ProductTranslationVM? GetTranslation(ProductVM product, ProductVM child) => GetTranslation(child);
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
_loading = true;
|
|
try
|
|
{
|
|
_product = await ProductService.GetProduct(Id.ToString());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Error fetching product: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
_loading = false;
|
|
}
|
|
}
|
|
} |