diff --git a/src/GreenHome.Api/Controllers/WeatherController.cs b/src/GreenHome.Api/Controllers/WeatherController.cs new file mode 100644 index 0000000..07a4135 --- /dev/null +++ b/src/GreenHome.Api/Controllers/WeatherController.cs @@ -0,0 +1,154 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using System.Net.Http.Headers; + +namespace GreenHome.Api.Controllers; + +/// +/// Proxy for external weather services (Open-Meteo / Nominatim) with tiered caching: +/// - current conditions : 30 minutes +/// - forecast : 6 hours +/// - historical archive : 30 days (historical data never changes) +/// - reverse geocoding : 30 days (location name rarely changes) +/// +[ApiController] +[Route("api/[controller]")] +public class WeatherController : ControllerBase +{ + private const double DefaultLatitude = 34.39674800; // Kahak Qom + private const double DefaultLongitude = 50.86594800; // Kahak Qom + + private static readonly TimeSpan CurrentTtl = TimeSpan.FromMinutes(30); + private static readonly TimeSpan ForecastTtl = TimeSpan.FromHours(6); + private static readonly TimeSpan HistoricalTtl = TimeSpan.FromDays(30); + private static readonly TimeSpan LocationTtl = TimeSpan.FromDays(30); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public WeatherController( + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _cache = cache; + _logger = logger; + } + + /// Current conditions only (temperature, humidity, wind, weather code). Cached 30 minutes. + [HttpGet("current")] + public Task Current( + [FromQuery] double latitude = DefaultLatitude, + [FromQuery] double longitude = DefaultLongitude, + CancellationToken cancellationToken = default) + { + const string query = "current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia/Tehran"; + return GetOrAddAsync( + $"weather:current:{latitude}:{longitude}", + "https://api.open-meteo.com/v1/forecast", + $"latitude={latitude}&longitude={longitude}&{query}", + CurrentTtl, + cancellationToken); + } + + /// Hourly + daily forecast (no current, which is fetched separately). Cached 6 hours. + [HttpGet("forecast")] + public Task Forecast( + [FromQuery] double latitude = DefaultLatitude, + [FromQuery] double longitude = DefaultLongitude, + [FromQuery] int forecastDays = 7, + CancellationToken cancellationToken = default) + { + const string query = "hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation" + + "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max" + + "&timezone=Asia/Tehran"; + + return GetOrAddAsync( + $"weather:forecast:{latitude}:{longitude}:{forecastDays}", + "https://api.open-meteo.com/v1/forecast", + $"latitude={latitude}&longitude={longitude}&{query}&forecast_days={forecastDays}", + ForecastTtl, + cancellationToken); + } + + /// Historical archive data for a past date range. Cached 30 days. + [HttpGet("historical")] + public Task Historical( + [FromQuery] double latitude = DefaultLatitude, + [FromQuery] double longitude = DefaultLongitude, + [FromQuery] string? start_date = null, + [FromQuery] string? end_date = null, + CancellationToken cancellationToken = default) + { + const string query = "daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran"; + + var cacheKey = $"weather:historical:{latitude}:{longitude}:{start_date}:{end_date}"; + var urlQuery = $"latitude={latitude}&longitude={longitude}&start_date={start_date}&end_date={end_date}&{query}"; + + return GetOrAddAsync( + cacheKey, + "https://archive-api.open-meteo.com/v1/archive", + urlQuery, + HistoricalTtl, + cancellationToken); + } + + /// Reverse geocoding (coordinates -> location name). Cached 30 days. + [HttpGet("location")] + public Task Location( + [FromQuery] double latitude = DefaultLatitude, + [FromQuery] double longitude = DefaultLongitude, + CancellationToken cancellationToken = default) + { + var cacheKey = $"weather:location:{latitude}:{longitude}"; + var url = $"https://nominatim.openstreetmap.org/reverse?format=json&lat={latitude}&lon={longitude}&zoom=10&addressdetails=1&accept-language=fa,en"; + + return GetOrAddAsync( + cacheKey, + url, + null, + LocationTtl, + cancellationToken, + userAgent: "GreenHome/1.0 (greenhouse weather proxy)"); + } + + private async Task GetOrAddAsync( + string cacheKey, + string baseUrl, + string? queryString, + TimeSpan ttl, + CancellationToken cancellationToken, + string? userAgent = null) + { + if (_cache.TryGetValue(cacheKey, out string? cachedJson)) + { + return Content(cachedJson!, "application/json"); + } + + var url = string.IsNullOrEmpty(queryString) ? baseUrl : $"{baseUrl}?{queryString}"; + + var client = _httpClientFactory.CreateClient(); + if (!string.IsNullOrEmpty(userAgent)) + { + client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent); + } + + var response = await client.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Upstream weather service returned {StatusCode} for {Url}", + (int)response.StatusCode, + url); + return StatusCode((int)response.StatusCode, "Upstream weather service error"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + _cache.Set(cacheKey, json, ttl); + + return Content(json, "application/json"); + } +} diff --git a/src/GreenHome.Api/GreenHome.Api.csproj b/src/GreenHome.Api/GreenHome.Api.csproj index a96c557..97b4131 100644 --- a/src/GreenHome.Api/GreenHome.Api.csproj +++ b/src/GreenHome.Api/GreenHome.Api.csproj @@ -28,4 +28,12 @@ + + + + + + + + diff --git a/src/GreenHome.Api/Program.cs b/src/GreenHome.Api/Program.cs index ef96bce..62e2ebe 100644 --- a/src/GreenHome.Api/Program.cs +++ b/src/GreenHome.Api/Program.cs @@ -31,6 +31,9 @@ ConfigureMiddleware(app); ConfigureStaticFiles(app); app.MapControllers(); +// SPA fallback: serve index.html for non-API routes (client-side routing) +app.MapFallbackToFile("index.html"); + app.Run(); // ============================================================ @@ -43,6 +46,10 @@ void ConfigureCoreServices(WebApplicationBuilder builder) builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); + // Caching & HTTP client factory (used by WeatherController proxy with tiered caching) + builder.Services.AddMemoryCache(); + builder.Services.AddHttpClient(); + builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile)); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -146,4 +153,8 @@ void ConfigureStaticFiles(WebApplication app) RequestPath = "/files", ContentTypeProvider = provider }); + + // Serve the SPA from wwwroot (Vite build output) + app.UseDefaultFiles(); + app.UseStaticFiles(); } diff --git a/src/GreenHome.Api/appsettings.json b/src/GreenHome.Api/appsettings.json index 86c522b..aeb5ea8 100644 --- a/src/GreenHome.Api/appsettings.json +++ b/src/GreenHome.Api/appsettings.json @@ -18,5 +18,9 @@ "DefaultTemperature": 1.0 }, + "Auth": { + "TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS" + }, + "AllowedHosts": "*" } \ No newline at end of file diff --git a/src/GreenHome.Api/wwwroot/assets/AnalysisTab-BlSU02rO.js b/src/GreenHome.Api/wwwroot/assets/AnalysisTab-BlSU02rO.js new file mode 100644 index 0000000..7de1716 --- /dev/null +++ b/src/GreenHome.Api/wwwroot/assets/AnalysisTab-BlSU02rO.js @@ -0,0 +1,19 @@ +import{e as br,g as Pt,j as W,r as Te,h as wr,L as Sr,E as Er,B as Cr,R as Ir,k as Tr,t as en}from"./index-DukGkLqQ.js";/** + * @license lucide-react v0.553.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ar=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",key:"1oefj6"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5",key:"wfsgrz"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]],Pr=br("file-text",Ar);function zr(e,n){const t={};return(e[e.length-1]===""?[...e,""]:e).join((t.padRight?" ":"")+","+(t.padLeft===!1?"":" ")).trim()}const Lr=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,vr=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Nr={};function qn(e,n){return(Nr.jsx?vr:Lr).test(e)}const Dr=/[ \t\n\f\r]/g;function _r(e){return typeof e=="object"?e.type==="text"?$n(e.value):!1:$n(e)}function $n(e){return e.replace(Dr,"")===""}class Ue{constructor(n,t,r){this.normal=t,this.property=n,r&&(this.space=r)}}Ue.prototype.normal={};Ue.prototype.property={};Ue.prototype.space=void 0;function zt(e,n){const t={},r={};for(const i of e)Object.assign(t,i.property),Object.assign(r,i.normal);return new Ue(t,r,n)}function dn(e){return e.toLowerCase()}class Z{constructor(n,t){this.attribute=t,this.property=n}}Z.prototype.attribute="";Z.prototype.booleanish=!1;Z.prototype.boolean=!1;Z.prototype.commaOrSpaceSeparated=!1;Z.prototype.commaSeparated=!1;Z.prototype.defined=!1;Z.prototype.mustUseProperty=!1;Z.prototype.number=!1;Z.prototype.overloadedBoolean=!1;Z.prototype.property="";Z.prototype.spaceSeparated=!1;Z.prototype.space=void 0;let Rr=0;const v=Se(),Y=Se(),gn=Se(),x=Se(),H=Se(),we=Se(),ne=Se();function Se(){return 2**++Rr}const yn=Object.freeze(Object.defineProperty({__proto__:null,boolean:v,booleanish:Y,commaOrSpaceSeparated:ne,commaSeparated:we,number:x,overloadedBoolean:gn,spaceSeparated:H},Symbol.toStringTag,{value:"Module"})),nn=Object.keys(yn);class In extends Z{constructor(n,t,r,i){let l=-1;if(super(n,t),Wn(this,"space",i),typeof r=="number")for(;++l4&&t.slice(0,4)==="data"&&jr.test(n)){if(n.charAt(4)==="-"){const l=n.slice(5).replace(Yn,Vr);r="data"+l.charAt(0).toUpperCase()+l.slice(1)}else{const l=n.slice(4);if(!Yn.test(l)){let o=l.replace(Br,Ur);o.charAt(0)!=="-"&&(o="-"+o),n="data"+o}}i=In}return new i(r,n)}function Ur(e){return"-"+e.toLowerCase()}function Vr(e){return e.charAt(1).toUpperCase()}const qr=zt([Lt,Or,Dt,_t,Rt],"html"),Tn=zt([Lt,Fr,Dt,_t,Rt],"svg");function $r(e){return e.join(" ").trim()}var Ae={},tn,Xn;function Wr(){if(Xn)return tn;Xn=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,n=/\n/g,t=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,l=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,o=/^[;\s]*/,a=/^\s+|\s+$/g,c=` +`,u="/",s="*",p="",d="comment",f="declaration";function w(I,k){if(typeof I!="string")throw new TypeError("First argument must be a string");if(!I)return[];k=k||{};var L=1,C=1;function B(P){var S=P.match(n);S&&(L+=S.length);var M=P.lastIndexOf(c);C=~M?P.length-M:C+P.length}function U(){var P={line:L,column:C};return function(S){return S.position=new y(P),O(),S}}function y(P){this.start=P,this.end={line:L,column:C},this.source=k.source}y.prototype.content=I;function _(P){var S=new Error(k.source+":"+L+":"+C+": "+P);if(S.reason=P,S.filename=k.source,S.line=L,S.column=C,S.source=I,!k.silent)throw S}function V(P){var S=P.exec(I);if(S){var M=S[0];return B(M),I=I.slice(M.length),S}}function O(){V(t)}function F(P){var S;for(P=P||[];S=A();)S!==!1&&P.push(S);return P}function A(){var P=U();if(!(u!=I.charAt(0)||s!=I.charAt(1))){for(var S=2;p!=I.charAt(S)&&(s!=I.charAt(S)||u!=I.charAt(S+1));)++S;if(S+=2,p===I.charAt(S-1))return _("End of comment missing");var M=I.slice(2,S-2);return C+=2,B(M),I=I.slice(S),C+=2,P({type:d,comment:M})}}function T(){var P=U(),S=V(r);if(S){if(A(),!V(i))return _("property missing ':'");var M=V(l),X=P({type:f,property:E(S[0].replace(e,p)),value:M?E(M[0].replace(e,p)):p});return V(o),X}}function q(){var P=[];F(P);for(var S;S=T();)S!==!1&&(P.push(S),F(P));return P}return O(),q()}function E(I){return I?I.replace(a,p):p}return tn=w,tn}var Qn;function Yr(){if(Qn)return Ae;Qn=1;var e=Ae&&Ae.__importDefault||function(r){return r&&r.__esModule?r:{default:r}};Object.defineProperty(Ae,"__esModule",{value:!0}),Ae.default=t;const n=e(Wr());function t(r,i){let l=null;if(!r||typeof r!="string")return l;const o=(0,n.default)(r),a=typeof i=="function";return o.forEach(c=>{if(c.type!=="declaration")return;const{property:u,value:s}=c;a?i(u,s,c):s&&(l=l||{},l[u]=s)}),l}return Ae}var _e={},Kn;function Xr(){if(Kn)return _e;Kn=1,Object.defineProperty(_e,"__esModule",{value:!0}),_e.camelCase=void 0;var e=/^--[a-zA-Z0-9_-]+$/,n=/-([a-z])/g,t=/^[^-]+$/,r=/^-(webkit|moz|ms|o|khtml)-/,i=/^-(ms)-/,l=function(u){return!u||t.test(u)||e.test(u)},o=function(u,s){return s.toUpperCase()},a=function(u,s){return"".concat(s,"-")},c=function(u,s){return s===void 0&&(s={}),l(u)?u:(u=u.toLowerCase(),s.reactCompat?u=u.replace(i,a):u=u.replace(r,a),u.replace(n,o))};return _e.camelCase=c,_e}var Re,Gn;function Qr(){if(Gn)return Re;Gn=1;var e=Re&&Re.__importDefault||function(i){return i&&i.__esModule?i:{default:i}},n=e(Yr()),t=Xr();function r(i,l){var o={};return!i||typeof i!="string"||(0,n.default)(i,function(a,c){a&&c&&(o[(0,t.camelCase)(a,l)]=c)}),o}return r.default=r,Re=r,Re}var Kr=Qr();const Gr=Pt(Kr),Ot=Ft("end"),An=Ft("start");function Ft(e){return n;function n(t){const r=t&&t.position&&t.position[e]||{};if(typeof r.line=="number"&&r.line>0&&typeof r.column=="number"&&r.column>0)return{line:r.line,column:r.column,offset:typeof r.offset=="number"&&r.offset>-1?r.offset:void 0}}}function Jr(e){const n=An(e),t=Ot(e);if(n&&t)return{start:n,end:t}}function Me(e){return!e||typeof e!="object"?"":"position"in e||"type"in e?Jn(e.position):"start"in e||"end"in e?Jn(e):"line"in e||"column"in e?xn(e):""}function xn(e){return Zn(e&&e.line)+":"+Zn(e&&e.column)}function Jn(e){return xn(e&&e.start)+"-"+xn(e&&e.end)}function Zn(e){return e&&typeof e=="number"?e:1}class K extends Error{constructor(n,t,r){super(),typeof t=="string"&&(r=t,t=void 0);let i="",l={},o=!1;if(t&&("line"in t&&"column"in t?l={place:t}:"start"in t&&"end"in t?l={place:t}:"type"in t?l={ancestors:[t],place:t.position}:l={...t}),typeof n=="string"?i=n:!l.cause&&n&&(o=!0,i=n.message,l.cause=n),!l.ruleId&&!l.source&&typeof r=="string"){const c=r.indexOf(":");c===-1?l.ruleId=r:(l.source=r.slice(0,c),l.ruleId=r.slice(c+1))}if(!l.place&&l.ancestors&&l.ancestors){const c=l.ancestors[l.ancestors.length-1];c&&(l.place=c.position)}const a=l.place&&"start"in l.place?l.place.start:l.place;this.ancestors=l.ancestors||void 0,this.cause=l.cause||void 0,this.column=a?a.column:void 0,this.fatal=void 0,this.file="",this.message=i,this.line=a?a.line:void 0,this.name=Me(l.place)||"1:1",this.place=l.place||void 0,this.reason=this.message,this.ruleId=l.ruleId||void 0,this.source=l.source||void 0,this.stack=o&&l.cause&&typeof l.cause.stack=="string"?l.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}K.prototype.file="";K.prototype.name="";K.prototype.reason="";K.prototype.message="";K.prototype.stack="";K.prototype.column=void 0;K.prototype.line=void 0;K.prototype.ancestors=void 0;K.prototype.cause=void 0;K.prototype.fatal=void 0;K.prototype.place=void 0;K.prototype.ruleId=void 0;K.prototype.source=void 0;const Pn={}.hasOwnProperty,Zr=new Map,ei=/[A-Z]/g,ni=new Set(["table","tbody","thead","tfoot","tr"]),ti=new Set(["td","th"]),Mt="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function ri(e,n){if(!n||n.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const t=n.filePath||void 0;let r;if(n.development){if(typeof n.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");r=pi(t,n.jsxDEV)}else{if(typeof n.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof n.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");r=ci(t,n.jsx,n.jsxs)}const i={Fragment:n.Fragment,ancestors:[],components:n.components||{},create:r,elementAttributeNameCase:n.elementAttributeNameCase||"react",evaluater:n.createEvaluater?n.createEvaluater():void 0,filePath:t,ignoreInvalidStyle:n.ignoreInvalidStyle||!1,passKeys:n.passKeys!==!1,passNode:n.passNode||!1,schema:n.space==="svg"?Tn:qr,stylePropertyNameCase:n.stylePropertyNameCase||"dom",tableCellAlignToStyle:n.tableCellAlignToStyle!==!1},l=Bt(i,e,void 0);return l&&typeof l!="string"?l:i.create(e,i.Fragment,{children:l||void 0},void 0)}function Bt(e,n,t){if(n.type==="element")return ii(e,n,t);if(n.type==="mdxFlowExpression"||n.type==="mdxTextExpression")return li(e,n);if(n.type==="mdxJsxFlowElement"||n.type==="mdxJsxTextElement")return ai(e,n,t);if(n.type==="mdxjsEsm")return oi(e,n);if(n.type==="root")return ui(e,n,t);if(n.type==="text")return si(e,n)}function ii(e,n,t){const r=e.schema;let i=r;n.tagName.toLowerCase()==="svg"&&r.space==="html"&&(i=Tn,e.schema=i),e.ancestors.push(n);const l=Ht(e,n.tagName,!1),o=fi(e,n);let a=Ln(e,n);return ni.has(n.tagName)&&(a=a.filter(function(c){return typeof c=="string"?!_r(c):!0})),jt(e,o,l,n),zn(o,a),e.ancestors.pop(),e.schema=r,e.create(n,l,o,t)}function li(e,n){if(n.data&&n.data.estree&&e.evaluater){const r=n.data.estree.body[0];return r.type,e.evaluater.evaluateExpression(r.expression)}He(e,n.position)}function oi(e,n){if(n.data&&n.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(n.data.estree);He(e,n.position)}function ai(e,n,t){const r=e.schema;let i=r;n.name==="svg"&&r.space==="html"&&(i=Tn,e.schema=i),e.ancestors.push(n);const l=n.name===null?e.Fragment:Ht(e,n.name,!0),o=hi(e,n),a=Ln(e,n);return jt(e,o,l,n),zn(o,a),e.ancestors.pop(),e.schema=r,e.create(n,l,o,t)}function ui(e,n,t){const r={};return zn(r,Ln(e,n)),e.create(n,e.Fragment,r,t)}function si(e,n){return n.value}function jt(e,n,t,r){typeof t!="string"&&t!==e.Fragment&&e.passNode&&(n.node=r)}function zn(e,n){if(n.length>0){const t=n.length>1?n:n[0];t&&(e.children=t)}}function ci(e,n,t){return r;function r(i,l,o,a){const u=Array.isArray(o.children)?t:n;return a?u(l,o,a):u(l,o)}}function pi(e,n){return t;function t(r,i,l,o){const a=Array.isArray(l.children),c=An(r);return n(i,l,o,a,{columnNumber:c?c.column-1:void 0,fileName:e,lineNumber:c?c.line:void 0},void 0)}}function fi(e,n){const t={};let r,i;for(i in n.properties)if(i!=="children"&&Pn.call(n.properties,i)){const l=mi(e,i,n.properties[i]);if(l){const[o,a]=l;e.tableCellAlignToStyle&&o==="align"&&typeof a=="string"&&ti.has(n.tagName)?r=a:t[o]=a}}if(r){const l=t.style||(t.style={});l[e.stylePropertyNameCase==="css"?"text-align":"textAlign"]=r}return t}function hi(e,n){const t={};for(const r of n.attributes)if(r.type==="mdxJsxExpressionAttribute")if(r.data&&r.data.estree&&e.evaluater){const l=r.data.estree.body[0];l.type;const o=l.expression;o.type;const a=o.properties[0];a.type,Object.assign(t,e.evaluater.evaluateExpression(a.argument))}else He(e,n.position);else{const i=r.name;let l;if(r.value&&typeof r.value=="object")if(r.value.data&&r.value.data.estree&&e.evaluater){const a=r.value.data.estree.body[0];a.type,l=e.evaluater.evaluateExpression(a.expression)}else He(e,n.position);else l=r.value===null?!0:r.value;t[i]=l}return t}function Ln(e,n){const t=[];let r=-1;const i=e.passKeys?new Map:Zr;for(;++ri?0:i+n:n=n>i?i:n,t=t>0?t:0,r.length<1e4)o=Array.from(r),o.unshift(n,t),e.splice(...o);else for(t&&e.splice(n,t);l0?(ce(e,e.length,0,n),e):n}const tt={}.hasOwnProperty;function Si(e){const n={};let t=-1;for(;++t13&&t<32||t>126&&t<160||t>55295&&t<57344||t>64975&&t<65008||(t&65535)===65535||(t&65535)===65534||t>1114111?"�":String.fromCodePoint(t)}function Pe(e){return e.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const se=ye(/[A-Za-z]/),te=ye(/[\dA-Za-z]/),Ii=ye(/[#-'*+\--9=?A-Z^-~]/);function kn(e){return e!==null&&(e<32||e===127)}const bn=ye(/\d/),Ti=ye(/[\dA-Fa-f]/),Ai=ye(/[!-/:-@[-`{-~]/);function z(e){return e!==null&&e<-2}function J(e){return e!==null&&(e<0||e===32)}function R(e){return e===-2||e===-1||e===32}const Pi=ye(new RegExp("\\p{P}|\\p{S}","u")),zi=ye(/\s/);function ye(e){return n;function n(t){return t!==null&&t>-1&&e.test(String.fromCharCode(t))}}function Le(e){const n=[];let t=-1,r=0,i=0;for(;++t55295&&l<57344){const a=e.charCodeAt(t+1);l<56320&&a>56319&&a<57344?(o=String.fromCharCode(l,a),i=1):o="�"}else o=String.fromCharCode(l);o&&(n.push(e.slice(r,t),encodeURIComponent(o)),r=t+i+1,o=""),i&&(t+=i,i=0)}return n.join("")+e.slice(r)}function $(e,n,t,r){const i=r?r-1:Number.POSITIVE_INFINITY;let l=0;return o;function o(c){return R(c)?(e.enter(t),a(c)):n(c)}function a(c){return R(c)&&l++o))return;const _=n.events.length;let V=_,O,F;for(;V--;)if(n.events[V][0]==="exit"&&n.events[V][1].type==="chunkFlow"){if(O){F=n.events[V][1].end;break}O=!0}for(k(r),y=_;yC;){const U=t[B];n.containerState=U[1],U[0].exit.call(n,e)}t.length=C}function L(){i.write([null]),l=void 0,i=void 0,n.containerState._closeFlow=void 0}}function _i(e,n,t){return $(e,e.attempt(this.parser.constructs.document,n,t),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function it(e){if(e===null||J(e)||zi(e))return 1;if(Pi(e))return 2}function Nn(e,n,t){const r=[];let i=-1;for(;++i1&&e[t][1].end.offset-e[t][1].start.offset>1?2:1;const p={...e[r][1].end},d={...e[t][1].start};lt(p,-c),lt(d,c),o={type:c>1?"strongSequence":"emphasisSequence",start:p,end:{...e[r][1].end}},a={type:c>1?"strongSequence":"emphasisSequence",start:{...e[t][1].start},end:d},l={type:c>1?"strongText":"emphasisText",start:{...e[r][1].end},end:{...e[t][1].start}},i={type:c>1?"strong":"emphasis",start:{...o.start},end:{...a.end}},e[r][1].end={...o.start},e[t][1].start={...a.end},u=[],e[r][1].end.offset-e[r][1].start.offset&&(u=re(u,[["enter",e[r][1],n],["exit",e[r][1],n]])),u=re(u,[["enter",i,n],["enter",o,n],["exit",o,n],["enter",l,n]]),u=re(u,Nn(n.parser.constructs.insideSpan.null,e.slice(r+1,t),n)),u=re(u,[["exit",l,n],["enter",a,n],["exit",a,n],["exit",i,n]]),e[t][1].end.offset-e[t][1].start.offset?(s=2,u=re(u,[["enter",e[t][1],n],["exit",e[t][1],n]])):s=0,ce(e,r-1,t-r+3,u),t=r+u.length-s-2;break}}for(t=-1;++t0&&R(y)?$(e,L,"linePrefix",l+1)(y):L(y)}function L(y){return y===null||z(y)?e.check(ot,E,B)(y):(e.enter("codeFlowValue"),C(y))}function C(y){return y===null||z(y)?(e.exit("codeFlowValue"),L(y)):(e.consume(y),C)}function B(y){return e.exit("codeFenced"),n(y)}function U(y,_,V){let O=0;return F;function F(S){return y.enter("lineEnding"),y.consume(S),y.exit("lineEnding"),A}function A(S){return y.enter("codeFencedFence"),R(S)?$(y,T,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(S):T(S)}function T(S){return S===a?(y.enter("codeFencedFenceSequence"),q(S)):V(S)}function q(S){return S===a?(O++,y.consume(S),q):O>=o?(y.exit("codeFencedFenceSequence"),R(S)?$(y,P,"whitespace")(S):P(S)):V(S)}function P(S){return S===null||z(S)?(y.exit("codeFencedFence"),_(S)):V(S)}}}function Wi(e,n,t){const r=this;return i;function i(o){return o===null?t(o):(e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),l)}function l(o){return r.parser.lazy[r.now().line]?t(o):n(o)}}const ln={name:"codeIndented",tokenize:Xi},Yi={partial:!0,tokenize:Qi};function Xi(e,n,t){const r=this;return i;function i(u){return e.enter("codeIndented"),$(e,l,"linePrefix",5)(u)}function l(u){const s=r.events[r.events.length-1];return s&&s[1].type==="linePrefix"&&s[2].sliceSerialize(s[1],!0).length>=4?o(u):t(u)}function o(u){return u===null?c(u):z(u)?e.attempt(Yi,o,c)(u):(e.enter("codeFlowValue"),a(u))}function a(u){return u===null||z(u)?(e.exit("codeFlowValue"),o(u)):(e.consume(u),a)}function c(u){return e.exit("codeIndented"),n(u)}}function Qi(e,n,t){const r=this;return i;function i(o){return r.parser.lazy[r.now().line]?t(o):z(o)?(e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),i):$(e,l,"linePrefix",5)(o)}function l(o){const a=r.events[r.events.length-1];return a&&a[1].type==="linePrefix"&&a[2].sliceSerialize(a[1],!0).length>=4?n(o):z(o)?i(o):t(o)}}const Ki={name:"codeText",previous:Ji,resolve:Gi,tokenize:Zi};function Gi(e){let n=e.length-4,t=3,r,i;if((e[t][1].type==="lineEnding"||e[t][1].type==="space")&&(e[n][1].type==="lineEnding"||e[n][1].type==="space")){for(r=t;++r=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+n+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return nthis.left.length?this.right.slice(this.right.length-r+this.left.length,this.right.length-n+this.left.length).reverse():this.left.slice(n).concat(this.right.slice(this.right.length-r+this.left.length).reverse())}splice(n,t,r){const i=t||0;this.setCursor(Math.trunc(n));const l=this.right.splice(this.right.length-i,Number.POSITIVE_INFINITY);return r&&Oe(this.left,r),l.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(n){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(n)}pushMany(n){this.setCursor(Number.POSITIVE_INFINITY),Oe(this.left,n)}unshift(n){this.setCursor(0),this.right.push(n)}unshiftMany(n){this.setCursor(0),Oe(this.right,n.reverse())}setCursor(n){if(!(n===this.left.length||n>this.left.length&&this.right.length===0||n<0&&this.left.length===0))if(n=4?n(o):e.interrupt(r.parser.constructs.flow,t,n)(o)}}function Xt(e,n,t,r,i,l,o,a,c){const u=c||Number.POSITIVE_INFINITY;let s=0;return p;function p(k){return k===60?(e.enter(r),e.enter(i),e.enter(l),e.consume(k),e.exit(l),d):k===null||k===32||k===41||kn(k)?t(k):(e.enter(r),e.enter(o),e.enter(a),e.enter("chunkString",{contentType:"string"}),E(k))}function d(k){return k===62?(e.enter(l),e.consume(k),e.exit(l),e.exit(i),e.exit(r),n):(e.enter(a),e.enter("chunkString",{contentType:"string"}),f(k))}function f(k){return k===62?(e.exit("chunkString"),e.exit(a),d(k)):k===null||k===60||z(k)?t(k):(e.consume(k),k===92?w:f)}function w(k){return k===60||k===62||k===92?(e.consume(k),f):f(k)}function E(k){return!s&&(k===null||k===41||J(k))?(e.exit("chunkString"),e.exit(a),e.exit(o),e.exit(r),n(k)):s999||f===null||f===91||f===93&&!c||f===94&&!a&&"_hiddenFootnoteSupport"in o.parser.constructs?t(f):f===93?(e.exit(l),e.enter(i),e.consume(f),e.exit(i),e.exit(r),n):z(f)?(e.enter("lineEnding"),e.consume(f),e.exit("lineEnding"),s):(e.enter("chunkString",{contentType:"string"}),p(f))}function p(f){return f===null||f===91||f===93||z(f)||a++>999?(e.exit("chunkString"),s(f)):(e.consume(f),c||(c=!R(f)),f===92?d:p)}function d(f){return f===91||f===92||f===93?(e.consume(f),a++,p):p(f)}}function Kt(e,n,t,r,i,l){let o;return a;function a(d){return d===34||d===39||d===40?(e.enter(r),e.enter(i),e.consume(d),e.exit(i),o=d===40?41:d,c):t(d)}function c(d){return d===o?(e.enter(i),e.consume(d),e.exit(i),e.exit(r),n):(e.enter(l),u(d))}function u(d){return d===o?(e.exit(l),c(o)):d===null?t(d):z(d)?(e.enter("lineEnding"),e.consume(d),e.exit("lineEnding"),$(e,u,"linePrefix")):(e.enter("chunkString",{contentType:"string"}),s(d))}function s(d){return d===o||d===null||z(d)?(e.exit("chunkString"),u(d)):(e.consume(d),d===92?p:s)}function p(d){return d===o||d===92?(e.consume(d),s):s(d)}}function Be(e,n){let t;return r;function r(i){return z(i)?(e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),t=!0,r):R(i)?$(e,r,t?"linePrefix":"lineSuffix")(i):n(i)}}const al={name:"definition",tokenize:sl},ul={partial:!0,tokenize:cl};function sl(e,n,t){const r=this;let i;return l;function l(f){return e.enter("definition"),o(f)}function o(f){return Qt.call(r,e,a,t,"definitionLabel","definitionLabelMarker","definitionLabelString")(f)}function a(f){return i=Pe(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),f===58?(e.enter("definitionMarker"),e.consume(f),e.exit("definitionMarker"),c):t(f)}function c(f){return J(f)?Be(e,u)(f):u(f)}function u(f){return Xt(e,s,t,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(f)}function s(f){return e.attempt(ul,p,p)(f)}function p(f){return R(f)?$(e,d,"whitespace")(f):d(f)}function d(f){return f===null||z(f)?(e.exit("definition"),r.parser.defined.push(i),n(f)):t(f)}}function cl(e,n,t){return r;function r(a){return J(a)?Be(e,i)(a):t(a)}function i(a){return Kt(e,l,t,"definitionTitle","definitionTitleMarker","definitionTitleString")(a)}function l(a){return R(a)?$(e,o,"whitespace")(a):o(a)}function o(a){return a===null||z(a)?n(a):t(a)}}const pl={name:"hardBreakEscape",tokenize:fl};function fl(e,n,t){return r;function r(l){return e.enter("hardBreakEscape"),e.consume(l),i}function i(l){return z(l)?(e.exit("hardBreakEscape"),n(l)):t(l)}}const hl={name:"headingAtx",resolve:ml,tokenize:dl};function ml(e,n){let t=e.length-2,r=3,i,l;return e[r][1].type==="whitespace"&&(r+=2),t-2>r&&e[t][1].type==="whitespace"&&(t-=2),e[t][1].type==="atxHeadingSequence"&&(r===t-1||t-4>r&&e[t-2][1].type==="whitespace")&&(t-=r+1===t?2:4),t>r&&(i={type:"atxHeadingText",start:e[r][1].start,end:e[t][1].end},l={type:"chunkText",start:e[r][1].start,end:e[t][1].end,contentType:"text"},ce(e,r,t-r+1,[["enter",i,n],["enter",l,n],["exit",l,n],["exit",i,n]])),e}function dl(e,n,t){let r=0;return i;function i(s){return e.enter("atxHeading"),l(s)}function l(s){return e.enter("atxHeadingSequence"),o(s)}function o(s){return s===35&&r++<6?(e.consume(s),o):s===null||J(s)?(e.exit("atxHeadingSequence"),a(s)):t(s)}function a(s){return s===35?(e.enter("atxHeadingSequence"),c(s)):s===null||z(s)?(e.exit("atxHeading"),n(s)):R(s)?$(e,a,"whitespace")(s):(e.enter("atxHeadingText"),u(s))}function c(s){return s===35?(e.consume(s),c):(e.exit("atxHeadingSequence"),a(s))}function u(s){return s===null||s===35||J(s)?(e.exit("atxHeadingText"),a(s)):(e.consume(s),u)}}const gl=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],ut=["pre","script","style","textarea"],yl={concrete:!0,name:"htmlFlow",resolveTo:bl,tokenize:wl},xl={partial:!0,tokenize:El},kl={partial:!0,tokenize:Sl};function bl(e){let n=e.length;for(;n--&&!(e[n][0]==="enter"&&e[n][1].type==="htmlFlow"););return n>1&&e[n-2][1].type==="linePrefix"&&(e[n][1].start=e[n-2][1].start,e[n+1][1].start=e[n-2][1].start,e.splice(n-2,2)),e}function wl(e,n,t){const r=this;let i,l,o,a,c;return u;function u(m){return s(m)}function s(m){return e.enter("htmlFlow"),e.enter("htmlFlowData"),e.consume(m),p}function p(m){return m===33?(e.consume(m),d):m===47?(e.consume(m),l=!0,E):m===63?(e.consume(m),i=3,r.interrupt?n:h):se(m)?(e.consume(m),o=String.fromCharCode(m),I):t(m)}function d(m){return m===45?(e.consume(m),i=2,f):m===91?(e.consume(m),i=5,a=0,w):se(m)?(e.consume(m),i=4,r.interrupt?n:h):t(m)}function f(m){return m===45?(e.consume(m),r.interrupt?n:h):t(m)}function w(m){const oe="CDATA[";return m===oe.charCodeAt(a++)?(e.consume(m),a===oe.length?r.interrupt?n:T:w):t(m)}function E(m){return se(m)?(e.consume(m),o=String.fromCharCode(m),I):t(m)}function I(m){if(m===null||m===47||m===62||J(m)){const oe=m===47,xe=o.toLowerCase();return!oe&&!l&&ut.includes(xe)?(i=1,r.interrupt?n(m):T(m)):gl.includes(o.toLowerCase())?(i=6,oe?(e.consume(m),k):r.interrupt?n(m):T(m)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?t(m):l?L(m):C(m))}return m===45||te(m)?(e.consume(m),o+=String.fromCharCode(m),I):t(m)}function k(m){return m===62?(e.consume(m),r.interrupt?n:T):t(m)}function L(m){return R(m)?(e.consume(m),L):F(m)}function C(m){return m===47?(e.consume(m),F):m===58||m===95||se(m)?(e.consume(m),B):R(m)?(e.consume(m),C):F(m)}function B(m){return m===45||m===46||m===58||m===95||te(m)?(e.consume(m),B):U(m)}function U(m){return m===61?(e.consume(m),y):R(m)?(e.consume(m),U):C(m)}function y(m){return m===null||m===60||m===61||m===62||m===96?t(m):m===34||m===39?(e.consume(m),c=m,_):R(m)?(e.consume(m),y):V(m)}function _(m){return m===c?(e.consume(m),c=null,O):m===null||z(m)?t(m):(e.consume(m),_)}function V(m){return m===null||m===34||m===39||m===47||m===60||m===61||m===62||m===96||J(m)?U(m):(e.consume(m),V)}function O(m){return m===47||m===62||R(m)?C(m):t(m)}function F(m){return m===62?(e.consume(m),A):t(m)}function A(m){return m===null||z(m)?T(m):R(m)?(e.consume(m),A):t(m)}function T(m){return m===45&&i===2?(e.consume(m),M):m===60&&i===1?(e.consume(m),X):m===62&&i===4?(e.consume(m),le):m===63&&i===3?(e.consume(m),h):m===93&&i===5?(e.consume(m),pe):z(m)&&(i===6||i===7)?(e.exit("htmlFlowData"),e.check(xl,fe,q)(m)):m===null||z(m)?(e.exit("htmlFlowData"),q(m)):(e.consume(m),T)}function q(m){return e.check(kl,P,fe)(m)}function P(m){return e.enter("lineEnding"),e.consume(m),e.exit("lineEnding"),S}function S(m){return m===null||z(m)?q(m):(e.enter("htmlFlowData"),T(m))}function M(m){return m===45?(e.consume(m),h):T(m)}function X(m){return m===47?(e.consume(m),o="",ie):T(m)}function ie(m){if(m===62){const oe=o.toLowerCase();return ut.includes(oe)?(e.consume(m),le):T(m)}return se(m)&&o.length<8?(e.consume(m),o+=String.fromCharCode(m),ie):T(m)}function pe(m){return m===93?(e.consume(m),h):T(m)}function h(m){return m===62?(e.consume(m),le):m===45&&i===2?(e.consume(m),h):T(m)}function le(m){return m===null||z(m)?(e.exit("htmlFlowData"),fe(m)):(e.consume(m),le)}function fe(m){return e.exit("htmlFlow"),n(m)}}function Sl(e,n,t){const r=this;return i;function i(o){return z(o)?(e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),l):t(o)}function l(o){return r.parser.lazy[r.now().line]?t(o):n(o)}}function El(e,n,t){return r;function r(i){return e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),e.attempt(Ge,n,t)}}const Cl={name:"htmlText",tokenize:Il};function Il(e,n,t){const r=this;let i,l,o;return a;function a(h){return e.enter("htmlText"),e.enter("htmlTextData"),e.consume(h),c}function c(h){return h===33?(e.consume(h),u):h===47?(e.consume(h),U):h===63?(e.consume(h),C):se(h)?(e.consume(h),V):t(h)}function u(h){return h===45?(e.consume(h),s):h===91?(e.consume(h),l=0,w):se(h)?(e.consume(h),L):t(h)}function s(h){return h===45?(e.consume(h),f):t(h)}function p(h){return h===null?t(h):h===45?(e.consume(h),d):z(h)?(o=p,X(h)):(e.consume(h),p)}function d(h){return h===45?(e.consume(h),f):p(h)}function f(h){return h===62?M(h):h===45?d(h):p(h)}function w(h){const le="CDATA[";return h===le.charCodeAt(l++)?(e.consume(h),l===le.length?E:w):t(h)}function E(h){return h===null?t(h):h===93?(e.consume(h),I):z(h)?(o=E,X(h)):(e.consume(h),E)}function I(h){return h===93?(e.consume(h),k):E(h)}function k(h){return h===62?M(h):h===93?(e.consume(h),k):E(h)}function L(h){return h===null||h===62?M(h):z(h)?(o=L,X(h)):(e.consume(h),L)}function C(h){return h===null?t(h):h===63?(e.consume(h),B):z(h)?(o=C,X(h)):(e.consume(h),C)}function B(h){return h===62?M(h):C(h)}function U(h){return se(h)?(e.consume(h),y):t(h)}function y(h){return h===45||te(h)?(e.consume(h),y):_(h)}function _(h){return z(h)?(o=_,X(h)):R(h)?(e.consume(h),_):M(h)}function V(h){return h===45||te(h)?(e.consume(h),V):h===47||h===62||J(h)?O(h):t(h)}function O(h){return h===47?(e.consume(h),M):h===58||h===95||se(h)?(e.consume(h),F):z(h)?(o=O,X(h)):R(h)?(e.consume(h),O):M(h)}function F(h){return h===45||h===46||h===58||h===95||te(h)?(e.consume(h),F):A(h)}function A(h){return h===61?(e.consume(h),T):z(h)?(o=A,X(h)):R(h)?(e.consume(h),A):O(h)}function T(h){return h===null||h===60||h===61||h===62||h===96?t(h):h===34||h===39?(e.consume(h),i=h,q):z(h)?(o=T,X(h)):R(h)?(e.consume(h),T):(e.consume(h),P)}function q(h){return h===i?(e.consume(h),i=void 0,S):h===null?t(h):z(h)?(o=q,X(h)):(e.consume(h),q)}function P(h){return h===null||h===34||h===39||h===60||h===61||h===96?t(h):h===47||h===62||J(h)?O(h):(e.consume(h),P)}function S(h){return h===47||h===62||J(h)?O(h):t(h)}function M(h){return h===62?(e.consume(h),e.exit("htmlTextData"),e.exit("htmlText"),n):t(h)}function X(h){return e.exit("htmlTextData"),e.enter("lineEnding"),e.consume(h),e.exit("lineEnding"),ie}function ie(h){return R(h)?$(e,pe,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(h):pe(h)}function pe(h){return e.enter("htmlTextData"),o(h)}}const Dn={name:"labelEnd",resolveAll:zl,resolveTo:Ll,tokenize:vl},Tl={tokenize:Nl},Al={tokenize:Dl},Pl={tokenize:_l};function zl(e){let n=-1;const t=[];for(;++n=3&&(u===null||z(u))?(e.exit("thematicBreak"),n(u)):t(u)}function c(u){return u===i?(e.consume(u),r++,c):(e.exit("thematicBreakSequence"),R(u)?$(e,a,"whitespace")(u):a(u))}}const G={continuation:{tokenize:ql},exit:Wl,name:"list",tokenize:Vl},Hl={partial:!0,tokenize:Yl},Ul={partial:!0,tokenize:$l};function Vl(e,n,t){const r=this,i=r.events[r.events.length-1];let l=i&&i[1].type==="linePrefix"?i[2].sliceSerialize(i[1],!0).length:0,o=0;return a;function a(f){const w=r.containerState.type||(f===42||f===43||f===45?"listUnordered":"listOrdered");if(w==="listUnordered"?!r.containerState.marker||f===r.containerState.marker:bn(f)){if(r.containerState.type||(r.containerState.type=w,e.enter(w,{_container:!0})),w==="listUnordered")return e.enter("listItemPrefix"),f===42||f===45?e.check(Xe,t,u)(f):u(f);if(!r.interrupt||f===49)return e.enter("listItemPrefix"),e.enter("listItemValue"),c(f)}return t(f)}function c(f){return bn(f)&&++o<10?(e.consume(f),c):(!r.interrupt||o<2)&&(r.containerState.marker?f===r.containerState.marker:f===41||f===46)?(e.exit("listItemValue"),u(f)):t(f)}function u(f){return e.enter("listItemMarker"),e.consume(f),e.exit("listItemMarker"),r.containerState.marker=r.containerState.marker||f,e.check(Ge,r.interrupt?t:s,e.attempt(Hl,d,p))}function s(f){return r.containerState.initialBlankLine=!0,l++,d(f)}function p(f){return R(f)?(e.enter("listItemPrefixWhitespace"),e.consume(f),e.exit("listItemPrefixWhitespace"),d):t(f)}function d(f){return r.containerState.size=l+r.sliceSerialize(e.exit("listItemPrefix"),!0).length,n(f)}}function ql(e,n,t){const r=this;return r.containerState._closeFlow=void 0,e.check(Ge,i,l);function i(a){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,$(e,n,"listItemIndent",r.containerState.size+1)(a)}function l(a){return r.containerState.furtherBlankLines||!R(a)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,o(a)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(Ul,n,o)(a))}function o(a){return r.containerState._closeFlow=!0,r.interrupt=void 0,$(e,e.attempt(G,n,t),"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(a)}}function $l(e,n,t){const r=this;return $(e,i,"listItemIndent",r.containerState.size+1);function i(l){const o=r.events[r.events.length-1];return o&&o[1].type==="listItemIndent"&&o[2].sliceSerialize(o[1],!0).length===r.containerState.size?n(l):t(l)}}function Wl(e){e.exit(this.containerState.type)}function Yl(e,n,t){const r=this;return $(e,i,"listItemPrefixWhitespace",r.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function i(l){const o=r.events[r.events.length-1];return!R(l)&&o&&o[1].type==="listItemPrefixWhitespace"?n(l):t(l)}}const st={name:"setextUnderline",resolveTo:Xl,tokenize:Ql};function Xl(e,n){let t=e.length,r,i,l;for(;t--;)if(e[t][0]==="enter"){if(e[t][1].type==="content"){r=t;break}e[t][1].type==="paragraph"&&(i=t)}else e[t][1].type==="content"&&e.splice(t,1),!l&&e[t][1].type==="definition"&&(l=t);const o={type:"setextHeading",start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type="setextHeadingText",l?(e.splice(i,0,["enter",o,n]),e.splice(l+1,0,["exit",e[r][1],n]),e[r][1].end={...e[l][1].end}):e[r][1]=o,e.push(["exit",o,n]),e}function Ql(e,n,t){const r=this;let i;return l;function l(u){let s=r.events.length,p;for(;s--;)if(r.events[s][1].type!=="lineEnding"&&r.events[s][1].type!=="linePrefix"&&r.events[s][1].type!=="content"){p=r.events[s][1].type==="paragraph";break}return!r.parser.lazy[r.now().line]&&(r.interrupt||p)?(e.enter("setextHeadingLine"),i=u,o(u)):t(u)}function o(u){return e.enter("setextHeadingLineSequence"),a(u)}function a(u){return u===i?(e.consume(u),a):(e.exit("setextHeadingLineSequence"),R(u)?$(e,c,"lineSuffix")(u):c(u))}function c(u){return u===null||z(u)?(e.exit("setextHeadingLine"),n(u)):t(u)}}const Kl={tokenize:Gl};function Gl(e){const n=this,t=e.attempt(Ge,r,e.attempt(this.parser.constructs.flowInitial,i,$(e,e.attempt(this.parser.constructs.flow,i,e.attempt(tl,i)),"linePrefix")));return t;function r(l){if(l===null){e.consume(l);return}return e.enter("lineEndingBlank"),e.consume(l),e.exit("lineEndingBlank"),n.currentConstruct=void 0,t}function i(l){if(l===null){e.consume(l);return}return e.enter("lineEnding"),e.consume(l),e.exit("lineEnding"),n.currentConstruct=void 0,t}}const Jl={resolveAll:Jt()},Zl=Gt("string"),eo=Gt("text");function Gt(e){return{resolveAll:Jt(e==="text"?no:void 0),tokenize:n};function n(t){const r=this,i=this.parser.constructs[e],l=t.attempt(i,o,a);return o;function o(s){return u(s)?l(s):a(s)}function a(s){if(s===null){t.consume(s);return}return t.enter("data"),t.consume(s),c}function c(s){return u(s)?(t.exit("data"),l(s)):(t.consume(s),c)}function u(s){if(s===null)return!0;const p=i[s];let d=-1;if(p)for(;++d-1){const a=o[0];typeof a=="string"?o[0]=a.slice(r):o.shift()}l>0&&o.push(e[i].slice(0,l))}return o}function mo(e,n){let t=-1;const r=[];let i;for(;++t0){const ae=N.tokenStack[N.tokenStack.length-1];(ae[1]||pt).call(N,void 0,ae[0])}for(b.position={start:ge(g.length>0?g[0][1].start:{line:1,column:1,offset:0}),end:ge(g.length>0?g[g.length-2][1].end:{line:1,column:1,offset:0})},j=-1;++j0&&(r.className=["language-"+i[0]]);let l={type:"element",tagName:"code",properties:r,children:[{type:"text",value:t}]};return n.meta&&(l.data={meta:n.meta}),e.patch(n,l),l=e.applyData(n,l),l={type:"element",tagName:"pre",properties:{},children:[l]},e.patch(n,l),l}function zo(e,n){const t={type:"element",tagName:"del",properties:{},children:e.all(n)};return e.patch(n,t),e.applyData(n,t)}function Lo(e,n){const t={type:"element",tagName:"em",properties:{},children:e.all(n)};return e.patch(n,t),e.applyData(n,t)}function vo(e,n){const t=typeof e.options.clobberPrefix=="string"?e.options.clobberPrefix:"user-content-",r=String(n.identifier).toUpperCase(),i=Le(r.toLowerCase()),l=e.footnoteOrder.indexOf(r);let o,a=e.footnoteCounts.get(r);a===void 0?(a=0,e.footnoteOrder.push(r),o=e.footnoteOrder.length):o=l+1,a+=1,e.footnoteCounts.set(r,a);const c={type:"element",tagName:"a",properties:{href:"#"+t+"fn-"+i,id:t+"fnref-"+i+(a>1?"-"+a:""),dataFootnoteRef:!0,ariaDescribedBy:["footnote-label"]},children:[{type:"text",value:String(o)}]};e.patch(n,c);const u={type:"element",tagName:"sup",properties:{},children:[c]};return e.patch(n,u),e.applyData(n,u)}function No(e,n){const t={type:"element",tagName:"h"+n.depth,properties:{},children:e.all(n)};return e.patch(n,t),e.applyData(n,t)}function Do(e,n){if(e.options.allowDangerousHtml){const t={type:"raw",value:n.value};return e.patch(n,t),e.applyData(n,t)}}function nr(e,n){const t=n.referenceType;let r="]";if(t==="collapsed"?r+="[]":t==="full"&&(r+="["+(n.label||n.identifier)+"]"),n.type==="imageReference")return[{type:"text",value:"!["+n.alt+r}];const i=e.all(n),l=i[0];l&&l.type==="text"?l.value="["+l.value:i.unshift({type:"text",value:"["});const o=i[i.length-1];return o&&o.type==="text"?o.value+=r:i.push({type:"text",value:r}),i}function _o(e,n){const t=String(n.identifier).toUpperCase(),r=e.definitionById.get(t);if(!r)return nr(e,n);const i={src:Le(r.url||""),alt:n.alt};r.title!==null&&r.title!==void 0&&(i.title=r.title);const l={type:"element",tagName:"img",properties:i,children:[]};return e.patch(n,l),e.applyData(n,l)}function Ro(e,n){const t={src:Le(n.url)};n.alt!==null&&n.alt!==void 0&&(t.alt=n.alt),n.title!==null&&n.title!==void 0&&(t.title=n.title);const r={type:"element",tagName:"img",properties:t,children:[]};return e.patch(n,r),e.applyData(n,r)}function Oo(e,n){const t={type:"text",value:n.value.replace(/\r?\n|\r/g," ")};e.patch(n,t);const r={type:"element",tagName:"code",properties:{},children:[t]};return e.patch(n,r),e.applyData(n,r)}function Fo(e,n){const t=String(n.identifier).toUpperCase(),r=e.definitionById.get(t);if(!r)return nr(e,n);const i={href:Le(r.url||"")};r.title!==null&&r.title!==void 0&&(i.title=r.title);const l={type:"element",tagName:"a",properties:i,children:e.all(n)};return e.patch(n,l),e.applyData(n,l)}function Mo(e,n){const t={href:Le(n.url)};n.title!==null&&n.title!==void 0&&(t.title=n.title);const r={type:"element",tagName:"a",properties:t,children:e.all(n)};return e.patch(n,r),e.applyData(n,r)}function Bo(e,n,t){const r=e.all(n),i=t?jo(t):tr(n),l={},o=[];if(typeof n.checked=="boolean"){const s=r[0];let p;s&&s.type==="element"&&s.tagName==="p"?p=s:(p={type:"element",tagName:"p",properties:{},children:[]},r.unshift(p)),p.children.length>0&&p.children.unshift({type:"text",value:" "}),p.children.unshift({type:"element",tagName:"input",properties:{type:"checkbox",checked:n.checked,disabled:!0},children:[]}),l.className=["task-list-item"]}let a=-1;for(;++a1}function Ho(e,n){const t={},r=e.all(n);let i=-1;for(typeof n.start=="number"&&n.start!==1&&(t.start=n.start);++i0){const o={type:"element",tagName:"tbody",properties:{},children:e.wrap(t,!0)},a=An(n.children[1]),c=Ot(n.children[n.children.length-1]);a&&c&&(o.position={start:a,end:c}),i.push(o)}const l={type:"element",tagName:"table",properties:{},children:e.wrap(i,!0)};return e.patch(n,l),e.applyData(n,l)}function Wo(e,n,t){const r=t?t.children:void 0,l=(r?r.indexOf(n):1)===0?"th":"td",o=t&&t.type==="table"?t.align:void 0,a=o?o.length:n.children.length;let c=-1;const u=[];for(;++c0,!0),r[0]),i=r.index+r[0].length,r=t.exec(n);return l.push(mt(n.slice(i),i>0,!1)),l.join("")}function mt(e,n,t){let r=0,i=e.length;if(n){let l=e.codePointAt(r);for(;l===ft||l===ht;)r++,l=e.codePointAt(r)}if(t){let l=e.codePointAt(i-1);for(;l===ft||l===ht;)i--,l=e.codePointAt(i-1)}return i>r?e.slice(r,i):""}function Qo(e,n){const t={type:"text",value:Xo(String(n.value))};return e.patch(n,t),e.applyData(n,t)}function Ko(e,n){const t={type:"element",tagName:"hr",properties:{},children:[]};return e.patch(n,t),e.applyData(n,t)}const Go={blockquote:To,break:Ao,code:Po,delete:zo,emphasis:Lo,footnoteReference:vo,heading:No,html:Do,imageReference:_o,image:Ro,inlineCode:Oo,linkReference:Fo,link:Mo,listItem:Bo,list:Ho,paragraph:Uo,root:Vo,strong:qo,table:$o,tableCell:Yo,tableRow:Wo,text:Qo,thematicBreak:Ko,toml:$e,yaml:$e,definition:$e,footnoteDefinition:$e};function $e(){}const rr=-1,Je=0,je=1,Qe=2,_n=3,Rn=4,On=5,Fn=6,ir=7,lr=8,{defineProperty:Jo}=Object,or=typeof self=="object"?self:globalThis,dt=(e,n)=>{switch(e){case"Function":case"SharedWorker":case"Worker":case"eval":case"setInterval":case"setTimeout":throw new TypeError("unable to deserialize "+e)}return new or[e](n)},Zo=(e,n)=>{const t=(i,l)=>(e.set(l,i),i),r=i=>{if(e.has(i))return e.get(i);const[l,o]=n[i];switch(l){case Je:case rr:return t(o,i);case je:{const a=t([],i);for(const c of o)a.push(r(c));return a}case Qe:{const a=t({},i);for(const[c,u]of o){const s=r(c),p=r(u);s==="__proto__"?Jo(a,s,{value:p,configurable:!0,enumerable:!0,writable:!0}):a[s]=p}return a}case _n:return t(new Date(o),i);case Rn:{const{source:a,flags:c}=o;return t(new RegExp(a,c),i)}case On:{const a=t(new Map,i);for(const[c,u]of o)a.set(r(c),r(u));return a}case Fn:{const a=t(new Set,i);for(const c of o)a.add(r(c));return a}case ir:{const{name:a,message:c}=o;return t(typeof or[a]=="function"?dt(a,c):new Error(c),i)}case lr:return t(BigInt(o),i);case"BigInt":return t(Object(BigInt(o)),i);case"ArrayBuffer":return t(new Uint8Array(o).buffer,o);case"DataView":{const{buffer:a}=new Uint8Array(o);return t(new DataView(a),o)}case"-0":return-0}return t(dt(l,o),i)};return r},gt=e=>Zo(new Map,e)(0),be="",{toString:ea}={},{keys:na,is:ta}=Object,Fe=e=>{const n=typeof e;if(n!=="object"||!e)return[Je,n];const t=ea.call(e).slice(8,-1);switch(t){case"Array":return[je,be];case"Object":return[Qe,be];case"Date":return[_n,be];case"RegExp":return[Rn,be];case"Map":return[On,be];case"Set":return[Fn,be];case"DataView":return[je,t]}return t.includes("Array")?[je,t]:e instanceof Error?[ir,e.name||"Error"]:[Qe,t]},We=([e,n])=>e===Je&&(n==="function"||n==="symbol"),ra=(e,n,t,r)=>{const i=(o,a)=>{const c=r.push(o)-1;return t.set(a,c),c},l=o=>{if(t.has(o))return t.get(o);let[a,c]=Fe(o);switch(a){case Je:{let s=o;switch(c){case"bigint":a=lr,s=o.toString();break;case"number":if(!o&&ta(o,-0))return r.push(["-0"])-1;break;case"function":case"symbol":if(e)throw new TypeError("unable to serialize "+c);s=null;break;case"undefined":return i([rr],o)}return i([a,s],o)}case je:{if(c){let d=o;return c==="DataView"?d=new Uint8Array(o.buffer):c==="ArrayBuffer"&&(d=new Uint8Array(o)),i([c,[...d]],o)}const s=[],p=i([a,s],o);for(const d of o)s.push(l(d));return p}case Qe:{if(c)switch(c){case"BigInt":return i([c,o.toString()],o);case"Boolean":case"Number":case"String":return i([c,o.valueOf()],o)}if(n&&"toJSON"in o)return l(o.toJSON());const s=[],p=i([a,s],o);for(const d of na(o))(e||!We(Fe(o[d])))&&s.push([l(d),l(o[d])]);return p}case _n:return i([a,isNaN(o.getTime())?be:o.toISOString()],o);case Rn:{const{source:s,flags:p}=o;return i([a,{source:s,flags:p}],o)}case On:{const s=[],p=i([a,s],o);for(const[d,f]of o)(e||!(We(Fe(d))||We(Fe(f))))&&s.push([l(d),l(f)]);return p}case Fn:{const s=[],p=i([a,s],o);for(const d of o)(e||!We(Fe(d)))&&s.push(l(d));return p}}const{message:u}=o;return i([a,{name:c,message:u}],o)};return l},yt=(e,{json:n,lossy:t}={})=>{const r=[];return ra(!(n||t),!!n,new Map,r)(e),r},Ke=typeof structuredClone=="function"?(e,n)=>n&&("json"in n||"lossy"in n)?gt(yt(e,n)):structuredClone(e):(e,n)=>gt(yt(e,n));function ia(e,n){const t=[{type:"text",value:"↩"}];return n>1&&t.push({type:"element",tagName:"sup",properties:{},children:[{type:"text",value:String(n)}]}),t}function la(e,n){return"Back to reference "+(e+1)+(n>1?"-"+n:"")}function oa(e){const n=typeof e.options.clobberPrefix=="string"?e.options.clobberPrefix:"user-content-",t=e.options.footnoteBackContent||ia,r=e.options.footnoteBackLabel||la,i=e.options.footnoteLabel||"Footnotes",l=e.options.footnoteLabelTagName||"h2",o=e.options.footnoteLabelProperties||{className:["sr-only"]},a=[];let c=-1;for(;++c0&&w.push({type:"text",value:" "});let L=typeof t=="string"?t:t(c,f);typeof L=="string"&&(L={type:"text",value:L}),w.push({type:"element",tagName:"a",properties:{href:"#"+n+"fnref-"+d+(f>1?"-"+f:""),dataFootnoteBackref:"",ariaLabel:typeof r=="string"?r:r(c,f),className:["data-footnote-backref"]},children:Array.isArray(L)?L:[L]})}const I=s[s.length-1];if(I&&I.type==="element"&&I.tagName==="p"){const L=I.children[I.children.length-1];L&&L.type==="text"?L.value+=" ":I.children.push({type:"text",value:" "}),I.children.push(...w)}else s.push(...w);const k={type:"element",tagName:"li",properties:{id:n+"fn-"+d},children:e.wrap(s,!0)};e.patch(u,k),a.push(k)}if(a.length!==0)return{type:"element",tagName:"section",properties:{dataFootnotes:!0,className:["footnotes"]},children:[{type:"element",tagName:l,properties:{...Ke(o),id:"footnote-label"},children:[{type:"text",value:i}]},{type:"text",value:` +`},{type:"element",tagName:"ol",properties:{},children:e.wrap(a,!0)},{type:"text",value:` +`}]}}const ar=(function(e){if(e==null)return ca;if(typeof e=="function")return Ze(e);if(typeof e=="object")return Array.isArray(e)?aa(e):ua(e);if(typeof e=="string")return sa(e);throw new Error("Expected function, string, or object as test")});function aa(e){const n=[];let t=-1;for(;++t":""))+")"})}return d;function d(){let f=ur,w,E,I;if((!n||l(c,u,s[s.length-1]||void 0))&&(f=da(t(c,s)),f[0]===xt))return f;if("children"in c&&c.children){const k=c;if(k.children&&f[0]!==ha)for(E=(r?k.children.length:-1)+o,I=s.concat(k);E>-1&&E0&&t.push({type:"text",value:` +`}),t}function kt(e){let n=0,t=e.charCodeAt(n);for(;t===9||t===32;)n++,t=e.charCodeAt(n);return e.slice(n)}function bt(e,n){const t=ya(e,n),r=t.one(e,void 0),i=oa(t),l=Array.isArray(r)?{type:"root",children:r}:r||{type:"root",children:[]};return i&&l.children.push({type:"text",value:` +`},i),l}function Sa(e,n){return e&&"run"in e?async function(t,r){const i=bt(t,{file:r,...n});await e.run(i,r)}:function(t,r){return bt(t,{file:r,...e||n})}}function wt(e){if(e)throw e}var an,St;function Ea(){if(St)return an;St=1;var e=Object.prototype.hasOwnProperty,n=Object.prototype.toString,t=Object.defineProperty,r=Object.getOwnPropertyDescriptor,i=function(u){return typeof Array.isArray=="function"?Array.isArray(u):n.call(u)==="[object Array]"},l=function(u){if(!u||n.call(u)!=="[object Object]")return!1;var s=e.call(u,"constructor"),p=u.constructor&&u.constructor.prototype&&e.call(u.constructor.prototype,"isPrototypeOf");if(u.constructor&&!s&&!p)return!1;var d;for(d in u);return typeof d>"u"||e.call(u,d)},o=function(u,s){t&&s.name==="__proto__"?t(u,s.name,{enumerable:!0,configurable:!0,value:s.newValue,writable:!0}):u[s.name]=s.newValue},a=function(u,s){if(s==="__proto__")if(e.call(u,s)){if(r)return r(u,s).value}else return;return u[s]};return an=function c(){var u,s,p,d,f,w,E=arguments[0],I=1,k=arguments.length,L=!1;for(typeof E=="boolean"&&(L=E,E=arguments[1]||{},I=2),(E==null||typeof E!="object"&&typeof E!="function")&&(E={});Io.length;let c;a&&o.push(i);try{c=e.apply(this,o)}catch(u){const s=u;if(a&&t)throw s;return i(s)}a||(c&&c.then&&typeof c.then=="function"?c.then(l,i):c instanceof Error?i(c):l(c))}function i(o,...a){t||(t=!0,n(o,...a))}function l(o){i(null,o)}}const ue={basename:Aa,dirname:Pa,extname:za,join:La,sep:"/"};function Aa(e,n){if(n!==void 0&&typeof n!="string")throw new TypeError('"ext" argument must be a string');Ve(e);let t=0,r=-1,i=e.length,l;if(n===void 0||n.length===0||n.length>e.length){for(;i--;)if(e.codePointAt(i)===47){if(l){t=i+1;break}}else r<0&&(l=!0,r=i+1);return r<0?"":e.slice(t,r)}if(n===e)return"";let o=-1,a=n.length-1;for(;i--;)if(e.codePointAt(i)===47){if(l){t=i+1;break}}else o<0&&(l=!0,o=i+1),a>-1&&(e.codePointAt(i)===n.codePointAt(a--)?a<0&&(r=i):(a=-1,r=o));return t===r?r=o:r<0&&(r=e.length),e.slice(t,r)}function Pa(e){if(Ve(e),e.length===0)return".";let n=-1,t=e.length,r;for(;--t;)if(e.codePointAt(t)===47){if(r){n=t;break}}else r||(r=!0);return n<0?e.codePointAt(0)===47?"/":".":n===1&&e.codePointAt(0)===47?"//":e.slice(0,n)}function za(e){Ve(e);let n=e.length,t=-1,r=0,i=-1,l=0,o;for(;n--;){const a=e.codePointAt(n);if(a===47){if(o){r=n+1;break}continue}t<0&&(o=!0,t=n+1),a===46?i<0?i=n:l!==1&&(l=1):i>-1&&(l=-1)}return i<0||t<0||l===0||l===1&&i===t-1&&i===r+1?"":e.slice(i,t)}function La(...e){let n=-1,t;for(;++n0&&e.codePointAt(e.length-1)===47&&(t+="/"),n?"/"+t:t}function Na(e,n){let t="",r=0,i=-1,l=0,o=-1,a,c;for(;++o<=e.length;){if(o2){if(c=t.lastIndexOf("/"),c!==t.length-1){c<0?(t="",r=0):(t=t.slice(0,c),r=t.length-1-t.lastIndexOf("/")),i=o,l=0;continue}}else if(t.length>0){t="",r=0,i=o,l=0;continue}}n&&(t=t.length>0?t+"/..":"..",r=2)}else t.length>0?t+="/"+e.slice(i+1,o):t=e.slice(i+1,o),r=o-i-1;i=o,l=0}else a===46&&l>-1?l++:l=-1}return t}function Ve(e){if(typeof e!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(e))}const Da={cwd:_a};function _a(){return"/"}function Cn(e){return!!(e!==null&&typeof e=="object"&&"href"in e&&e.href&&"protocol"in e&&e.protocol&&e.auth===void 0)}function Ra(e){if(typeof e=="string")e=new URL(e);else if(!Cn(e)){const n=new TypeError('The "path" argument must be of type string or an instance of URL. Received `'+e+"`");throw n.code="ERR_INVALID_ARG_TYPE",n}if(e.protocol!=="file:"){const n=new TypeError("The URL must be of scheme file");throw n.code="ERR_INVALID_URL_SCHEME",n}return Oa(e)}function Oa(e){if(e.hostname!==""){const r=new TypeError('File URL host must be "localhost" or empty on darwin');throw r.code="ERR_INVALID_FILE_URL_HOST",r}const n=e.pathname;let t=-1;for(;++t0){let[f,...w]=s;const E=r[d][1];En(E)&&En(f)&&(f=un(!0,E,f)),r[d]=[u,f,...w]}}}}const ja=new Mn().freeze();function fn(e,n){if(typeof n!="function")throw new TypeError("Cannot `"+e+"` without `parser`")}function hn(e,n){if(typeof n!="function")throw new TypeError("Cannot `"+e+"` without `compiler`")}function mn(e,n){if(n)throw new Error("Cannot call `"+e+"` on a frozen processor.\nCreate a new processor first, by calling it: use `processor()` instead of `processor`.")}function Ct(e){if(!En(e)||typeof e.type!="string")throw new TypeError("Expected node, got `"+e+"`")}function It(e,n,t){if(!t)throw new Error("`"+e+"` finished async. Use `"+n+"` instead")}function Ye(e){return Ha(e)?e:new cr(e)}function Ha(e){return!!(e&&typeof e=="object"&&"message"in e&&"messages"in e)}function Ua(e){return typeof e=="string"||Va(e)}function Va(e){return!!(e&&typeof e=="object"&&"byteLength"in e&&"byteOffset"in e)}const qa="https://github.com/remarkjs/react-markdown/blob/main/changelog.md",Tt=[],At={allowDangerousHtml:!0},$a=/^(https?|ircs?|mailto|xmpp)$/i,Wa=[{from:"astPlugins",id:"remove-buggy-html-in-markdown-parser"},{from:"allowDangerousHtml",id:"remove-buggy-html-in-markdown-parser"},{from:"allowNode",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"allowElement"},{from:"allowedTypes",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"allowedElements"},{from:"className",id:"remove-classname"},{from:"disallowedTypes",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"disallowedElements"},{from:"escapeHtml",id:"remove-buggy-html-in-markdown-parser"},{from:"includeElementIndex",id:"#remove-includeelementindex"},{from:"includeNodeIndex",id:"change-includenodeindex-to-includeelementindex"},{from:"linkTarget",id:"remove-linktarget"},{from:"plugins",id:"change-plugins-to-remarkplugins",to:"remarkPlugins"},{from:"rawSourcePos",id:"#remove-rawsourcepos"},{from:"renderers",id:"change-renderers-to-components",to:"components"},{from:"source",id:"change-source-to-children",to:"children"},{from:"sourcePos",id:"#remove-sourcepos"},{from:"transformImageUri",id:"#add-urltransform",to:"urlTransform"},{from:"transformLinkUri",id:"#add-urltransform",to:"urlTransform"}];function Ya(e){const n=Xa(e),t=Qa(e);return Ka(n.runSync(n.parse(t),t),e)}function Xa(e){const n=e.rehypePlugins||Tt,t=e.remarkPlugins||Tt,r=e.remarkRehypeOptions?{...e.remarkRehypeOptions,...At}:At;return ja().use(Io).use(t).use(Sa,r).use(n)}function Qa(e){const n=e.children||"",t=new cr;return typeof n=="string"&&(t.value=n),t}function Ka(e,n){const t=n.allowedElements,r=n.allowElement,i=n.components,l=n.disallowedElements,o=n.skipHtml,a=n.unwrapDisallowed,c=n.urlTransform||Ga;for(const s of Wa)Object.hasOwn(n,s.from)&&(""+s.from+(s.to?"use `"+s.to+"` instead":"remove it")+qa+s.id,void 0);return sr(e,u),ri(e,{Fragment:W.Fragment,components:i,ignoreInvalidStyle:!0,jsx:W.jsx,jsxs:W.jsxs,passKeys:!0,passNode:!0});function u(s,p,d){if(s.type==="raw"&&d&&typeof p=="number")return o?d.children.splice(p,1):d.children[p]={type:"text",value:s.value},p;if(s.type==="element"){let f;for(f in rn)if(Object.hasOwn(rn,f)&&Object.hasOwn(s.properties,f)){const w=s.properties[f],E=rn[f];(E===null||E.includes(s.tagName))&&(s.properties[f]=c(String(w||""),f,s))}}if(s.type==="element"){let f=t?!t.includes(s.tagName):l?l.includes(s.tagName):!1;if(!f&&r&&typeof p=="number"&&(f=!r(s,p,d)),f&&d&&typeof p=="number")return a&&s.children?d.children.splice(p,1,...s.children):d.children.splice(p,1),p}}}function Ga(e){const n=e.indexOf(":"),t=e.indexOf("?"),r=e.indexOf("#"),i=e.indexOf("/");return n===-1||i!==-1&&n>i||t!==-1&&n>t||r!==-1&&n>r||$a.test(e.slice(0,n))?e:""}function Za({deviceId:e,selectedDate:n}){const[t,r]=Te.useState(null),[i,l]=Te.useState(!1),[o,a]=Te.useState(null),c=Te.useCallback(async()=>{if(!t){l(!0),a(null);try{const u=await wr.getDailyReport(e,n);r(u)}catch(u){console.error("Error loading analysis:",u),a("خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.")}finally{l(!1)}}},[e,n,t]);return Te.useEffect(()=>{r(null),a(null)},[n]),Te.useEffect(()=>{c()},[c]),i?W.jsx(Sr,{message:"در حال دریافت تحلیل...",fullScreen:!1}):o?W.jsx(Er,{message:o,action:W.jsx(Cr,{onClick:()=>{r(null),c()},variant:"primary",icon:Ir,children:"تلاش مجدد"})}):t?W.jsxs("div",{className:"space-y-6",children:[W.jsx("div",{className:"bg-gradient-to-l from-indigo-50 to-purple-50 rounded-xl p-4 border border-indigo-100",children:W.jsxs("div",{className:"grid grid-cols-2 md:grid-cols-4 gap-4 text-center",children:[W.jsxs("div",{children:[W.jsx("p",{className:"text-xs text-gray-500 mb-1",children:"تعداد رکوردها"}),W.jsx("p",{className:"text-lg font-bold text-gray-800",children:en(t.recordCount)})]}),W.jsxs("div",{children:[W.jsx("p",{className:"text-xs text-gray-500 mb-1",children:"رکوردهای نمونه"}),W.jsx("p",{className:"text-lg font-bold text-gray-800",children:en(t.sampledRecordCount)})]}),W.jsxs("div",{children:[W.jsx("p",{className:"text-xs text-gray-500 mb-1",children:"توکن‌های مصرفی"}),W.jsx("p",{className:"text-lg font-bold text-gray-800",children:en(t.totalTokens)})]}),W.jsxs("div",{children:[W.jsx("p",{className:"text-xs text-gray-500 mb-1",children:"منبع"}),W.jsx("p",{className:"text-lg font-bold text-gray-800",children:t.fromCache?"💾 کش":"🆕 جدید"})]})]})}),W.jsx("div",{className:"bg-white rounded-xl shadow-md p-6 prose prose-sm max-w-none markdown-content",children:W.jsx(Ya,{children:t.analysis})})]}):W.jsx(Tr,{icon:Pr,title:"تحلیلی موجود نیست",message:"تحلیلی برای نمایش وجود ندارد"})}export{Za as AnalysisTab}; diff --git a/src/GreenHome.Api/wwwroot/assets/WeatherTab-CUfVpZFI.js b/src/GreenHome.Api/wwwroot/assets/WeatherTab-CUfVpZFI.js new file mode 100644 index 0000000..89d918f --- /dev/null +++ b/src/GreenHome.Api/wwwroot/assets/WeatherTab-CUfVpZFI.js @@ -0,0 +1 @@ +import{j as s,T as g,P as N,S as b,W as v,r,i as m,f as T,a as W,b as E,Q as S,c as w,L as C,E as L,B as M,R as H,M as P,d as k}from"./index-DukGkLqQ.js";function O({weatherData:e,selectedDate:a}){return s.jsx("div",{className:"space-y-6",children:s.jsxs("div",{className:"bg-white rounded-lg border border-gray-200 overflow-hidden",children:[s.jsx("div",{className:"bg-blue-600 p-5",children:s.jsxs("div",{className:"text-center text-white",children:[s.jsx("p",{className:"text-sm opacity-90 mb-1",children:"📅 وضعیت آب و هوای روز"}),s.jsx("p",{className:"text-xl font-bold",children:a})]})}),s.jsxs("div",{className:"p-4 grid grid-cols-2 gap-3",children:[s.jsx(g,{weatherData:e,isToday:!1}),s.jsx(N,{weatherData:e}),s.jsx(b,{weatherData:e}),s.jsx(v,{weatherData:e})]})]})})}function R({selectedDate:e}){const[a,t]=r.useState(null),[h,o]=r.useState(!1),[c,n]=r.useState(null),[f,d]=r.useState(null),[j,i]=r.useState("در حال دریافت..."),x=r.useCallback(async()=>{if(!a){o(!0),n(null);try{const l=m(e),[p,y]=await Promise.all([l?T():W(e),E(S,w)]);t(p),i(y)}catch(l){console.error("Error loading weather:",l),n("خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید."),i("کهک قم، ایران")}finally{o(!1)}}},[e,a]);if(r.useEffect(()=>{t(null),n(null),d(null),i("در حال دریافت...")},[e]),h)return s.jsx(C,{message:"در حال دریافت اطلاعات آب و هوا...",fullScreen:!1});if(c)return s.jsx(L,{message:c,action:s.jsx(M,{onClick:()=>{t(null),x()},variant:"primary",icon:H,children:"تلاش مجدد"})});if(!a)return x(),null;const u=m(e);return s.jsxs("div",{className:"space-y-6",children:[s.jsxs("div",{className:"flex items-center justify-between",children:[s.jsxs("div",{className:"flex items-center gap-2 text-gray-600",children:[s.jsx(P,{className:"w-5 h-5 text-sky-500"}),s.jsx("span",{className:"font-medium",children:j})]}),s.jsx("span",{className:"text-xs text-gray-400",children:u?"پیش‌بینی هواشناسی برای مدیریت گلخانه":"وضعیت آب و هوای روز انتخاب شده"})]}),u?s.jsx(k,{weatherData:a,expandedDayIndex:f,onDayToggle:d}):s.jsx(O,{weatherData:a,selectedDate:e})]})}export{R as WeatherTab}; diff --git a/src/GreenHome.Api/wwwroot/assets/index-8ZWM35nx.css b/src/GreenHome.Api/wwwroot/assets/index-8ZWM35nx.css new file mode 100644 index 0000000..a3d82c6 --- /dev/null +++ b/src/GreenHome.Api/wwwroot/assets/index-8ZWM35nx.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial}}}@layer theme{:root,:host{--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Vazirmatn", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;--default-mono-font-family:"Vazirmatn", monospace}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\/2{top:50%}.top-\[-8px\]{top:-8px}.top-\[-20px\]{top:-20px}.top-\[-30px\]{top:-30px}.top-\[45px\]{top:45px}.right-0{right:0}.right-4{right:calc(var(--spacing) * 4)}.-bottom-6{bottom:calc(var(--spacing) * -6)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-\[-20px\]{bottom:-20px}.bottom-\[-30px\]{bottom:-30px}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-4{left:calc(var(--spacing) * 4)}.z-10{z-index:10}.z-15{z-index:15}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.-mx-6{margin-inline:calc(var(--spacing) * -6)}.mx-\[-1rem\]{margin-inline:-1rem}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.-mt-6{margin-top:calc(var(--spacing) * -6)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-0\.5{margin-right:calc(var(--spacing) * .5)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:var(--spacing)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-32{height:calc(var(--spacing) * 32)}.h-full{height:100%}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[90px\]{min-height:90px}.min-h-screen{min-height:100vh}.w-0{width:0}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-36{width:calc(var(--spacing) * 36)}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.min-w-0{min-width:0}.min-w-\[80px\]{min-width:80px}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-105{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.-rotate-90{rotate:-90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-manipulation{touch-action:manipulation}.resize{resize:both}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r-2{border-right-style:var(--tw-border-style);border-right-width:2px}.border-r-4{border-right-style:var(--tw-border-style);border-right-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-500{border-color:var(--color-amber-500)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-500{border-color:var(--color-blue-500)}.border-cyan-200{border-color:var(--color-cyan-200)}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-indigo-100{border-color:var(--color-indigo-100)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-indigo-600{border-color:var(--color-indigo-600)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-300{border-color:var(--color-orange-300)}.border-orange-500{border-color:var(--color-orange-500)}.border-purple-200{border-color:var(--color-purple-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-400{border-color:var(--color-slate-400)}.border-white{border-color:var(--color-white)}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-green-600{border-top-color:var(--color-green-600)}.border-t-transparent{border-top-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-200\/50{background-color:#fee68580}@supports (color:color-mix(in lab,red,red)){.bg-amber-200\/50{background-color:color-mix(in oklab,var(--color-amber-200) 50%,transparent)}}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-400{background-color:var(--color-cyan-400)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-gray-600{background-color:var(--color-gray-600)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-200{background-color:var(--color-red-200)}.bg-red-400\/30{background-color:#ff65684d}@supports (color:color-mix(in lab,red,red)){.bg-red-400\/30{background-color:color-mix(in oklab,var(--color-red-400) 30%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-500{background-color:var(--color-rose-500)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-400{background-color:var(--color-sky-400)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab,red,red)){.bg-white\/20{background-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.bg-white\/50{background-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.bg-white\/50{background-color:color-mix(in oklab,var(--color-white) 50%,transparent)}}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-l{--tw-gradient-position:to left in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-amber-50{--tw-gradient-from:var(--color-amber-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-50{--tw-gradient-from:var(--color-emerald-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-gray-50{--tw-gradient-from:var(--color-gray-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-gray-500{--tw-gradient-from:var(--color-gray-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-gray-600{--tw-gradient-from:var(--color-gray-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-green-50{--tw-gradient-from:var(--color-green-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-green-500{--tw-gradient-from:var(--color-green-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-indigo-50{--tw-gradient-from:var(--color-indigo-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-indigo-500{--tw-gradient-from:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-orange-500{--tw-gradient-from:var(--color-orange-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-500{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-red-500{--tw-gradient-from:var(--color-red-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-rose-50{--tw-gradient-from:var(--color-rose-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-sky-50{--tw-gradient-from:var(--color-sky-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-sky-500{--tw-gradient-from:var(--color-sky-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-50{--tw-gradient-from:var(--color-slate-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-yellow-500{--tw-gradient-from:var(--color-yellow-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-100{--tw-gradient-to:var(--color-blue-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-600{--tw-gradient-to:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-500{--tw-gradient-to:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-600{--tw-gradient-to:var(--color-cyan-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-emerald-50{--tw-gradient-to:var(--color-emerald-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-emerald-600{--tw-gradient-to:var(--color-emerald-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to:var(--color-gray-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-600{--tw-gradient-to:var(--color-gray-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-700{--tw-gradient-to:var(--color-gray-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-green-100{--tw-gradient-to:var(--color-green-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-green-600{--tw-gradient-to:var(--color-green-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-600{--tw-gradient-to:var(--color-indigo-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-orange-500{--tw-gradient-to:var(--color-orange-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-purple-50{--tw-gradient-to:var(--color-purple-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-purple-600{--tw-gradient-to:var(--color-purple-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-red-100{--tw-gradient-to:var(--color-red-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-red-600{--tw-gradient-to:var(--color-red-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-yellow-100{--tw-gradient-to:var(--color-yellow-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-1{padding-top:var(--spacing)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-10{padding-left:calc(var(--spacing) * 10)}.pl-12{padding-left:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:Vazirmatn,monospace}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-cyan-600{color:var(--color-cyan-600)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-600{color:var(--color-indigo-600)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-rose-600{color:var(--color-rose-600)}.text-sky-500{color:var(--color-sky-500)}.text-sky-600{color:var(--color-sky-600)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-white{color:var(--color-white)}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab,red,red)){.text-white\/60{color:color-mix(in oklab,var(--color-white) 60%,transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-yellow-600{color:var(--color-yellow-600)}.italic{font-style:italic}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-90{opacity:.9}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.select-none{-webkit-user-select:none;user-select:none}@media(hover:hover){.group-hover\:-translate-x-1:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:translate-x-\[-4px\]:is(:where(.group):hover *){--tw-translate-x:-4px;translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:text-green-600:is(:where(.group):hover *){color:var(--color-green-600)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:border-green-300:hover{border-color:var(--color-green-300)}.hover\:border-green-400:hover{border-color:var(--color-green-400)}.hover\:border-green-500:hover{border-color:var(--color-green-500)}.hover\:border-orange-400:hover{border-color:var(--color-orange-400)}.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-green-200:hover{background-color:var(--color-green-200)}.hover\:bg-green-600:hover{background-color:var(--color-green-600)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-orange-50:hover{background-color:var(--color-orange-50)}.hover\:bg-orange-600:hover{background-color:var(--color-orange-600)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:from-blue-600:hover{--tw-gradient-from:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:from-green-600:hover{--tw-gradient-from:var(--color-green-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:from-orange-600:hover{--tw-gradient-from:var(--color-orange-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-blue-700:hover{--tw-gradient-to:var(--color-blue-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-green-700:hover{--tw-gradient-to:var(--color-green-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-red-700:hover{--tw-gradient-to:var(--color-red-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-800:hover{color:var(--color-gray-800)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-600:hover{color:var(--color-green-600)}.hover\:text-green-700:hover{color:var(--color-green-700)}.hover\:text-slate-900:hover{color:var(--color-slate-900)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-sm:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:border-green-500:focus{border-color:var(--color-green-500)}.focus\:border-orange-500:focus{border-color:var(--color-orange-500)}.focus\:border-red-500:focus{border-color:var(--color-red-500)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-green-500\/20:focus{--tw-ring-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.focus\:ring-green-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.focus\:ring-orange-500:focus{--tw-ring-color:var(--color-orange-500)}.focus\:ring-red-500\/20:focus{--tw-ring-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.focus\:ring-red-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-\[0\.98\]:active{scale:.98}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:block{display:block}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:h-5{height:calc(var(--spacing) * 5)}.sm\:h-6{height:calc(var(--spacing) * 6)}.sm\:w-5{width:calc(var(--spacing) * 5)}.sm\:w-6{width:calc(var(--spacing) * 6)}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:48rem){.md\:right-4{right:calc(var(--spacing) * 4)}.md\:left-auto{left:auto}.md\:mx-0{margin-inline:0}.md\:flex{display:flex}.md\:hidden{display:none}.md\:min-h-\[100px\]{min-height:100px}.md\:w-96{width:calc(var(--spacing) * 96)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:rounded-xl{border-radius:var(--radius-xl)}.md\:p-3{padding:calc(var(--spacing) * 3)}.md\:p-4{padding:calc(var(--spacing) * 4)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:p-12{padding:calc(var(--spacing) * 12)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}.md\:pt-0{padding-top:0}.md\:pb-0{padding-bottom:0}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media(min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media(min-width:80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media(prefers-color-scheme:dark){.dark\:border-red-400{border-color:var(--color-red-400)}.dark\:border-slate-500{border-color:var(--color-slate-500)}.dark\:border-slate-700{border-color:var(--color-slate-700)}.dark\:bg-red-500\/40{background-color:#fb2c3666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-500\/40{background-color:color-mix(in oklab,var(--color-red-500) 40%,transparent)}}.dark\:bg-slate-500{background-color:var(--color-slate-500)}.dark\:bg-slate-700{background-color:var(--color-slate-700)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-slate-200{color:var(--color-slate-200)}.dark\:text-slate-300{color:var(--color-slate-300)}.dark\:text-slate-400{color:var(--color-slate-400)}@media(hover:hover){.dark\:hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.dark\:hover\:text-slate-200:hover{color:var(--color-slate-200)}}}.\[\&\:\:-moz-range-thumb\]\:pointer-events-auto::-moz-range-thumb{pointer-events:auto}.\[\&\:\:-moz-range-thumb\]\:h-20::-moz-range-thumb{height:calc(var(--spacing) * 20)}.\[\&\:\:-moz-range-thumb\]\:w-3::-moz-range-thumb{width:calc(var(--spacing) * 3)}.\[\&\:\:-moz-range-thumb\]\:cursor-grab::-moz-range-thumb{cursor:grab}.\[\&\:\:-moz-range-thumb\]\:rounded-sm::-moz-range-thumb{border-radius:var(--radius-sm)}.\[\&\:\:-moz-range-thumb\]\:border-0::-moz-range-thumb{border-style:var(--tw-border-style);border-width:0}.\[\&\:\:-moz-range-thumb\]\:bg-emerald-500::-moz-range-thumb{background-color:var(--color-emerald-500)}.\[\&\:\:-moz-range-thumb\]\:bg-rose-500::-moz-range-thumb{background-color:var(--color-rose-500)}.\[\&\:\:-webkit-slider-thumb\]\:pointer-events-auto::-webkit-slider-thumb{pointer-events:auto}.\[\&\:\:-webkit-slider-thumb\]\:h-20::-webkit-slider-thumb{height:calc(var(--spacing) * 20)}.\[\&\:\:-webkit-slider-thumb\]\:w-3::-webkit-slider-thumb{width:calc(var(--spacing) * 3)}.\[\&\:\:-webkit-slider-thumb\]\:cursor-grab::-webkit-slider-thumb{cursor:grab}.\[\&\:\:-webkit-slider-thumb\]\:appearance-none::-webkit-slider-thumb{-webkit-appearance:none;-moz-appearance:none;appearance:none}.\[\&\:\:-webkit-slider-thumb\]\:rounded-sm::-webkit-slider-thumb{border-radius:var(--radius-sm)}.\[\&\:\:-webkit-slider-thumb\]\:bg-emerald-500::-webkit-slider-thumb{background-color:var(--color-emerald-500)}.\[\&\:\:-webkit-slider-thumb\]\:bg-rose-500::-webkit-slider-thumb{background-color:var(--color-rose-500)}.\[\&\:\:-webkit-slider-thumb\]\:active\:cursor-grabbing::-webkit-slider-thumb:active{cursor:grabbing}}:root{--foreground:#171717}@media(prefers-color-scheme:dark){:root{--foreground:#ededed}}*{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Vazirmatn,-apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif}html{scroll-behavior:smooth;-webkit-overflow-scrolling:touch}body{color:var(--foreground);overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;background:#f8fafc;font-family:Vazirmatn,-apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif;font-weight:400}button,a,[role=button]{touch-action:manipulation;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;min-width:44px;min-height:44px}.persian-number{font-feature-settings:"ss01";font-variant-numeric:persian;font-family:Vazirmatn,-apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif}.markdown-content{color:#374151;line-height:1.8}.markdown-content h1,.markdown-content h2,.markdown-content h3,.markdown-content h4{color:#1f2937;margin-top:1.5em;margin-bottom:.5em;font-weight:700}.markdown-content h1{font-size:1.5rem}.markdown-content h2{font-size:1.25rem}.markdown-content h3{font-size:1.125rem}.markdown-content h4{font-size:1rem}.markdown-content p{margin-bottom:1em}.markdown-content ul,.markdown-content ol{margin:.75em 0;padding-right:1.5em}.markdown-content li{margin:.25em 0}.markdown-content strong{color:#1f2937;font-weight:700}.markdown-content code{background-color:#f3f4f6;border-radius:.25em;padding:.125em .375em;font-size:.875em}.markdown-content blockquote{color:#6b7280;border-right:4px solid #6366f1;margin:1em 0;padding-right:1em;font-style:italic}.markdown-content hr{border:none;border-top:1px solid #e5e7eb;margin:1.5em 0}@keyframes shimmer{0%{background-position:-1000px 0}to{background-position:1000px 0}}.skeleton{background:linear-gradient(90deg,#f0f0f0,#e0e0e0,#f0f0f0) 0 0/2000px 100%;animation:2s infinite shimmer}@supports (padding-bottom:env(safe-area-inset-bottom)){.pb-safe{padding-bottom:env(safe-area-inset-bottom)}.h-safe-bottom{height:env(safe-area-inset-bottom)}}.has-bottom-nav{padding-bottom:5rem}@media(max-width:768px){.has-bottom-nav{padding-bottom:4.5rem}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/src/GreenHome.Api/wwwroot/assets/index-DukGkLqQ.js b/src/GreenHome.Api/wwwroot/assets/index-DukGkLqQ.js new file mode 100644 index 0000000..351dc69 --- /dev/null +++ b/src/GreenHome.Api/wwwroot/assets/index-DukGkLqQ.js @@ -0,0 +1,357 @@ +var e2=Object.defineProperty;var n2=(n,e,a)=>e in n?e2(n,e,{enumerable:!0,configurable:!0,writable:!0,value:a}):n[e]=a;var gt=(n,e,a)=>n2(n,typeof e!="symbol"?e+"":e,a);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const c of r)if(c.type==="childList")for(const u of c.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&s(u)}).observe(document,{childList:!0,subtree:!0});function a(r){const c={};return r.integrity&&(c.integrity=r.integrity),r.referrerPolicy&&(c.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?c.credentials="include":r.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function s(r){if(r.ep)return;r.ep=!0;const c=a(r);fetch(r.href,c)}})();function Ix(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var yf={exports:{}},il={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Q0;function i2(){if(Q0)return il;Q0=1;var n=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function a(s,r,c){var u=null;if(c!==void 0&&(u=""+c),r.key!==void 0&&(u=""+r.key),"key"in r){c={};for(var f in r)f!=="key"&&(c[f]=r[f])}else c=r;return r=c.ref,{$$typeof:n,type:s,key:u,ref:r!==void 0?r:null,props:c}}return il.Fragment=e,il.jsx=a,il.jsxs=a,il}var Z0;function a2(){return Z0||(Z0=1,yf.exports=i2()),yf.exports}var m=a2(),bf={exports:{}},ft={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var P0;function s2(){if(P0)return ft;P0=1;var n=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),u=Symbol.for("react.context"),f=Symbol.for("react.forward_ref"),d=Symbol.for("react.suspense"),g=Symbol.for("react.memo"),x=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),v=Symbol.iterator;function _(A){return A===null||typeof A!="object"?null:(A=v&&A[v]||A["@@iterator"],typeof A=="function"?A:null)}var M={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,j={};function w(A,B,Z){this.props=A,this.context=B,this.refs=j,this.updater=Z||M}w.prototype.isReactComponent={},w.prototype.setState=function(A,B){if(typeof A!="object"&&typeof A!="function"&&A!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,A,B,"setState")},w.prototype.forceUpdate=function(A){this.updater.enqueueForceUpdate(this,A,"forceUpdate")};function C(){}C.prototype=w.prototype;function z(A,B,Z){this.props=A,this.context=B,this.refs=j,this.updater=Z||M}var L=z.prototype=new C;L.constructor=z,T(L,w.prototype),L.isPureReactComponent=!0;var k=Array.isArray;function $(){}var O={H:null,A:null,T:null,S:null},Q=Object.prototype.hasOwnProperty;function I(A,B,Z){var P=Z.ref;return{$$typeof:n,type:A,key:B,ref:P!==void 0?P:null,props:Z}}function ot(A,B){return I(A.type,B,A.props)}function W(A){return typeof A=="object"&&A!==null&&A.$$typeof===n}function at(A){var B={"=":"=0",":":"=2"};return"$"+A.replace(/[=:]/g,function(Z){return B[Z]})}var jt=/\/+/g;function At(A,B){return typeof A=="object"&&A!==null&&A.key!=null?at(""+A.key):B.toString(36)}function ut(A){switch(A.status){case"fulfilled":return A.value;case"rejected":throw A.reason;default:switch(typeof A.status=="string"?A.then($,$):(A.status="pending",A.then(function(B){A.status==="pending"&&(A.status="fulfilled",A.value=B)},function(B){A.status==="pending"&&(A.status="rejected",A.reason=B)})),A.status){case"fulfilled":return A.value;case"rejected":throw A.reason}}throw A}function Y(A,B,Z,P,tt){var et=typeof A;(et==="undefined"||et==="boolean")&&(A=null);var dt=!1;if(A===null)dt=!0;else switch(et){case"bigint":case"string":case"number":dt=!0;break;case"object":switch(A.$$typeof){case n:case e:dt=!0;break;case x:return dt=A._init,Y(dt(A._payload),B,Z,P,tt)}}if(dt)return tt=tt(A),dt=P===""?"."+At(A,0):P,k(tt)?(Z="",dt!=null&&(Z=dt.replace(jt,"$&/")+"/"),Y(tt,B,Z,"",function(xe){return xe})):tt!=null&&(W(tt)&&(tt=ot(tt,Z+(tt.key==null||A&&A.key===tt.key?"":(""+tt.key).replace(jt,"$&/")+"/")+dt)),B.push(tt)),1;dt=0;var Qt=P===""?".":P+":";if(k(A))for(var Tt=0;Tt>>1,vt=Y[bt];if(0>>1;btr(Z,st))Pr(tt,Z)?(Y[bt]=tt,Y[P]=st,bt=P):(Y[bt]=Z,Y[B]=st,bt=B);else if(Pr(tt,st))Y[bt]=tt,Y[P]=st,bt=P;else break t}}return K}function r(Y,K){var st=Y.sortIndex-K.sortIndex;return st!==0?st:Y.id-K.id}if(n.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;n.unstable_now=function(){return c.now()}}else{var u=Date,f=u.now();n.unstable_now=function(){return u.now()-f}}var d=[],g=[],x=1,y=null,v=3,_=!1,M=!1,T=!1,j=!1,w=typeof setTimeout=="function"?setTimeout:null,C=typeof clearTimeout=="function"?clearTimeout:null,z=typeof setImmediate<"u"?setImmediate:null;function L(Y){for(var K=a(g);K!==null;){if(K.callback===null)s(g);else if(K.startTime<=Y)s(g),K.sortIndex=K.expirationTime,e(d,K);else break;K=a(g)}}function k(Y){if(T=!1,L(Y),!M)if(a(d)!==null)M=!0,$||($=!0,at());else{var K=a(g);K!==null&&ut(k,K.startTime-Y)}}var $=!1,O=-1,Q=5,I=-1;function ot(){return j?!0:!(n.unstable_now()-IY&&ot());){var bt=y.callback;if(typeof bt=="function"){y.callback=null,v=y.priorityLevel;var vt=bt(y.expirationTime<=Y);if(Y=n.unstable_now(),typeof vt=="function"){y.callback=vt,L(Y),K=!0;break e}y===a(d)&&s(d),L(Y)}else s(d);y=a(d)}if(y!==null)K=!0;else{var A=a(g);A!==null&&ut(k,A.startTime-Y),K=!1}}break t}finally{y=null,v=st,_=!1}K=void 0}}finally{K?at():$=!1}}}var at;if(typeof z=="function")at=function(){z(W)};else if(typeof MessageChannel<"u"){var jt=new MessageChannel,At=jt.port2;jt.port1.onmessage=W,at=function(){At.postMessage(null)}}else at=function(){w(W,0)};function ut(Y,K){O=w(function(){Y(n.unstable_now())},K)}n.unstable_IdlePriority=5,n.unstable_ImmediatePriority=1,n.unstable_LowPriority=4,n.unstable_NormalPriority=3,n.unstable_Profiling=null,n.unstable_UserBlockingPriority=2,n.unstable_cancelCallback=function(Y){Y.callback=null},n.unstable_forceFrameRate=function(Y){0>Y||125bt?(Y.sortIndex=st,e(g,Y),a(d)===null&&Y===a(g)&&(T?(C(O),O=-1):T=!0,ut(k,st-bt))):(Y.sortIndex=vt,e(d,Y),M||_||(M=!0,$||($=!0,at()))),Y},n.unstable_shouldYield=ot,n.unstable_wrapCallback=function(Y){var K=v;return function(){var st=v;v=K;try{return Y.apply(this,arguments)}finally{v=st}}}})(Sf)),Sf}var W0;function r2(){return W0||(W0=1,_f.exports=l2()),_f.exports}var jf={exports:{}},me={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var J0;function o2(){if(J0)return me;J0=1;var n=fd();function e(d){var g="https://react.dev/errors/"+d;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}return n(),jf.exports=o2(),jf.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var ep;function u2(){if(ep)return al;ep=1;var n=r2(),e=fd(),a=c2();function s(t){var i="https://react.dev/errors/"+t;if(1vt||(t.current=bt[vt],bt[vt]=null,vt--)}function Z(t,i){vt++,bt[vt]=t.current,t.current=i}var P=A(null),tt=A(null),et=A(null),dt=A(null);function Qt(t,i){switch(Z(et,i),Z(tt,t),Z(P,null),i.nodeType){case 9:case 11:t=(t=i.documentElement)&&(t=t.namespaceURI)?p0(t):0;break;default:if(t=i.tagName,i=i.namespaceURI)i=p0(i),t=x0(i,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}B(P),Z(P,t)}function Tt(){B(P),B(tt),B(et)}function xe(t){t.memoizedState!==null&&Z(dt,t);var i=P.current,l=x0(i,t.type);i!==l&&(Z(tt,t),Z(P,l))}function Mi(t){tt.current===t&&(B(P),B(tt)),dt.current===t&&(B(dt),Js._currentValue=st)}var Jo,Fd;function Ti(t){if(Jo===void 0)try{throw Error()}catch(l){var i=l.stack.trim().match(/\n( *(at )?)/);Jo=i&&i[1]||"",Fd=-1)":-1h||E[o]!==U[h]){var G=` +`+E[o].replace(" at new "," at ");return t.displayName&&G.includes("")&&(G=G.replace("",t.displayName)),G}while(1<=o&&0<=h);break}}}finally{tc=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?Ti(l):""}function Ob(t,i){switch(t.tag){case 26:case 27:case 5:return Ti(t.type);case 16:return Ti("Lazy");case 13:return t.child!==i&&i!==null?Ti("Suspense Fallback"):Ti("Suspense");case 19:return Ti("SuspenseList");case 0:case 15:return ec(t.type,!1);case 11:return ec(t.type.render,!1);case 1:return ec(t.type,!0);case 31:return Ti("Activity");default:return""}}function Qd(t){try{var i="",l=null;do i+=Ob(t,l),l=t,t=t.return;while(t);return i}catch(o){return` +Error generating stack: `+o.message+` +`+o.stack}}var nc=Object.prototype.hasOwnProperty,ic=n.unstable_scheduleCallback,ac=n.unstable_cancelCallback,zb=n.unstable_shouldYield,Rb=n.unstable_requestPaint,Ce=n.unstable_now,kb=n.unstable_getCurrentPriorityLevel,Zd=n.unstable_ImmediatePriority,Pd=n.unstable_UserBlockingPriority,kl=n.unstable_NormalPriority,Lb=n.unstable_LowPriority,Kd=n.unstable_IdlePriority,Hb=n.log,Ub=n.unstable_setDisableYieldValue,fs=null,De=null;function Fn(t){if(typeof Hb=="function"&&Ub(t),De&&typeof De.setStrictMode=="function")try{De.setStrictMode(fs,t)}catch{}}var Ae=Math.clz32?Math.clz32:$b,Bb=Math.log,Vb=Math.LN2;function $b(t){return t>>>=0,t===0?32:31-(Bb(t)/Vb|0)|0}var Ll=256,Hl=262144,Ul=4194304;function Ei(t){var i=t&42;if(i!==0)return i;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Bl(t,i,l){var o=t.pendingLanes;if(o===0)return 0;var h=0,p=t.suspendedLanes,b=t.pingedLanes;t=t.warmLanes;var S=o&134217727;return S!==0?(o=S&~p,o!==0?h=Ei(o):(b&=S,b!==0?h=Ei(b):l||(l=S&~t,l!==0&&(h=Ei(l))))):(S=o&~p,S!==0?h=Ei(S):b!==0?h=Ei(b):l||(l=o&~t,l!==0&&(h=Ei(l)))),h===0?0:i!==0&&i!==h&&(i&p)===0&&(p=h&-h,l=i&-i,p>=l||p===32&&(l&4194048)!==0)?i:h}function ds(t,i){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&i)===0}function Yb(t,i){switch(t){case 1:case 2:case 4:case 8:case 64:return i+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Id(){var t=Ul;return Ul<<=1,(Ul&62914560)===0&&(Ul=4194304),t}function sc(t){for(var i=[],l=0;31>l;l++)i.push(t);return i}function hs(t,i){t.pendingLanes|=i,i!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function qb(t,i,l,o,h,p){var b=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var S=t.entanglements,E=t.expirationTimes,U=t.hiddenUpdates;for(l=b&~l;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Pb=/[\n"\\]/g;function Ge(t){return t.replace(Pb,function(i){return"\\"+i.charCodeAt(0).toString(16)+" "})}function fc(t,i,l,o,h,p,b,S){t.name="",b!=null&&typeof b!="function"&&typeof b!="symbol"&&typeof b!="boolean"?t.type=b:t.removeAttribute("type"),i!=null?b==="number"?(i===0&&t.value===""||t.value!=i)&&(t.value=""+qe(i)):t.value!==""+qe(i)&&(t.value=""+qe(i)):b!=="submit"&&b!=="reset"||t.removeAttribute("value"),i!=null?dc(t,b,qe(i)):l!=null?dc(t,b,qe(l)):o!=null&&t.removeAttribute("value"),h==null&&p!=null&&(t.defaultChecked=!!p),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),S!=null&&typeof S!="function"&&typeof S!="symbol"&&typeof S!="boolean"?t.name=""+qe(S):t.removeAttribute("name")}function uh(t,i,l,o,h,p,b,S){if(p!=null&&typeof p!="function"&&typeof p!="symbol"&&typeof p!="boolean"&&(t.type=p),i!=null||l!=null){if(!(p!=="submit"&&p!=="reset"||i!=null)){uc(t);return}l=l!=null?""+qe(l):"",i=i!=null?""+qe(i):l,S||i===t.value||(t.value=i),t.defaultValue=i}o=o??h,o=typeof o!="function"&&typeof o!="symbol"&&!!o,t.checked=S?t.checked:!!o,t.defaultChecked=!!o,b!=null&&typeof b!="function"&&typeof b!="symbol"&&typeof b!="boolean"&&(t.name=b),uc(t)}function dc(t,i,l){i==="number"&&Yl(t.ownerDocument)===t||t.defaultValue===""+l||(t.defaultValue=""+l)}function fa(t,i,l,o){if(t=t.options,i){i={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),xc=!1;if(wn)try{var xs={};Object.defineProperty(xs,"passive",{get:function(){xc=!0}}),window.addEventListener("test",xs,xs),window.removeEventListener("test",xs,xs)}catch{xc=!1}var Zn=null,yc=null,Gl=null;function xh(){if(Gl)return Gl;var t,i=yc,l=i.length,o,h="value"in Zn?Zn.value:Zn.textContent,p=h.length;for(t=0;t=vs),jh=" ",wh=!1;function Nh(t,i){switch(t){case"keyup":return jv.indexOf(i.keyCode)!==-1;case"keydown":return i.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Mh(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ga=!1;function Nv(t,i){switch(t){case"compositionend":return Mh(i);case"keypress":return i.which!==32?null:(wh=!0,jh);case"textInput":return t=i.data,t===jh&&wh?null:t;default:return null}}function Mv(t,i){if(ga)return t==="compositionend"||!jc&&Nh(t,i)?(t=xh(),Gl=yc=Zn=null,ga=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(i.ctrlKey||i.altKey||i.metaKey)||i.ctrlKey&&i.altKey){if(i.char&&1=i)return{node:l,offset:i-t};t=o}t:{for(;l;){if(l.nextSibling){l=l.nextSibling;break t}l=l.parentNode}l=void 0}l=Rh(l)}}function Lh(t,i){return t&&i?t===i?!0:t&&t.nodeType===3?!1:i&&i.nodeType===3?Lh(t,i.parentNode):"contains"in t?t.contains(i):t.compareDocumentPosition?!!(t.compareDocumentPosition(i)&16):!1:!1}function Hh(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var i=Yl(t.document);i instanceof t.HTMLIFrameElement;){try{var l=typeof i.contentWindow.location.href=="string"}catch{l=!1}if(l)t=i.contentWindow;else break;i=Yl(t.document)}return i}function Mc(t){var i=t&&t.nodeName&&t.nodeName.toLowerCase();return i&&(i==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||i==="textarea"||t.contentEditable==="true")}var Rv=wn&&"documentMode"in document&&11>=document.documentMode,pa=null,Tc=null,ws=null,Ec=!1;function Uh(t,i,l){var o=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Ec||pa==null||pa!==Yl(o)||(o=pa,"selectionStart"in o&&Mc(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),ws&&js(ws,o)||(ws=o,o=Hr(Tc,"onSelect"),0>=b,h-=b,dn=1<<32-Ae(i)+h|l<mt?(St=nt,nt=null):St=nt.sibling;var Ct=V(R,nt,H[mt],X);if(Ct===null){nt===null&&(nt=St);break}t&&nt&&Ct.alternate===null&&i(R,nt),D=p(Ct,D,mt),Et===null?lt=Ct:Et.sibling=Ct,Et=Ct,nt=St}if(mt===H.length)return l(R,nt),wt&&Mn(R,mt),lt;if(nt===null){for(;mtmt?(St=nt,nt=null):St=nt.sibling;var pi=V(R,nt,Ct.value,X);if(pi===null){nt===null&&(nt=St);break}t&&nt&&pi.alternate===null&&i(R,nt),D=p(pi,D,mt),Et===null?lt=pi:Et.sibling=pi,Et=pi,nt=St}if(Ct.done)return l(R,nt),wt&&Mn(R,mt),lt;if(nt===null){for(;!Ct.done;mt++,Ct=H.next())Ct=F(R,Ct.value,X),Ct!==null&&(D=p(Ct,D,mt),Et===null?lt=Ct:Et.sibling=Ct,Et=Ct);return wt&&Mn(R,mt),lt}for(nt=o(nt);!Ct.done;mt++,Ct=H.next())Ct=q(nt,R,mt,Ct.value,X),Ct!==null&&(t&&Ct.alternate!==null&&nt.delete(Ct.key===null?mt:Ct.key),D=p(Ct,D,mt),Et===null?lt=Ct:Et.sibling=Ct,Et=Ct);return t&&nt.forEach(function(t2){return i(R,t2)}),wt&&Mn(R,mt),lt}function Ht(R,D,H,X){if(typeof H=="object"&&H!==null&&H.type===T&&H.key===null&&(H=H.props.children),typeof H=="object"&&H!==null){switch(H.$$typeof){case _:t:{for(var lt=H.key;D!==null;){if(D.key===lt){if(lt=H.type,lt===T){if(D.tag===7){l(R,D.sibling),X=h(D,H.props.children),X.return=R,R=X;break t}}else if(D.elementType===lt||typeof lt=="object"&<!==null&<.$$typeof===Q&&Bi(lt)===D.type){l(R,D.sibling),X=h(D,H.props),Ds(X,H),X.return=R,R=X;break t}l(R,D);break}else i(R,D);D=D.sibling}H.type===T?(X=Ri(H.props.children,R.mode,X,H.key),X.return=R,R=X):(X=tr(H.type,H.key,H.props,null,R.mode,X),Ds(X,H),X.return=R,R=X)}return b(R);case M:t:{for(lt=H.key;D!==null;){if(D.key===lt)if(D.tag===4&&D.stateNode.containerInfo===H.containerInfo&&D.stateNode.implementation===H.implementation){l(R,D.sibling),X=h(D,H.children||[]),X.return=R,R=X;break t}else{l(R,D);break}else i(R,D);D=D.sibling}X=kc(H,R.mode,X),X.return=R,R=X}return b(R);case Q:return H=Bi(H),Ht(R,D,H,X)}if(ut(H))return J(R,D,H,X);if(at(H)){if(lt=at(H),typeof lt!="function")throw Error(s(150));return H=lt.call(H),ct(R,D,H,X)}if(typeof H.then=="function")return Ht(R,D,rr(H),X);if(H.$$typeof===z)return Ht(R,D,ir(R,H),X);or(R,H)}return typeof H=="string"&&H!==""||typeof H=="number"||typeof H=="bigint"?(H=""+H,D!==null&&D.tag===6?(l(R,D.sibling),X=h(D,H),X.return=R,R=X):(l(R,D),X=Rc(H,R.mode,X),X.return=R,R=X),b(R)):l(R,D)}return function(R,D,H,X){try{Cs=0;var lt=Ht(R,D,H,X);return Ta=null,lt}catch(nt){if(nt===Ma||nt===sr)throw nt;var Et=ze(29,nt,null,R.mode);return Et.lanes=X,Et.return=R,Et}finally{}}}var $i=rm(!0),om=rm(!1),Jn=!1;function Qc(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Zc(t,i){t=t.updateQueue,i.updateQueue===t&&(i.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function ti(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ei(t,i,l){var o=t.updateQueue;if(o===null)return null;if(o=o.shared,(Dt&2)!==0){var h=o.pending;return h===null?i.next=i:(i.next=h.next,h.next=i),o.pending=i,i=Jl(t),Xh(t,null,l),i}return Wl(t,o,i,l),Jl(t)}function As(t,i,l){if(i=i.updateQueue,i!==null&&(i=i.shared,(l&4194048)!==0)){var o=i.lanes;o&=t.pendingLanes,l|=o,i.lanes=l,Jd(t,l)}}function Pc(t,i){var l=t.updateQueue,o=t.alternate;if(o!==null&&(o=o.updateQueue,l===o)){var h=null,p=null;if(l=l.firstBaseUpdate,l!==null){do{var b={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};p===null?h=p=b:p=p.next=b,l=l.next}while(l!==null);p===null?h=p=i:p=p.next=i}else h=p=i;l={baseState:o.baseState,firstBaseUpdate:h,lastBaseUpdate:p,shared:o.shared,callbacks:o.callbacks},t.updateQueue=l;return}t=l.lastBaseUpdate,t===null?l.firstBaseUpdate=i:t.next=i,l.lastBaseUpdate=i}var Kc=!1;function Os(){if(Kc){var t=Na;if(t!==null)throw t}}function zs(t,i,l,o){Kc=!1;var h=t.updateQueue;Jn=!1;var p=h.firstBaseUpdate,b=h.lastBaseUpdate,S=h.shared.pending;if(S!==null){h.shared.pending=null;var E=S,U=E.next;E.next=null,b===null?p=U:b.next=U,b=E;var G=t.alternate;G!==null&&(G=G.updateQueue,S=G.lastBaseUpdate,S!==b&&(S===null?G.firstBaseUpdate=U:S.next=U,G.lastBaseUpdate=E))}if(p!==null){var F=h.baseState;b=0,G=U=E=null,S=p;do{var V=S.lane&-536870913,q=V!==S.lane;if(q?(_t&V)===V:(o&V)===V){V!==0&&V===wa&&(Kc=!0),G!==null&&(G=G.next={lane:0,tag:S.tag,payload:S.payload,callback:null,next:null});t:{var J=t,ct=S;V=i;var Ht=l;switch(ct.tag){case 1:if(J=ct.payload,typeof J=="function"){F=J.call(Ht,F,V);break t}F=J;break t;case 3:J.flags=J.flags&-65537|128;case 0:if(J=ct.payload,V=typeof J=="function"?J.call(Ht,F,V):J,V==null)break t;F=y({},F,V);break t;case 2:Jn=!0}}V=S.callback,V!==null&&(t.flags|=64,q&&(t.flags|=8192),q=h.callbacks,q===null?h.callbacks=[V]:q.push(V))}else q={lane:V,tag:S.tag,payload:S.payload,callback:S.callback,next:null},G===null?(U=G=q,E=F):G=G.next=q,b|=V;if(S=S.next,S===null){if(S=h.shared.pending,S===null)break;q=S,S=q.next,q.next=null,h.lastBaseUpdate=q,h.shared.pending=null}}while(!0);G===null&&(E=F),h.baseState=E,h.firstBaseUpdate=U,h.lastBaseUpdate=G,p===null&&(h.shared.lanes=0),li|=b,t.lanes=b,t.memoizedState=F}}function cm(t,i){if(typeof t!="function")throw Error(s(191,t));t.call(i)}function um(t,i){var l=t.callbacks;if(l!==null)for(t.callbacks=null,t=0;tp?p:8;var b=Y.T,S={};Y.T=S,gu(t,!1,i,l);try{var E=h(),U=Y.S;if(U!==null&&U(S,E),E!==null&&typeof E=="object"&&typeof E.then=="function"){var G=qv(E,o);Ls(t,i,G,Ue(t))}else Ls(t,i,o,Ue(t))}catch(F){Ls(t,i,{then:function(){},status:"rejected",reason:F},Ue())}finally{K.p=p,b!==null&&S.types!==null&&(b.types=S.types),Y.T=b}}function Pv(){}function hu(t,i,l,o){if(t.tag!==5)throw Error(s(476));var h=Ym(t).queue;$m(t,h,i,st,l===null?Pv:function(){return qm(t),l(o)})}function Ym(t){var i=t.memoizedState;if(i!==null)return i;i={memoizedState:st,baseState:st,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Dn,lastRenderedState:st},next:null};var l={};return i.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Dn,lastRenderedState:l},next:null},t.memoizedState=i,t=t.alternate,t!==null&&(t.memoizedState=i),i}function qm(t){var i=Ym(t);i.next===null&&(i=t.alternate.memoizedState),Ls(t,i.next.queue,{},Ue())}function mu(){return fe(Js)}function Gm(){return It().memoizedState}function Xm(){return It().memoizedState}function Kv(t){for(var i=t.return;i!==null;){switch(i.tag){case 24:case 3:var l=Ue();t=ti(l);var o=ei(i,t,l);o!==null&&(Ne(o,i,l),As(o,i,l)),i={cache:qc()},t.payload=i;return}i=i.return}}function Iv(t,i,l){var o=Ue();l={lane:o,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},yr(t)?Qm(i,l):(l=Oc(t,i,l,o),l!==null&&(Ne(l,t,o),Zm(l,i,o)))}function Fm(t,i,l){var o=Ue();Ls(t,i,l,o)}function Ls(t,i,l,o){var h={lane:o,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(yr(t))Qm(i,h);else{var p=t.alternate;if(t.lanes===0&&(p===null||p.lanes===0)&&(p=i.lastRenderedReducer,p!==null))try{var b=i.lastRenderedState,S=p(b,l);if(h.hasEagerState=!0,h.eagerState=S,Oe(S,b))return Wl(t,i,h,0),Ut===null&&Il(),!1}catch{}finally{}if(l=Oc(t,i,h,o),l!==null)return Ne(l,t,o),Zm(l,i,o),!0}return!1}function gu(t,i,l,o){if(o={lane:2,revertLane:Qu(),gesture:null,action:o,hasEagerState:!1,eagerState:null,next:null},yr(t)){if(i)throw Error(s(479))}else i=Oc(t,l,o,2),i!==null&&Ne(i,t,2)}function yr(t){var i=t.alternate;return t===ht||i!==null&&i===ht}function Qm(t,i){Ca=fr=!0;var l=t.pending;l===null?i.next=i:(i.next=l.next,l.next=i),t.pending=i}function Zm(t,i,l){if((l&4194048)!==0){var o=i.lanes;o&=t.pendingLanes,l|=o,i.lanes=l,Jd(t,l)}}var Hs={readContext:fe,use:mr,useCallback:Zt,useContext:Zt,useEffect:Zt,useImperativeHandle:Zt,useLayoutEffect:Zt,useInsertionEffect:Zt,useMemo:Zt,useReducer:Zt,useRef:Zt,useState:Zt,useDebugValue:Zt,useDeferredValue:Zt,useTransition:Zt,useSyncExternalStore:Zt,useId:Zt,useHostTransitionStatus:Zt,useFormState:Zt,useActionState:Zt,useOptimistic:Zt,useMemoCache:Zt,useCacheRefresh:Zt};Hs.useEffectEvent=Zt;var Pm={readContext:fe,use:mr,useCallback:function(t,i){return ye().memoizedState=[t,i===void 0?null:i],t},useContext:fe,useEffect:Om,useImperativeHandle:function(t,i,l){l=l!=null?l.concat([t]):null,pr(4194308,4,Lm.bind(null,i,t),l)},useLayoutEffect:function(t,i){return pr(4194308,4,t,i)},useInsertionEffect:function(t,i){pr(4,2,t,i)},useMemo:function(t,i){var l=ye();i=i===void 0?null:i;var o=t();if(Yi){Fn(!0);try{t()}finally{Fn(!1)}}return l.memoizedState=[o,i],o},useReducer:function(t,i,l){var o=ye();if(l!==void 0){var h=l(i);if(Yi){Fn(!0);try{l(i)}finally{Fn(!1)}}}else h=i;return o.memoizedState=o.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},o.queue=t,t=t.dispatch=Iv.bind(null,ht,t),[o.memoizedState,t]},useRef:function(t){var i=ye();return t={current:t},i.memoizedState=t},useState:function(t){t=ou(t);var i=t.queue,l=Fm.bind(null,ht,i);return i.dispatch=l,[t.memoizedState,l]},useDebugValue:fu,useDeferredValue:function(t,i){var l=ye();return du(l,t,i)},useTransition:function(){var t=ou(!1);return t=$m.bind(null,ht,t.queue,!0,!1),ye().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,i,l){var o=ht,h=ye();if(wt){if(l===void 0)throw Error(s(407));l=l()}else{if(l=i(),Ut===null)throw Error(s(349));(_t&127)!==0||pm(o,i,l)}h.memoizedState=l;var p={value:l,getSnapshot:i};return h.queue=p,Om(ym.bind(null,o,p,t),[t]),o.flags|=2048,Aa(9,{destroy:void 0},xm.bind(null,o,p,l,i),null),l},useId:function(){var t=ye(),i=Ut.identifierPrefix;if(wt){var l=hn,o=dn;l=(o&~(1<<32-Ae(o)-1)).toString(32)+l,i="_"+i+"R_"+l,l=dr++,0<\/script>",p=p.removeChild(p.firstChild);break;case"select":p=typeof o.is=="string"?b.createElement("select",{is:o.is}):b.createElement("select"),o.multiple?p.multiple=!0:o.size&&(p.size=o.size);break;default:p=typeof o.is=="string"?b.createElement(h,{is:o.is}):b.createElement(h)}}p[ce]=i,p[be]=o;t:for(b=i.child;b!==null;){if(b.tag===5||b.tag===6)p.appendChild(b.stateNode);else if(b.tag!==4&&b.tag!==27&&b.child!==null){b.child.return=b,b=b.child;continue}if(b===i)break t;for(;b.sibling===null;){if(b.return===null||b.return===i)break t;b=b.return}b.sibling.return=b.return,b=b.sibling}i.stateNode=p;t:switch(he(p,h,o),h){case"button":case"input":case"select":case"textarea":o=!!o.autoFocus;break t;case"img":o=!0;break t;default:o=!1}o&&On(i)}}return Yt(i),Cu(i,i.type,t===null?null:t.memoizedProps,i.pendingProps,l),null;case 6:if(t&&i.stateNode!=null)t.memoizedProps!==o&&On(i);else{if(typeof o!="string"&&i.stateNode===null)throw Error(s(166));if(t=et.current,Sa(i)){if(t=i.stateNode,l=i.memoizedProps,o=null,h=ue,h!==null)switch(h.tag){case 27:case 5:o=h.memoizedProps}t[ce]=i,t=!!(t.nodeValue===l||o!==null&&o.suppressHydrationWarning===!0||m0(t.nodeValue,l)),t||In(i,!0)}else t=Ur(t).createTextNode(o),t[ce]=i,i.stateNode=t}return Yt(i),null;case 31:if(l=i.memoizedState,t===null||t.memoizedState!==null){if(o=Sa(i),l!==null){if(t===null){if(!o)throw Error(s(318));if(t=i.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(s(557));t[ce]=i}else ki(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;Yt(i),t=!1}else l=Bc(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=l),t=!0;if(!t)return i.flags&256?(ke(i),i):(ke(i),null);if((i.flags&128)!==0)throw Error(s(558))}return Yt(i),null;case 13:if(o=i.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(h=Sa(i),o!==null&&o.dehydrated!==null){if(t===null){if(!h)throw Error(s(318));if(h=i.memoizedState,h=h!==null?h.dehydrated:null,!h)throw Error(s(317));h[ce]=i}else ki(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;Yt(i),h=!1}else h=Bc(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=h),h=!0;if(!h)return i.flags&256?(ke(i),i):(ke(i),null)}return ke(i),(i.flags&128)!==0?(i.lanes=l,i):(l=o!==null,t=t!==null&&t.memoizedState!==null,l&&(o=i.child,h=null,o.alternate!==null&&o.alternate.memoizedState!==null&&o.alternate.memoizedState.cachePool!==null&&(h=o.alternate.memoizedState.cachePool.pool),p=null,o.memoizedState!==null&&o.memoizedState.cachePool!==null&&(p=o.memoizedState.cachePool.pool),p!==h&&(o.flags|=2048)),l!==t&&l&&(i.child.flags|=8192),jr(i,i.updateQueue),Yt(i),null);case 4:return Tt(),t===null&&Iu(i.stateNode.containerInfo),Yt(i),null;case 10:return En(i.type),Yt(i),null;case 19:if(B(Kt),o=i.memoizedState,o===null)return Yt(i),null;if(h=(i.flags&128)!==0,p=o.rendering,p===null)if(h)Bs(o,!1);else{if(Pt!==0||t!==null&&(t.flags&128)!==0)for(t=i.child;t!==null;){if(p=ur(t),p!==null){for(i.flags|=128,Bs(o,!1),t=p.updateQueue,i.updateQueue=t,jr(i,t),i.subtreeFlags=0,t=l,l=i.child;l!==null;)Fh(l,t),l=l.sibling;return Z(Kt,Kt.current&1|2),wt&&Mn(i,o.treeForkCount),i.child}t=t.sibling}o.tail!==null&&Ce()>Er&&(i.flags|=128,h=!0,Bs(o,!1),i.lanes=4194304)}else{if(!h)if(t=ur(p),t!==null){if(i.flags|=128,h=!0,t=t.updateQueue,i.updateQueue=t,jr(i,t),Bs(o,!0),o.tail===null&&o.tailMode==="hidden"&&!p.alternate&&!wt)return Yt(i),null}else 2*Ce()-o.renderingStartTime>Er&&l!==536870912&&(i.flags|=128,h=!0,Bs(o,!1),i.lanes=4194304);o.isBackwards?(p.sibling=i.child,i.child=p):(t=o.last,t!==null?t.sibling=p:i.child=p,o.last=p)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Ce(),t.sibling=null,l=Kt.current,Z(Kt,h?l&1|2:l&1),wt&&Mn(i,o.treeForkCount),t):(Yt(i),null);case 22:case 23:return ke(i),Wc(),o=i.memoizedState!==null,t!==null?t.memoizedState!==null!==o&&(i.flags|=8192):o&&(i.flags|=8192),o?(l&536870912)!==0&&(i.flags&128)===0&&(Yt(i),i.subtreeFlags&6&&(i.flags|=8192)):Yt(i),l=i.updateQueue,l!==null&&jr(i,l.retryQueue),l=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(l=t.memoizedState.cachePool.pool),o=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(o=i.memoizedState.cachePool.pool),o!==l&&(i.flags|=2048),t!==null&&B(Ui),null;case 24:return l=null,t!==null&&(l=t.memoizedState.cache),i.memoizedState.cache!==l&&(i.flags|=2048),En(te),Yt(i),null;case 25:return null;case 30:return null}throw Error(s(156,i.tag))}function n1(t,i){switch(Hc(i),i.tag){case 1:return t=i.flags,t&65536?(i.flags=t&-65537|128,i):null;case 3:return En(te),Tt(),t=i.flags,(t&65536)!==0&&(t&128)===0?(i.flags=t&-65537|128,i):null;case 26:case 27:case 5:return Mi(i),null;case 31:if(i.memoizedState!==null){if(ke(i),i.alternate===null)throw Error(s(340));ki()}return t=i.flags,t&65536?(i.flags=t&-65537|128,i):null;case 13:if(ke(i),t=i.memoizedState,t!==null&&t.dehydrated!==null){if(i.alternate===null)throw Error(s(340));ki()}return t=i.flags,t&65536?(i.flags=t&-65537|128,i):null;case 19:return B(Kt),null;case 4:return Tt(),null;case 10:return En(i.type),null;case 22:case 23:return ke(i),Wc(),t!==null&&B(Ui),t=i.flags,t&65536?(i.flags=t&-65537|128,i):null;case 24:return En(te),null;case 25:return null;default:return null}}function bg(t,i){switch(Hc(i),i.tag){case 3:En(te),Tt();break;case 26:case 27:case 5:Mi(i);break;case 4:Tt();break;case 31:i.memoizedState!==null&&ke(i);break;case 13:ke(i);break;case 19:B(Kt);break;case 10:En(i.type);break;case 22:case 23:ke(i),Wc(),t!==null&&B(Ui);break;case 24:En(te)}}function Vs(t,i){try{var l=i.updateQueue,o=l!==null?l.lastEffect:null;if(o!==null){var h=o.next;l=h;do{if((l.tag&t)===t){o=void 0;var p=l.create,b=l.inst;o=p(),b.destroy=o}l=l.next}while(l!==h)}}catch(S){zt(i,i.return,S)}}function ai(t,i,l){try{var o=i.updateQueue,h=o!==null?o.lastEffect:null;if(h!==null){var p=h.next;o=p;do{if((o.tag&t)===t){var b=o.inst,S=b.destroy;if(S!==void 0){b.destroy=void 0,h=i;var E=l,U=S;try{U()}catch(G){zt(h,E,G)}}}o=o.next}while(o!==p)}}catch(G){zt(i,i.return,G)}}function vg(t){var i=t.updateQueue;if(i!==null){var l=t.stateNode;try{um(i,l)}catch(o){zt(t,t.return,o)}}}function _g(t,i,l){l.props=qi(t.type,t.memoizedProps),l.state=t.memoizedState;try{l.componentWillUnmount()}catch(o){zt(t,i,o)}}function $s(t,i){try{var l=t.ref;if(l!==null){switch(t.tag){case 26:case 27:case 5:var o=t.stateNode;break;case 30:o=t.stateNode;break;default:o=t.stateNode}typeof l=="function"?t.refCleanup=l(o):l.current=o}}catch(h){zt(t,i,h)}}function mn(t,i){var l=t.ref,o=t.refCleanup;if(l!==null)if(typeof o=="function")try{o()}catch(h){zt(t,i,h)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(h){zt(t,i,h)}else l.current=null}function Sg(t){var i=t.type,l=t.memoizedProps,o=t.stateNode;try{t:switch(i){case"button":case"input":case"select":case"textarea":l.autoFocus&&o.focus();break t;case"img":l.src?o.src=l.src:l.srcSet&&(o.srcset=l.srcSet)}}catch(h){zt(t,t.return,h)}}function Du(t,i,l){try{var o=t.stateNode;w1(o,t.type,l,i),o[be]=i}catch(h){zt(t,t.return,h)}}function jg(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&fi(t.type)||t.tag===4}function Au(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||jg(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&fi(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function Ou(t,i,l){var o=t.tag;if(o===5||o===6)t=t.stateNode,i?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(t,i):(i=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,i.appendChild(t),l=l._reactRootContainer,l!=null||i.onclick!==null||(i.onclick=jn));else if(o!==4&&(o===27&&fi(t.type)&&(l=t.stateNode,i=null),t=t.child,t!==null))for(Ou(t,i,l),t=t.sibling;t!==null;)Ou(t,i,l),t=t.sibling}function wr(t,i,l){var o=t.tag;if(o===5||o===6)t=t.stateNode,i?l.insertBefore(t,i):l.appendChild(t);else if(o!==4&&(o===27&&fi(t.type)&&(l=t.stateNode),t=t.child,t!==null))for(wr(t,i,l),t=t.sibling;t!==null;)wr(t,i,l),t=t.sibling}function wg(t){var i=t.stateNode,l=t.memoizedProps;try{for(var o=t.type,h=i.attributes;h.length;)i.removeAttributeNode(h[0]);he(i,o,l),i[ce]=t,i[be]=l}catch(p){zt(t,t.return,p)}}var zn=!1,ie=!1,zu=!1,Ng=typeof WeakSet=="function"?WeakSet:Set,re=null;function i1(t,i){if(t=t.containerInfo,tf=Xr,t=Hh(t),Mc(t)){if("selectionStart"in t)var l={start:t.selectionStart,end:t.selectionEnd};else t:{l=(l=t.ownerDocument)&&l.defaultView||window;var o=l.getSelection&&l.getSelection();if(o&&o.rangeCount!==0){l=o.anchorNode;var h=o.anchorOffset,p=o.focusNode;o=o.focusOffset;try{l.nodeType,p.nodeType}catch{l=null;break t}var b=0,S=-1,E=-1,U=0,G=0,F=t,V=null;e:for(;;){for(var q;F!==l||h!==0&&F.nodeType!==3||(S=b+h),F!==p||o!==0&&F.nodeType!==3||(E=b+o),F.nodeType===3&&(b+=F.nodeValue.length),(q=F.firstChild)!==null;)V=F,F=q;for(;;){if(F===t)break e;if(V===l&&++U===h&&(S=b),V===p&&++G===o&&(E=b),(q=F.nextSibling)!==null)break;F=V,V=F.parentNode}F=q}l=S===-1||E===-1?null:{start:S,end:E}}else l=null}l=l||{start:0,end:0}}else l=null;for(ef={focusedElem:t,selectionRange:l},Xr=!1,re=i;re!==null;)if(i=re,t=i.child,(i.subtreeFlags&1028)!==0&&t!==null)t.return=i,re=t;else for(;re!==null;){switch(i=re,p=i.alternate,t=i.flags,i.tag){case 0:if((t&4)!==0&&(t=i.updateQueue,t=t!==null?t.events:null,t!==null))for(l=0;l title"))),he(p,o,l),p[ce]=t,le(p),o=p;break t;case"link":var b=A0("link","href",h).get(o+(l.href||""));if(b){for(var S=0;SHt&&(b=Ht,Ht=ct,ct=b);var R=kh(S,ct),D=kh(S,Ht);if(R&&D&&(q.rangeCount!==1||q.anchorNode!==R.node||q.anchorOffset!==R.offset||q.focusNode!==D.node||q.focusOffset!==D.offset)){var H=F.createRange();H.setStart(R.node,R.offset),q.removeAllRanges(),ct>Ht?(q.addRange(H),q.extend(D.node,D.offset)):(H.setEnd(D.node,D.offset),q.addRange(H))}}}}for(F=[],q=S;q=q.parentNode;)q.nodeType===1&&F.push({element:q,left:q.scrollLeft,top:q.scrollTop});for(typeof S.focus=="function"&&S.focus(),S=0;Sl?32:l,Y.T=null,l=Vu,Vu=null;var p=oi,b=Un;if(ae=0,La=oi=null,Un=0,(Dt&6)!==0)throw Error(s(331));var S=Dt;if(Dt|=4,Lg(p.current),zg(p,p.current,b,l),Dt=S,Qs(0,!1),De&&typeof De.onPostCommitFiberRoot=="function")try{De.onPostCommitFiberRoot(fs,p)}catch{}return!0}finally{K.p=h,Y.T=o,t0(t,i)}}function n0(t,i,l){i=Fe(l,i),i=bu(t.stateNode,i,2),t=ei(t,i,2),t!==null&&(hs(t,2),gn(t))}function zt(t,i,l){if(t.tag===3)n0(t,t,l);else for(;i!==null;){if(i.tag===3){n0(i,t,l);break}else if(i.tag===1){var o=i.stateNode;if(typeof i.type.getDerivedStateFromError=="function"||typeof o.componentDidCatch=="function"&&(ri===null||!ri.has(o))){t=Fe(l,t),l=ig(2),o=ei(i,l,2),o!==null&&(ag(l,o,i,t),hs(o,2),gn(o));break}}i=i.return}}function Gu(t,i,l){var o=t.pingCache;if(o===null){o=t.pingCache=new l1;var h=new Set;o.set(i,h)}else h=o.get(i),h===void 0&&(h=new Set,o.set(i,h));h.has(l)||(Lu=!0,h.add(l),t=f1.bind(null,t,i,l),i.then(t,t))}function f1(t,i,l){var o=t.pingCache;o!==null&&o.delete(i),t.pingedLanes|=t.suspendedLanes&l,t.warmLanes&=~l,Ut===t&&(_t&l)===l&&(Pt===4||Pt===3&&(_t&62914560)===_t&&300>Ce()-Tr?(Dt&2)===0&&Ha(t,0):Hu|=l,ka===_t&&(ka=0)),gn(t)}function i0(t,i){i===0&&(i=Id()),t=zi(t,i),t!==null&&(hs(t,i),gn(t))}function d1(t){var i=t.memoizedState,l=0;i!==null&&(l=i.retryLane),i0(t,l)}function h1(t,i){var l=0;switch(t.tag){case 31:case 13:var o=t.stateNode,h=t.memoizedState;h!==null&&(l=h.retryLane);break;case 19:o=t.stateNode;break;case 22:o=t.stateNode._retryCache;break;default:throw Error(s(314))}o!==null&&o.delete(i),i0(t,l)}function m1(t,i){return ic(t,i)}var Rr=null,Ba=null,Xu=!1,kr=!1,Fu=!1,ui=0;function gn(t){t!==Ba&&t.next===null&&(Ba===null?Rr=Ba=t:Ba=Ba.next=t),kr=!0,Xu||(Xu=!0,p1())}function Qs(t,i){if(!Fu&&kr){Fu=!0;do for(var l=!1,o=Rr;o!==null;){if(t!==0){var h=o.pendingLanes;if(h===0)var p=0;else{var b=o.suspendedLanes,S=o.pingedLanes;p=(1<<31-Ae(42|t)+1)-1,p&=h&~(b&~S),p=p&201326741?p&201326741|1:p?p|2:0}p!==0&&(l=!0,r0(o,p))}else p=_t,p=Bl(o,o===Ut?p:0,o.cancelPendingCommit!==null||o.timeoutHandle!==-1),(p&3)===0||ds(o,p)||(l=!0,r0(o,p));o=o.next}while(l);Fu=!1}}function g1(){a0()}function a0(){kr=Xu=!1;var t=0;ui!==0&&M1()&&(t=ui);for(var i=Ce(),l=null,o=Rr;o!==null;){var h=o.next,p=s0(o,i);p===0?(o.next=null,l===null?Rr=h:l.next=h,h===null&&(Ba=l)):(l=o,(t!==0||(p&3)!==0)&&(kr=!0)),o=h}ae!==0&&ae!==5||Qs(t),ui!==0&&(ui=0)}function s0(t,i){for(var l=t.suspendedLanes,o=t.pingedLanes,h=t.expirationTimes,p=t.pendingLanes&-62914561;0S)break;var G=E.transferSize,F=E.initiatorType;G&&g0(F)&&(E=E.responseEnd,b+=G*(E"u"?null:document;function T0(t,i,l){var o=Va;if(o&&typeof i=="string"&&i){var h=Ge(i);h='link[rel="'+t+'"][href="'+h+'"]',typeof l=="string"&&(h+='[crossorigin="'+l+'"]'),M0.has(h)||(M0.add(h),t={rel:t,crossOrigin:l,href:i},o.querySelector(h)===null&&(i=o.createElement("link"),he(i,"link",t),le(i),o.head.appendChild(i)))}}function k1(t){Bn.D(t),T0("dns-prefetch",t,null)}function L1(t,i){Bn.C(t,i),T0("preconnect",t,i)}function H1(t,i,l){Bn.L(t,i,l);var o=Va;if(o&&t&&i){var h='link[rel="preload"][as="'+Ge(i)+'"]';i==="image"&&l&&l.imageSrcSet?(h+='[imagesrcset="'+Ge(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(h+='[imagesizes="'+Ge(l.imageSizes)+'"]')):h+='[href="'+Ge(t)+'"]';var p=h;switch(i){case"style":p=$a(t);break;case"script":p=Ya(t)}We.has(p)||(t=y({rel:"preload",href:i==="image"&&l&&l.imageSrcSet?void 0:t,as:i},l),We.set(p,t),o.querySelector(h)!==null||i==="style"&&o.querySelector(Is(p))||i==="script"&&o.querySelector(Ws(p))||(i=o.createElement("link"),he(i,"link",t),le(i),o.head.appendChild(i)))}}function U1(t,i){Bn.m(t,i);var l=Va;if(l&&t){var o=i&&typeof i.as=="string"?i.as:"script",h='link[rel="modulepreload"][as="'+Ge(o)+'"][href="'+Ge(t)+'"]',p=h;switch(o){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":p=Ya(t)}if(!We.has(p)&&(t=y({rel:"modulepreload",href:t},i),We.set(p,t),l.querySelector(h)===null)){switch(o){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(Ws(p)))return}o=l.createElement("link"),he(o,"link",t),le(o),l.head.appendChild(o)}}}function B1(t,i,l){Bn.S(t,i,l);var o=Va;if(o&&t){var h=ca(o).hoistableStyles,p=$a(t);i=i||"default";var b=h.get(p);if(!b){var S={loading:0,preload:null};if(b=o.querySelector(Is(p)))S.loading=5;else{t=y({rel:"stylesheet",href:t,"data-precedence":i},l),(l=We.get(p))&&cf(t,l);var E=b=o.createElement("link");le(E),he(E,"link",t),E._p=new Promise(function(U,G){E.onload=U,E.onerror=G}),E.addEventListener("load",function(){S.loading|=1}),E.addEventListener("error",function(){S.loading|=2}),S.loading|=4,Vr(b,i,o)}b={type:"stylesheet",instance:b,count:1,state:S},h.set(p,b)}}}function V1(t,i){Bn.X(t,i);var l=Va;if(l&&t){var o=ca(l).hoistableScripts,h=Ya(t),p=o.get(h);p||(p=l.querySelector(Ws(h)),p||(t=y({src:t,async:!0},i),(i=We.get(h))&&uf(t,i),p=l.createElement("script"),le(p),he(p,"link",t),l.head.appendChild(p)),p={type:"script",instance:p,count:1,state:null},o.set(h,p))}}function $1(t,i){Bn.M(t,i);var l=Va;if(l&&t){var o=ca(l).hoistableScripts,h=Ya(t),p=o.get(h);p||(p=l.querySelector(Ws(h)),p||(t=y({src:t,async:!0,type:"module"},i),(i=We.get(h))&&uf(t,i),p=l.createElement("script"),le(p),he(p,"link",t),l.head.appendChild(p)),p={type:"script",instance:p,count:1,state:null},o.set(h,p))}}function E0(t,i,l,o){var h=(h=et.current)?Br(h):null;if(!h)throw Error(s(446));switch(t){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(i=$a(l.href),l=ca(h).hoistableStyles,o=l.get(i),o||(o={type:"style",instance:null,count:0,state:null},l.set(i,o)),o):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){t=$a(l.href);var p=ca(h).hoistableStyles,b=p.get(t);if(b||(h=h.ownerDocument||h,b={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},p.set(t,b),(p=h.querySelector(Is(t)))&&!p._p&&(b.instance=p,b.state.loading=5),We.has(t)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},We.set(t,l),p||Y1(h,t,l,b.state))),i&&o===null)throw Error(s(528,""));return b}if(i&&o!==null)throw Error(s(529,""));return null;case"script":return i=l.async,l=l.src,typeof l=="string"&&i&&typeof i!="function"&&typeof i!="symbol"?(i=Ya(l),l=ca(h).hoistableScripts,o=l.get(i),o||(o={type:"script",instance:null,count:0,state:null},l.set(i,o)),o):{type:"void",instance:null,count:0,state:null};default:throw Error(s(444,t))}}function $a(t){return'href="'+Ge(t)+'"'}function Is(t){return'link[rel="stylesheet"]['+t+"]"}function C0(t){return y({},t,{"data-precedence":t.precedence,precedence:null})}function Y1(t,i,l,o){t.querySelector('link[rel="preload"][as="style"]['+i+"]")?o.loading=1:(i=t.createElement("link"),o.preload=i,i.addEventListener("load",function(){return o.loading|=1}),i.addEventListener("error",function(){return o.loading|=2}),he(i,"link",l),le(i),t.head.appendChild(i))}function Ya(t){return'[src="'+Ge(t)+'"]'}function Ws(t){return"script[async]"+t}function D0(t,i,l){if(i.count++,i.instance===null)switch(i.type){case"style":var o=t.querySelector('style[data-href~="'+Ge(l.href)+'"]');if(o)return i.instance=o,le(o),o;var h=y({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return o=(t.ownerDocument||t).createElement("style"),le(o),he(o,"style",h),Vr(o,l.precedence,t),i.instance=o;case"stylesheet":h=$a(l.href);var p=t.querySelector(Is(h));if(p)return i.state.loading|=4,i.instance=p,le(p),p;o=C0(l),(h=We.get(h))&&cf(o,h),p=(t.ownerDocument||t).createElement("link"),le(p);var b=p;return b._p=new Promise(function(S,E){b.onload=S,b.onerror=E}),he(p,"link",o),i.state.loading|=4,Vr(p,l.precedence,t),i.instance=p;case"script":return p=Ya(l.src),(h=t.querySelector(Ws(p)))?(i.instance=h,le(h),h):(o=l,(h=We.get(p))&&(o=y({},l),uf(o,h)),t=t.ownerDocument||t,h=t.createElement("script"),le(h),he(h,"link",o),t.head.appendChild(h),i.instance=h);case"void":return null;default:throw Error(s(443,i.type))}else i.type==="stylesheet"&&(i.state.loading&4)===0&&(o=i.instance,i.state.loading|=4,Vr(o,l.precedence,t));return i.instance}function Vr(t,i,l){for(var o=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),h=o.length?o[o.length-1]:null,p=h,b=0;b title"):null)}function q1(t,i,l){if(l===1||i.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof i.precedence!="string"||typeof i.href!="string"||i.href==="")break;return!0;case"link":if(typeof i.rel!="string"||typeof i.href!="string"||i.href===""||i.onLoad||i.onError)break;switch(i.rel){case"stylesheet":return t=i.disabled,typeof i.precedence=="string"&&t==null;default:return!0}case"script":if(i.async&&typeof i.async!="function"&&typeof i.async!="symbol"&&!i.onLoad&&!i.onError&&i.src&&typeof i.src=="string")return!0}return!1}function z0(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function G1(t,i,l,o){if(l.type==="stylesheet"&&(typeof o.media!="string"||matchMedia(o.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var h=$a(o.href),p=i.querySelector(Is(h));if(p){i=p._p,i!==null&&typeof i=="object"&&typeof i.then=="function"&&(t.count++,t=Yr.bind(t),i.then(t,t)),l.state.loading|=4,l.instance=p,le(p);return}p=i.ownerDocument||i,o=C0(o),(h=We.get(h))&&cf(o,h),p=p.createElement("link"),le(p);var b=p;b._p=new Promise(function(S,E){b.onload=S,b.onerror=E}),he(p,"link",o),l.instance=p}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(l,i),(i=l.state.preload)&&(l.state.loading&3)===0&&(t.count++,l=Yr.bind(t),i.addEventListener("load",l),i.addEventListener("error",l))}}var ff=0;function X1(t,i){return t.stylesheets&&t.count===0&&Gr(t,t.stylesheets),0ff?50:800)+i);return t.unsuspend=l,function(){t.unsuspend=null,clearTimeout(o),clearTimeout(h)}}:null}function Yr(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Gr(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var qr=null;function Gr(t,i){t.stylesheets=null,t.unsuspend!==null&&(t.count++,qr=new Map,i.forEach(F1,t),qr=null,Yr.call(t))}function F1(t,i){if(!(i.state.loading&4)){var l=qr.get(t);if(l)var o=l.get(null);else{l=new Map,qr.set(t,l);for(var h=t.querySelectorAll("link[data-precedence],style[data-precedence]"),p=0;p"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}return n(),vf.exports=u2(),vf.exports}var d2=f2();const h2=Ix(d2);/** + * react-router v7.18.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var dd=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,Jx=/^[\\/]{2}/;function m2(n,e){return e+n.replace(/\\/g,"/")}var ip="popstate";function ap(n){return typeof n=="object"&&n!=null&&"pathname"in n&&"search"in n&&"hash"in n&&"state"in n&&"key"in n}function g2(n={}){function e(s,r){var g;let c=(g=r.state)==null?void 0:g.masked,{pathname:u,search:f,hash:d}=c||s.location;return $f("",{pathname:u,search:f,hash:d},r.state&&r.state.usr||null,r.state&&r.state.key||"default",c?{pathname:s.location.pathname,search:s.location.search,hash:s.location.hash}:void 0)}function a(s,r){return typeof r=="string"?r:ea(r)}return x2(e,a,null,n)}function Ft(n,e){if(n===!1||n===null||typeof n>"u")throw new Error(e)}function en(n,e){if(!n){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function p2(){return Math.random().toString(36).substring(2,10)}function sp(n,e){return{usr:n.state,key:n.key,idx:e,masked:n.mask?{pathname:n.pathname,search:n.search,hash:n.hash}:void 0}}function $f(n,e,a=null,s,r){return{pathname:typeof n=="string"?n:n.pathname,search:"",hash:"",...typeof e=="string"?ss(e):e,state:a,key:e&&e.key||s||p2(),mask:r}}function ea({pathname:n="/",search:e="",hash:a=""}){return e&&e!=="?"&&(n+=e.charAt(0)==="?"?e:"?"+e),a&&a!=="#"&&(n+=a.charAt(0)==="#"?a:"#"+a),n}function ss(n){let e={};if(n){let a=n.indexOf("#");a>=0&&(e.hash=n.substring(a),n=n.substring(0,a));let s=n.indexOf("?");s>=0&&(e.search=n.substring(s),n=n.substring(0,s)),n&&(e.pathname=n)}return e}function x2(n,e,a,s={}){let{window:r=document.defaultView,v5Compat:c=!1}=s,u=r.history,f="POP",d=null,g=x();g==null&&(g=0,u.replaceState({...u.state,idx:g},""));function x(){return(u.state||{idx:null}).idx}function y(){f="POP";let j=x(),w=j==null?null:j-g;g=j,d&&d({action:f,location:T.location,delta:w})}function v(j,w){f="PUSH";let C=ap(j)?j:$f(T.location,j,w);g=x()+1;let z=sp(C,g),L=T.createHref(C.mask||C);try{u.pushState(z,"",L)}catch(k){if(k instanceof DOMException&&k.name==="DataCloneError")throw k;r.location.assign(L)}c&&d&&d({action:f,location:T.location,delta:1})}function _(j,w){f="REPLACE";let C=ap(j)?j:$f(T.location,j,w);g=x();let z=sp(C,g),L=T.createHref(C.mask||C);u.replaceState(z,"",L),c&&d&&d({action:f,location:T.location,delta:0})}function M(j){return y2(r,j)}let T={get action(){return f},get location(){return n(r,u)},listen(j){if(d)throw new Error("A history only accepts one active listener");return r.addEventListener(ip,y),d=j,()=>{r.removeEventListener(ip,y),d=null}},createHref(j){return e(r,j)},createURL:M,encodeLocation(j){let w=M(j);return{pathname:w.pathname,search:w.search,hash:w.hash}},push:v,replace:_,go(j){return u.go(j)}};return T}function y2(n,e,a=!1){let s="http://localhost";n&&(s=n.location.origin!=="null"?n.location.origin:n.location.href),Ft(s,"No window.location.(origin|href) available to create URL");let r=typeof e=="string"?e:ea(e);return r=r.replace(/ $/,"%20"),!a&&Jx.test(r)&&(r=s+r),new URL(r,s)}function ty(n,e,a="/"){return b2(n,e,a,!1)}function b2(n,e,a,s,r){let c=typeof e=="string"?ss(e):e,u=Gn(c.pathname||"/",a);if(u==null)return null;let f=v2(n),d=null,g=A2(u);for(let x=0;d==null&&x{let x={relativePath:g===void 0?u.path||"":g,caseSensitive:u.caseSensitive===!0,childrenIndex:f,route:u};if(x.relativePath.startsWith("/")){if(!x.relativePath.startsWith(s)&&d)return;Ft(x.relativePath.startsWith(s),`Absolute route path "${x.relativePath}" nested under path "${s}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),x.relativePath=x.relativePath.slice(s.length)}let y=cn([s,x.relativePath]),v=a.concat(x);u.children&&u.children.length>0&&(Ft(u.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${y}".`),ey(u.children,e,v,y,d)),!(u.path==null&&!u.index)&&e.push({path:y,score:E2(y,u.index),routesMeta:v.map((_,M)=>{let[T,j]=ay(_.relativePath,_.caseSensitive,M===v.length-1);return{..._,matcher:T,compiledParams:j}})})};return n.forEach((u,f)=>{var d;if(u.path===""||!((d=u.path)!=null&&d.includes("?")))c(u,f);else for(let g of ny(u.path))c(u,f,!0,g)}),e}function ny(n){let e=n.split("/");if(e.length===0)return[];let[a,...s]=e,r=a.endsWith("?"),c=a.replace(/\?$/,"");if(s.length===0)return r?[c,""]:[c];let u=ny(s.join("/")),f=[];return f.push(...u.map(d=>d===""?c:[c,d].join("/"))),r&&f.push(...u),f.map(d=>n.startsWith("/")&&d===""?"/":d)}function _2(n){n.sort((e,a)=>e.score!==a.score?a.score-e.score:C2(e.routesMeta.map(s=>s.childrenIndex),a.routesMeta.map(s=>s.childrenIndex)))}var S2=/^:[\w-]+$/,j2=3,w2=2,N2=1,M2=10,T2=-2,lp=n=>n==="*";function E2(n,e){let a=n.split("/"),s=a.length;return a.some(lp)&&(s+=T2),e&&(s+=w2),a.filter(r=>!lp(r)).reduce((r,c)=>r+(S2.test(c)?j2:c===""?N2:M2),s)}function C2(n,e){return n.length===e.length&&n.slice(0,-1).every((s,r)=>s===e[r])?n[n.length-1]-e[e.length-1]:0}function D2(n,e,a=!1){let{routesMeta:s}=n,r={},c="/",u=[];for(let f=0;f{if(x==="*"){let M=f[v]||"";u=Ka(c.slice(0,c.length-M.length),1)}const _=f[v];return y&&!_?g[x]=void 0:g[x]=(_||"").replace(/%2F/g,"/"),g},{}),pathname:c,pathnameBase:u,pattern:n}}function ay(n,e=!1,a=!0){en(n==="*"||!n.endsWith("*")||n.endsWith("/*"),`Route path "${n}" will be treated as if it were "${n.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${n.replace(/\*$/,"/*")}".`);let s=[],r="^"+n.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(u,f,d,g,x)=>{if(s.push({paramName:f,isOptional:d!=null}),d){let y=x.charAt(g+u.length);return y&&y!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return n.endsWith("*")?(s.push({paramName:"*"}),r+=n==="*"||n==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?r+="\\/*$":n!==""&&n!=="/"&&(r+="(?:(?=\\/|$))"),[new RegExp(r,e?void 0:"i"),s]}function A2(n){try{return n.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return en(!1,`The URL path "${n}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${e}).`),n}}function Gn(n,e){if(e==="/")return n;if(!n.toLowerCase().startsWith(e.toLowerCase()))return null;let a=e.endsWith("/")?e.length-1:e.length,s=n.charAt(a);return s&&s!=="/"?null:n.slice(a)||"/"}function O2(n,e="/"){let{pathname:a,search:s="",hash:r=""}=typeof n=="string"?ss(n):n,c;return a?(a=sy(a),a.startsWith("/")||a.startsWith("\\")?c=rp(a.substring(1),"/"):c=rp(a,e)):c=e,{pathname:c,search:k2(s),hash:L2(r)}}function rp(n,e){let a=Ka(e).split("/");return n.split("/").forEach(r=>{r===".."?a.length>1&&a.pop():r!=="."&&a.push(r)}),a.length>1?a.join("/"):"/"}function wf(n,e,a,s){return`Cannot include a '${n}' character in a manually specified \`to.${e}\` field [${JSON.stringify(s)}]. Please separate it out to the \`to.${a}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function z2(n){return n.filter((e,a)=>a===0||e.route.path&&e.route.path.length>0)}function hd(n){let e=z2(n);return e.map((a,s)=>s===e.length-1?a.pathname:a.pathnameBase)}function Lo(n,e,a,s=!1){let r;typeof n=="string"?r=ss(n):(r={...n},Ft(!r.pathname||!r.pathname.includes("?"),wf("?","pathname","search",r)),Ft(!r.pathname||!r.pathname.includes("#"),wf("#","pathname","hash",r)),Ft(!r.search||!r.search.includes("#"),wf("#","search","hash",r)));let c=n===""||r.pathname==="",u=c?"/":r.pathname,f;if(u==null)f=a;else{let y=e.length-1;if(!s&&u.startsWith("..")){let v=u.split("/");for(;v[0]==="..";)v.shift(),y-=1;r.pathname=v.join("/")}f=y>=0?e[y]:"/"}let d=O2(r,f),g=u&&u!=="/"&&u.endsWith("/"),x=(c||u===".")&&a.endsWith("/");return!d.pathname.endsWith("/")&&(g||x)&&(d.pathname+="/"),d}var sy=n=>n.replace(/[\\/]{2,}/g,"/"),cn=n=>sy(n.join("/"));function Ka(n,e=0){let a=n.length;for(;a>e&&n.charCodeAt(a-1)===47;)a--;return a===n.length?n:n.slice(0,a)}var R2=n=>Ka(n).replace(/^\/*/,"/"),k2=n=>!n||n==="?"?"":n.startsWith("?")?n:"?"+n,L2=n=>!n||n==="#"?"":n.startsWith("#")?n:"#"+n,H2=class{constructor(n,e,a,s=!1){this.status=n,this.statusText=e||"",this.internal=s,a instanceof Error?(this.data=a.toString(),this.error=a):this.data=a}};function U2(n){return n!=null&&typeof n.status=="number"&&typeof n.statusText=="string"&&typeof n.internal=="boolean"&&"data"in n}function B2(n){let e=n.map(a=>a.route.path).filter(Boolean);return cn(e)||"/"}var ly=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function ry(n,e){let a=n;if(typeof a!="string"||!dd.test(a))return{absoluteURL:void 0,isExternal:!1,to:a};let s=a,r=!1;if(ly)try{let c=new URL(window.location.href),u=Jx.test(a)?new URL(m2(a,c.protocol)):new URL(a),f=Gn(u.pathname,e);u.origin===c.origin&&f!=null?a=f+u.search+u.hash:r=!0}catch{en(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:s,isExternal:r,to:a}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var op=new URL("http://localhost");function md(n){if(n.createURL)return n.createURL("/");try{return new URL(n.createHref("/"),op)}catch{return op}}function Nf(n,e){return n.origin===e.origin&&(n.origin!=="null"||n.protocol===e.protocol&&n.host===e.host)}function V2(n,e){if(n.startsWith("//"))return!0;let a=e.protocol.toLowerCase();return n.toLowerCase().startsWith(a)?e.host===""||n.slice(a.length).startsWith("//"):!1}function gd(n,e,a,s){let r=null;try{r=n==null?null:new URL(n,a)}catch{}let c=new URL(e,a),u=r!=null&&!Nf(r,a),f=!Nf(c,a);if(s==="reject"){if(u||f)throw new Error("External navigation is not allowed")}else if(f&&(r==null||!V2(n,r)||!Nf(r,c)))throw new Error("External navigation is not allowed")}var oy=["POST","PUT","PATCH","DELETE"];new Set(oy);var $2=["GET",...oy];new Set($2);var Y2=["about:","blob:","chrome:","chrome-untrusted:","content:","data:","devtools:","file:","filesystem:","javascript:"];function q2(n){try{return Y2.includes(new URL(n).protocol)}catch{return!1}}var ls=N.createContext(null);ls.displayName="DataRouter";var Ho=N.createContext(null);Ho.displayName="DataRouterState";var cy=N.createContext(!1);function G2(){return N.useContext(cy)}var uy=N.createContext({isTransitioning:!1});uy.displayName="ViewTransition";var X2=N.createContext(new Map);X2.displayName="Fetchers";var F2=N.createContext(null);F2.displayName="Await";var Ye=N.createContext(null);Ye.displayName="Navigation";var Al=N.createContext(null);Al.displayName="Location";var vn=N.createContext({outlet:null,matches:[],isDataRoute:!1});vn.displayName="Route";var pd=N.createContext(null);pd.displayName="RouteError";var fy="REACT_ROUTER_ERROR",Q2="REDIRECT",Z2="ROUTE_ERROR_RESPONSE";function P2(n){if(n.startsWith(`${fy}:${Q2}:{`))try{let e=JSON.parse(n.slice(28));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.location=="string"&&typeof e.reloadDocument=="boolean"&&typeof e.replace=="boolean")return e}catch{}}function K2(n){if(n.startsWith(`${fy}:${Z2}:{`))try{let e=JSON.parse(n.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new H2(e.status,e.statusText,e.data)}catch{}}function I2(n,{relative:e}={}){Ft(rs(),"useHref() may be used only in the context of a component.");let{basename:a,navigator:s}=N.useContext(Ye),{hash:r,pathname:c,search:u}=Ol(n,{relative:e}),f=c;return a!=="/"&&(f=c==="/"?a:cn([a,c])),s.createHref({pathname:f,search:u,hash:r})}function rs(){return N.useContext(Al)!=null}function nn(){return Ft(rs(),"useLocation() may be used only in the context of a component."),N.useContext(Al).location}var dy="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function hy(n){N.useContext(Ye).static||N.useLayoutEffect(n)}function Uo(){let{isDataRoute:n}=N.useContext(vn);return n?u_():W2()}function W2(){Ft(rs(),"useNavigate() may be used only in the context of a component.");let n=N.useContext(ls),{basename:e,navigator:a}=N.useContext(Ye),{matches:s}=N.useContext(vn),{pathname:r}=nn(),c=JSON.stringify(hd(s)),u=N.useRef(!1);return hy(()=>{u.current=!0}),N.useCallback((d,g={})=>{if(en(u.current,dy),!u.current)return;if(typeof d=="number"){a.go(d);return}let x=Lo(d,JSON.parse(c),r,g.relative==="path");n==null&&e!=="/"&&(x.pathname=x.pathname==="/"?e:cn([e,x.pathname])),gd(typeof d=="string"?d:ea(d),a.createHref(x),md(a),"reject"),(g.replace?a.replace:a.push)(x,g.state,g)},[e,a,c,r,n])}N.createContext(null);function Ol(n,{relative:e}={}){let{matches:a}=N.useContext(vn),{pathname:s}=nn(),r=JSON.stringify(hd(a));return N.useMemo(()=>Lo(n,JSON.parse(r),s,e==="path"),[n,r,s,e])}function J2(n,e){return my(n,e)}function my(n,e,a){var j;Ft(rs(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=N.useContext(Ye),{matches:r}=N.useContext(vn),c=r[r.length-1],u=c?c.params:{},f=c?c.pathname:"/",d=c?c.pathnameBase:"/",g=c&&c.route;{let w=g&&g.path||"";py(f,!g||w.endsWith("*")||w.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${f}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let x=nn(),y;if(e){let w=typeof e=="string"?ss(e):e;Ft(d==="/"||((j=w.pathname)==null?void 0:j.startsWith(d)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${d}" but pathname "${w.pathname}" was given in the \`location\` prop.`),y=w}else y=x;let v=y.pathname||"/",_=v;if(d!=="/"){let w=d.replace(/^\//,"").split("/");_="/"+v.replace(/^\//,"").split("/").slice(w.length).join("/")}let M=a&&a.state.matches.length?a.state.matches.map(w=>Object.assign(w,{route:a.manifest[w.route.id]||w.route})):ty(n,{pathname:_});en(g||M!=null,`No routes matched location "${y.pathname}${y.search}${y.hash}" `),en(M==null||M[M.length-1].route.element!==void 0||M[M.length-1].route.Component!==void 0||M[M.length-1].route.lazy!==void 0,`Matched leaf route at location "${y.pathname}${y.search}${y.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let T=a_(M&&M.map(w=>Object.assign({},w,{params:Object.assign({},u,w.params),pathname:cn([d,s.encodeLocation?s.encodeLocation(w.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:w.pathname]),pathnameBase:w.pathnameBase==="/"?d:cn([d,s.encodeLocation?s.encodeLocation(w.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:w.pathnameBase])})),r,a);return e&&T?N.createElement(Al.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...y},navigationType:"POP"}},T):T}function t_(){let n=c_(),e=U2(n)?`${n.status} ${n.statusText}`:n instanceof Error?n.message:JSON.stringify(n),a=n instanceof Error?n.stack:null,s="rgba(200,200,200, 0.5)",r={padding:"0.5rem",backgroundColor:s},c={padding:"2px 4px",backgroundColor:s},u=null;return console.error("Error handled by React Router default ErrorBoundary:",n),u=N.createElement(N.Fragment,null,N.createElement("p",null,"💿 Hey developer 👋"),N.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",N.createElement("code",{style:c},"ErrorBoundary")," or"," ",N.createElement("code",{style:c},"errorElement")," prop on your route.")),N.createElement(N.Fragment,null,N.createElement("h2",null,"Unexpected Application Error!"),N.createElement("h3",{style:{fontStyle:"italic"}},e),a?N.createElement("pre",{style:r},a):null,u)}var e_=N.createElement(t_,null),gy=class extends N.Component{constructor(n){super(n),this.state={location:n.location,revalidation:n.revalidation,error:n.error}}static getDerivedStateFromError(n){return{error:n}}static getDerivedStateFromProps(n,e){return e.location!==n.location||e.revalidation!=="idle"&&n.revalidation==="idle"?{error:n.error,location:n.location,revalidation:n.revalidation}:{error:n.error!==void 0?n.error:e.error,location:e.location,revalidation:n.revalidation||e.revalidation}}componentDidCatch(n,e){this.props.onError?this.props.onError(n,e):console.error("React Router caught the following error during render",n)}render(){let n=this.state.error;if(this.context&&typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){const a=K2(n.digest);a&&(n=a)}let e=n!==void 0?N.createElement(vn.Provider,{value:this.props.routeContext},N.createElement(pd.Provider,{value:n,children:this.props.component})):this.props.children;return this.context?N.createElement(n_,{error:n},e):e}};gy.contextType=cy;var Mf=new WeakMap;function n_({children:n,error:e}){let{basename:a,navigator:s}=N.useContext(Ye);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let r=P2(e.digest);if(r){let c=Mf.get(e);if(c)throw c;let u=ry(r.location,a),f=u.absoluteURL||u.to;if(gd(r.location,f,md(s),"allow-explicit"),q2(f))throw new Error("Invalid redirect location");if(ly&&!Mf.get(e))if(u.isExternal||r.reloadDocument)window.location.href=f;else{const d=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(u.to,{replace:r.replace}));throw Mf.set(e,d),d}return N.createElement("meta",{httpEquiv:"refresh",content:`0;url=${f}`})}}return n}function i_({routeContext:n,match:e,children:a}){let s=N.useContext(ls);return s&&s.static&&s.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(s.staticContext._deepestRenderedBoundaryId=e.route.id),N.createElement(vn.Provider,{value:n},a)}function a_(n,e=[],a){let s=a==null?void 0:a.state;if(n==null){if(!s)return null;if(s.errors)n=s.matches;else if(e.length===0&&!s.initialized&&s.matches.length>0)n=s.matches;else return null}let r=n,c=s==null?void 0:s.errors;if(c!=null){let x=r.findIndex(y=>y.route.id&&(c==null?void 0:c[y.route.id])!==void 0);Ft(x>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(c).join(",")}`),r=r.slice(0,Math.min(r.length,x+1))}let u=!1,f=-1;if(a&&s){u=s.renderFallback;for(let x=0;x=0?r=r.slice(0,f+1):r=[r[0]];break}}}}let d=a==null?void 0:a.onError,g=s&&d?(x,y)=>{var v,_;d(x,{location:s.location,params:((_=(v=s.matches)==null?void 0:v[0])==null?void 0:_.params)??{},pattern:B2(s.matches),errorInfo:y})}:void 0;return r.reduceRight((x,y,v)=>{let _,M=!1,T=null,j=null;s&&(_=c&&y.route.id?c[y.route.id]:void 0,T=y.route.errorElement||e_,u&&(f<0&&v===0?(py("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),M=!0,j=null):f===v&&(M=!0,j=y.route.hydrateFallbackElement||null)));let w=e.concat(r.slice(0,v+1)),C=()=>{let z;return _?z=T:M?z=j:y.route.Component?z=N.createElement(y.route.Component,null):y.route.element?z=y.route.element:z=x,N.createElement(i_,{match:y,routeContext:{outlet:x,matches:w,isDataRoute:s!=null},children:z})};return s&&(y.route.ErrorBoundary||y.route.errorElement||v===0)?N.createElement(gy,{location:s.location,revalidation:s.revalidation,component:T,error:_,children:C(),routeContext:{outlet:null,matches:w,isDataRoute:!0},onError:g}):C()},null)}function xd(n){return`${n} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function s_(n){let e=N.useContext(ls);return Ft(e,xd(n)),e}function l_(n){let e=N.useContext(Ho);return Ft(e,xd(n)),e}function r_(n){let e=N.useContext(vn);return Ft(e,xd(n)),e}function yd(n){let e=r_(n),a=e.matches[e.matches.length-1];return Ft(a.route.id,`${n} can only be used on routes that contain a unique "id"`),a.route.id}function o_(){return yd("useRouteId")}function c_(){var s;let n=N.useContext(pd),e=l_("useRouteError"),a=yd("useRouteError");return n!==void 0?n:(s=e.errors)==null?void 0:s[a]}function u_(){let{router:n}=s_("useNavigate"),e=yd("useNavigate"),a=N.useRef(!1);return hy(()=>{a.current=!0}),N.useCallback(async(r,c={})=>{en(a.current,dy),a.current&&(typeof r=="number"?await n.navigate(r):await n.navigate(r,{fromRouteId:e,...c}))},[n,e])}var cp={};function py(n,e,a){!e&&!cp[n]&&(cp[n]=!0,en(!1,a))}N.memo(f_);function f_({routes:n,manifest:e,future:a,state:s,isStatic:r,onError:c}){return my(n,void 0,{manifest:e,state:s,isStatic:r,onError:c})}function d_({to:n,replace:e,state:a,relative:s}){Ft(rs()," may be used only in the context of a component.");let{static:r,navigator:c}=N.useContext(Ye);en(!r," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:u}=N.useContext(vn),{pathname:f}=nn(),d=Uo(),g=Lo(n,hd(u),f,s==="path");gd(typeof n=="string"?n:ea(n),c.createHref(g),md(c),"reject");let x=JSON.stringify(g);return N.useEffect(()=>{d(JSON.parse(x),{replace:e,state:a,relative:s})},[d,x,s,e,a]),null}function Be(n){Ft(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function h_({basename:n="/",children:e=null,location:a,navigationType:s="POP",navigator:r,static:c=!1,useTransitions:u}){Ft(!rs(),"You cannot render a inside another . You should never have more than one in your app.");let f=n.replace(/^\/*/,"/"),d=N.useMemo(()=>({basename:f,navigator:r,static:c,useTransitions:u,future:{}}),[f,r,c,u]);typeof a=="string"&&(a=ss(a));let{pathname:g="/",search:x="",hash:y="",state:v=null,key:_="default",mask:M}=a,T=N.useMemo(()=>{let j=Gn(g,f);return j==null?null:{location:{pathname:j,search:x,hash:y,state:v,key:_,mask:M},navigationType:s}},[f,g,x,y,v,_,s,M]);return en(T!=null,` is not able to match the URL "${g}${x}${y}" because it does not start with the basename, so the won't render anything.`),T==null?null:N.createElement(Ye.Provider,{value:d},N.createElement(Al.Provider,{children:e,value:T}))}function m_({children:n,location:e}){return J2(Yf(n),e)}function Yf(n,e=[]){let a=[];return N.Children.forEach(n,(s,r)=>{if(!N.isValidElement(s))return;let c=[...e,r];if(s.type===N.Fragment){a.push.apply(a,Yf(s.props.children,c));return}Ft(s.type===Be,`[${typeof s.type=="string"?s.type:s.type.name}] is not a component. All component children of must be a or `),Ft(!s.props.index||!s.props.children,"An index route cannot have child routes.");let u={id:s.props.id||c.join("-"),caseSensitive:s.props.caseSensitive,element:s.props.element,Component:s.props.Component,index:s.props.index,path:s.props.path,middleware:s.props.middleware,loader:s.props.loader,action:s.props.action,hydrateFallbackElement:s.props.hydrateFallbackElement,HydrateFallback:s.props.HydrateFallback,errorElement:s.props.errorElement,ErrorBoundary:s.props.ErrorBoundary,hasErrorBoundary:s.props.hasErrorBoundary===!0||s.props.ErrorBoundary!=null||s.props.errorElement!=null,shouldRevalidate:s.props.shouldRevalidate,handle:s.props.handle,lazy:s.props.lazy};s.props.children&&(u.children=Yf(s.props.children,c)),a.push(u)}),a}var yo="get",bo="application/x-www-form-urlencoded";function Bo(n){return typeof HTMLElement<"u"&&n instanceof HTMLElement}function g_(n){return Bo(n)&&n.tagName.toLowerCase()==="button"}function p_(n){return Bo(n)&&n.tagName.toLowerCase()==="form"}function x_(n){return Bo(n)&&n.tagName.toLowerCase()==="input"}function y_(n){return!!(n.metaKey||n.altKey||n.ctrlKey||n.shiftKey)}function b_(n,e){return n.button===0&&(!e||e==="_self")&&!y_(n)}function qf(n=""){return new URLSearchParams(typeof n=="string"||Array.isArray(n)||n instanceof URLSearchParams?n:Object.keys(n).reduce((e,a)=>{let s=n[a];return e.concat(Array.isArray(s)?s.map(r=>[a,r]):[[a,s]])},[]))}function v_(n,e){let a=qf(n);return e&&e.forEach((s,r)=>{a.has(r)||e.getAll(r).forEach(c=>{a.append(r,c)})}),a}var Wr=null;function __(){if(Wr===null)try{new FormData(document.createElement("form"),0),Wr=!1}catch{Wr=!0}return Wr}var S_=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Tf(n){return n!=null&&!S_.has(n)?(en(!1,`"${n}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${bo}"`),null):n}function j_(n,e){let a,s,r,c,u;if(p_(n)){let f=n.getAttribute("action");s=f?Gn(f,e):null,a=n.getAttribute("method")||yo,r=Tf(n.getAttribute("enctype"))||bo,c=new FormData(n)}else if(g_(n)||x_(n)&&(n.type==="submit"||n.type==="image")){let f=n.form;if(f==null)throw new Error('Cannot submit a + + + {alerts.length === 0 ? ( + افزودن اولین هشدار} + /> + ) : ( +
+ {alerts.map(alert => ( + + ))} +
+ )} + + + + + + + ) +} + +export default function AlertSettingsPage() { + return ( + +
+
+

در حال بارگذاری...

+
+ + }> + +
+ ) +} diff --git a/src/GreenHome.Web/src/app/calendar/page.tsx b/src/GreenHome.Web/src/app/calendar/page.tsx new file mode 100644 index 0000000..d554005 --- /dev/null +++ b/src/GreenHome.Web/src/app/calendar/page.tsx @@ -0,0 +1,128 @@ +"use client" +import { Suspense, useEffect, useMemo, useState } from 'react' +import { useRouter, useSearchParams } from '@/lib/router' +import { api } from '@/lib/api' +import { getCurrentPersianYear } from '@/lib/date/persian-date' +import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react' +import Loading from '@/components/Loading' +import { Card } from '@/components/common' +import { YearSelector } from '@/components/calendar' +import { MonthCard } from '@/components/cards' +import { StatsCard } from '@/components/cards' +import { AppShell } from '@/components/layout' + +const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] + +function CalendarContent() { + const router = useRouter() + const searchParams = useSearchParams() + const deviceIdParam = searchParams.get('deviceId') + const [deviceId, setDeviceId] = useState(1) + const [year, setYear] = useState(getCurrentPersianYear()) + const [activeMonths, setActiveMonths] = useState([]) + const [monthDays, setMonthDays] = useState>({}) + const [loading, setLoading] = useState(true) + const years = useMemo(() => Array.from({ length: 10 }, (_, i) => getCurrentPersianYear() - 2 + i), []) + + // وقتی deviceIdParam تغییر کرد، deviceId را به روز کن + useEffect(() => { + if (deviceIdParam) { + setDeviceId(Number(deviceIdParam)) + } + }, [deviceIdParam]) + + useEffect(() => { + let mounted = true + setMonthDays({}) + setActiveMonths([]) + setLoading(true) + + api.activeMonths(deviceId, year) + .then(async (months) => { + if (!mounted) return + setActiveMonths(months) + const entries = await Promise.all(months.map(async m => { + const days = await api.monthDays(deviceId, year, m) + const records = days.reduce((s, d) => s + d.count, 0) + return [m, { days: days.length, records }] as const + })) + if (!mounted) return + setMonthDays(Object.fromEntries(entries)) + }) + .catch(console.error) + .finally(() => { + if (mounted) setLoading(false) + }) + return () => { mounted = false } + }, [deviceId, year]) + + const totalDays = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.days ?? 0), 0), [monthDays]) + const totalRecords = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.records ?? 0), 0), [monthDays]) + + if (loading) { + return + } + + return ( + +
+ {/* Main Card */} + + {/* Year Selector */} + + + {/* Summary Stats */} +
+
+ + +
+
+ + {/* Months Grid */} +
+ {monthNames.map((name, idx) => { + const m = idx + 1 + const isActive = activeMonths.includes(m) + const stats = monthDays[m] + return ( + isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)} + /> + ) + })} +
+
+
+
+ ) +} + +export default function CalendarPage() { + return ( + }> + + + ) +} diff --git a/src/GreenHome.Web/src/app/daily-report/page.tsx b/src/GreenHome.Web/src/app/daily-report/page.tsx new file mode 100644 index 0000000..3ce6485 --- /dev/null +++ b/src/GreenHome.Web/src/app/daily-report/page.tsx @@ -0,0 +1,484 @@ +"use client" +import { useEffect, useMemo, useState, useCallback, useRef, Suspense, lazy } from 'react' +import { useRouter, useSearchParams } from '@/lib/router' +import { api, TelemetryDto, PowerOutagePairDto } from '@/lib/api' +import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date' +import { formatPersianDate, ensureDateFormat, formatPersianTime } from '@/lib/format/persian-date' +import { TABS, TabType } from '@/features/daily-report' +import { detectDataGaps, estimateGapThreshold, estimateSendIntervalMinutes } from '@/features/daily-report/utils' +import { BarChart3, CalendarIcon, ChevronRight, Clock } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import Loading from '@/components/Loading' +import { SummaryTab } from '@/components/daily-report' +import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather' +import { Tabs, Button } from '@/components/common' +import { DateNavigation } from '@/components/navigation' +import { usePullToRefresh } from '@/hooks/usePullToRefresh' +import { usePolling } from '@/hooks/usePolling' +import { AppShell } from '@/components/layout' + +// Lazy load heavy components +const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab }))) +const WeatherTab = lazy(() => import('@/components/daily-report/WeatherTab').then(m => ({ default: m.WeatherTab }))) +const AnalysisTab = lazy(() => import('@/components/daily-report/AnalysisTab').then(m => ({ default: m.AnalysisTab }))) + +function DailyReportContent() { + const router = useRouter() + const searchParams = useSearchParams() + const [telemetry, setTelemetry] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('summary') + const [forecastWeather, setForecastWeather] = useState(null) + const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false) + const [visibleParams, setVisibleParams] = useState(null) + const [prevRecord, setPrevRecord] = useState(null) + const lastTimestampRef = useRef(null) + const touchStartXRef = useRef(null) + + // Map summary param to chart key + const paramToChartKey = useCallback((param: string): string => { + const mapping: Record = { + temperature: 'temp', + humidity: 'hum', + gas: 'gas', + soil: 'soil', + lux: 'lux', + } + return mapping[param] || param + }, []) + + // Handle card click: switch to charts tab and scroll to chart + const handleCardClick = useCallback((param: string) => { + const chartKey = paramToChartKey(param) + setActiveTab('charts') + + // Wait for tab to switch and chart to render, then scroll + setTimeout(() => { + const chartElement = document.getElementById(`chart-${chartKey}`) + if (chartElement) { + chartElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, 100) + }, [paramToChartKey]) + + const deviceId = Number(searchParams.get('deviceId') ?? '1') + const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay()) + + const selectedDate = useMemo(() => { + if (!dateParam) return null + try { + const decodedDate = decodeURIComponent(dateParam) + // Ensure date is in yyyy/MM/dd format + return ensureDateFormat(decodedDate) + } catch (error) { + console.error('Error decoding date parameter:', error) + return null + } + }, [dateParam]) + + const isTodaySelected = useMemo( + () => (selectedDate ? checkIsToday(selectedDate) : false), + [selectedDate], + ) + + // Navigate to previous day + const goToPreviousDay = useCallback(() => { + if (!selectedDate) return + const prevDay = getPreviousPersianDay(selectedDate) + if (prevDay) { + router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(prevDay)}`) + } + }, [selectedDate, deviceId, router]) + + // Navigate to next day + const goToNextDay = useCallback(() => { + if (!selectedDate || isTodaySelected) return + const nextDay = getNextPersianDay(selectedDate) + if (nextDay) { + router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(nextDay)}`) + } + }, [selectedDate, isTodaySelected, deviceId, router]) + + // Navigate to calendar to select a date + const goToCalendar = useCallback(() => { + router.push(`/calendar?deviceId=${deviceId}`) + }, [deviceId, router]) + + // Navigate to a specific date (from mini calendar / today chip) + const handleSelectDate = useCallback((date: string) => { + router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(date)}`) + }, [deviceId, router]) + + const fetchAllTelemetry = useCallback(async (startUtc: string, endUtc: string): Promise => { + const items: TelemetryDto[] = [] + let page = 1 + const pageSize = 1000 + + while (true) { + const result = await api.listTelemetry({ deviceId, startUtc, endUtc, page, pageSize }) + items.push(...result.items) + if (items.length >= result.totalCount || result.items.length === 0) break + page += 1 + } + + return items + }, [deviceId]) + + const loadData = useCallback(async () => { + if (!selectedDate) { + setLoading(false) + setPrevRecord(null) + return + } + + setLoading(true) + + try { + const [year, month, day] = selectedDate.split('/').map(Number) + const startDate = persianToGregorian(year, month, day) + startDate.setHours(0, 0, 0, 0) + const endDate = new Date(startDate) + endDate.setHours(23, 59, 59, 999) + + const startUtc = startDate.toISOString() + const endUtc = endDate.toISOString() + + const items = await fetchAllTelemetry(startUtc, endUtc) + setTelemetry(items) + + // Load the latest record before the selected day to seed the power state + try { + const prev = await api.listTelemetry({ deviceId, endUtc: startUtc, pageSize: 1 }) + setPrevRecord(prev.items?.[0] ?? null) + } catch (err) { + console.error('Error loading previous telemetry record:', err) + setPrevRecord(null) + } + + // Load visible params from server (controls which parameters are shown) + try { + const params = await api.getDisplayParameters(deviceId) + setVisibleParams(params ?? null) + } catch (err) { + console.error('Error loading display parameters:', err) + setVisibleParams(['gas', 'temperature', 'humidity', 'lux']) + } + } catch (error) { + console.error('Error loading telemetry:', error) + } finally { + setLoading(false) + } + }, [deviceId, selectedDate, fetchAllTelemetry]) + + // Pull-to-refresh for mobile + usePullToRefresh(loadData) + + useEffect(() => { + // Reset states when date or device changes + loadData() + }, [loadData, deviceId, selectedDate]) + + // Load forecast weather data when selectedDate is today - lazy load after main data is loaded + useEffect(() => { + if (!selectedDate || loading) { + setForecastWeather(null) + setForecastWeatherLoading(false) + return + } + + const isTodayDate = checkIsToday(selectedDate) + + if (isTodayDate) { + // Delay fetch to ensure page is fully loaded first + setForecastWeatherLoading(true) + setForecastWeather(null) + + // Use setTimeout to ensure page is fully rendered before fetching + const timer = setTimeout(() => { + fetchForecastWeather() + .then(setForecastWeather) + .catch((error) => { + console.error('Error loading forecast weather:', error) + setForecastWeather(null) + }) + .finally(() => { + setForecastWeatherLoading(false) + }) + }, 100) // Small delay to ensure page is rendered + + return () => clearTimeout(timer) + } else { + setForecastWeather(null) + setForecastWeatherLoading(false) + } + }, [selectedDate, loading]) + + const sortedTelemetry = useMemo(() => { + return [...telemetry].sort((a, b) => { + const aTime = a.serverTimestampUtc || a.timestampUtc + const bTime = b.serverTimestampUtc || b.timestampUtc + return new Date(aTime).getTime() - new Date(bTime).getTime() + }) + }, [telemetry]) + + // Derive current power status and outage timeline directly from telemetry + const { currentPower, powerOutages } = useMemo(() => { + const outages: PowerOutagePairDto[] = [] + // Previous day's last power state seeds an outage that started before midnight. + const prevPower: number | null = + prevRecord?.power === 0 || prevRecord?.power === 1 ? prevRecord.power : null + + // The live status must only reflect readings from the selected day itself. + let latestPower: 0 | 1 | null = null + let outageStart: string | null = + prevPower === 0 ? (prevRecord?.serverTimestampUtc || prevRecord?.timestampUtc || null) : null + + for (const t of sortedTelemetry) { + const p = t.power + if (p !== 0 && p !== 1) continue + + latestPower = p + const ts = t.serverTimestampUtc || t.timestampUtc + + if (p === 0) { + if (outageStart === null) outageStart = ts + } else { + if (outageStart !== null) { + const startMs = new Date(outageStart).getTime() + const endMs = new Date(ts).getTime() + outages.push({ + disconnectedAt: outageStart, + connectedAt: ts, + durationSeconds: endMs > startMs ? Math.round((endMs - startMs) / 1000) : 0, + }) + outageStart = null + } + } + } + + if (outageStart !== null) { + outages.push({ + disconnectedAt: outageStart, + connectedAt: null, + durationSeconds: null, + }) + } + + return { currentPower: latestPower, powerOutages: outages } + }, [sortedTelemetry, prevRecord]) + + // Build all series and stats in a single pass + const { soil, temp, hum, gas, lux, lastTimestamp } = useMemo(() => { + const soil: number[] = [] + const temp: number[] = [] + const hum: number[] = [] + const gas: number[] = [] + const lux: number[] = [] + + let lastTimestamp: string | null = null + let lastTime = Number.NEGATIVE_INFINITY + + for (const t of sortedTelemetry) { + soil.push(Number(t.soilPercent ?? 0)) + temp.push(Number(t.temperatureC ?? 0)) + hum.push(Number(t.humidityPercent ?? 0)) + gas.push(Number(t.gasPPM ?? 0)) + lux.push(Number(t.lux ?? 0)) + + const ts = t.serverTimestampUtc || t.timestampUtc + if (ts) { + const time = new Date(ts).getTime() + if (time > lastTime) { + lastTime = time + lastTimestamp = ts + } + } + } + + return { soil, temp, hum, gas, lux, lastTimestamp } + }, [sortedTelemetry]) + + // Estimate the device's normal send interval from observed timestamps + const sendIntervalMin = useMemo(() => { + return estimateSendIntervalMinutes( + sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc), + 15, + ) + }, [sortedTelemetry]) + + // Freshness of the latest record (only meaningful for today) + const freshness = useMemo(() => { + if (!isTodaySelected || !lastTimestamp) return null + const lastMs = new Date(lastTimestamp).getTime() + if (Number.isNaN(lastMs)) return null + const ageMin = Math.max(0, (Date.now() - lastMs) / 60000) + const freshThreshold = Math.max(10, sendIntervalMin * 2) + const staleThreshold = Math.max(30, sendIntervalMin * 10) + let status: 'fresh' | 'stale' | 'offline' = 'fresh' + if (ageMin > freshThreshold && ageMin <= staleThreshold) status = 'stale' + else if (ageMin > staleThreshold) status = 'offline' + return { ageMin, status } + }, [isTodaySelected, lastTimestamp, sendIntervalMin]) + + const isStale = freshness?.status === 'stale' || freshness?.status === 'offline' + + // Detect data gaps using a threshold derived from the actual send interval + const dataGaps = useMemo(() => { + const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc) + const gapThreshold = estimateGapThreshold(timestamps, 30) + return detectDataGaps(timestamps, gapThreshold) + }, [sortedTelemetry]) + + // Keep the polling cursor in sync with the latest record + useEffect(() => { + lastTimestampRef.current = lastTimestamp ?? null + }, [lastTimestamp]) + + // Fetch only new records since the last seen timestamp + const fetchNewTelemetry = useCallback(async (): Promise => { + const since = lastTimestampRef.current + if (!since) return [] + const result = await api.listTelemetry({ deviceId, sinceUtc: since, pageSize: 1000 }) + return result.items + }, [deviceId]) + + // Live polling: pause when the tab is hidden, sync immediately on return + usePolling({ + enabled: !loading && !!selectedDate && isTodaySelected, + intervalMs: 30000, + fetchNewData: fetchNewTelemetry, + onNewData: (newItems) => { + setTelemetry((prev) => { + const existing = new Set(prev.map((t) => t.id)) + const unique = newItems.filter((t) => !existing.has(t.id)) + return [...prev, ...unique] + }) + }, + }) + + if (loading) { + return + } + + if (!selectedDate) { + return ( + +
+
+ +
تاریخ انتخاب نشده است
+ +
+
+
+ ) + } + + return ( + +
{ touchStartXRef.current = e.touches[0]?.clientX ?? null }} + onTouchEnd={(e) => { + const startX = touchStartXRef.current + touchStartXRef.current = null + if (startX === null) return + const dx = (e.changedTouches[0]?.clientX ?? startX) - startX + if (Math.abs(dx) < 60) return + if (dx > 0) goToPreviousDay() + else goToNextDay() + }} + > + {/* Date Navigation (sticky) */} +
+ +
+ + {/* Data freshness / historical-mode notice */} + {isTodaySelected ? ( + freshness && ( +
+ + + {freshness.status === 'fresh' + ? `داده‌ها به‌روز است${lastTimestamp ? ` — آخرین داده ساعت ${formatPersianTime(lastTimestamp)}` : ''}` + : freshness.status === 'stale' + ? `داده کمی قدیمی است — ${toPersianDigits(String(Math.floor(freshness.ageMin)))} دقیقه پیش` + : `داده به‌روز نیست — آخرین داده ${toPersianDigits(String(Math.floor(freshness.ageMin / 60)))} ساعت پیش`} + +
+ ) + ) : ( +
+ + نمایش داده‌های تاریخی این روز +
+ )} + + {/* Tabs */} + + {{ + summary: , + charts: ( + }> + + + ), + weather: selectedDate ? ( + }> + + + ) : null, + analysis: selectedDate ? ( + }> + + + ) : null, + }} + +
+
+ ) +} + +export default function DailyReportPage() { + return ( + +
+
+

در حال بارگذاری گزارش...

+
+ + }> + +
+ ) +} diff --git a/src/GreenHome.Web/src/app/day-details/page.tsx b/src/GreenHome.Web/src/app/day-details/page.tsx new file mode 100644 index 0000000..67e79cd --- /dev/null +++ b/src/GreenHome.Web/src/app/day-details/page.tsx @@ -0,0 +1,156 @@ +"use client" +import { useEffect, useState, useMemo, Suspense } from 'react' +import { useRouter, useSearchParams } from '@/lib/router' +import { api } from '@/lib/api' +import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date' +import { Calendar as CalendarIcon, Database } from 'lucide-react' +import Loading from '@/components/Loading' +import { Card } from '@/components/common' +import { WeekdayHeaders } from '@/components/calendar' +import { CalendarDayCell, StatsCard } from '@/components/cards' +import { AppShell } from '@/components/layout' + +const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] + +function DayDetailsContent() { + const router = useRouter() + const searchParams = useSearchParams() + const [items, setItems] = useState<{ persianDate: string; count: number }[]>([]) + const [loading, setLoading] = useState(true) + const deviceId = Number(searchParams.get('deviceId') ?? '1') + const year = Number(searchParams.get('year') ?? getCurrentPersianYear()) + const month = Number(searchParams.get('month') ?? getCurrentPersianMonth()) + + useEffect(() => { + setLoading(true) + api.monthDays(deviceId, year, month) + .then(setItems) + .catch(console.error) + .finally(() => setLoading(false)) + }, [deviceId, year, month]) + + // Create a map of day -> count for quick lookup + const dataByDay = useMemo(() => { + const map = new Map() + items.forEach(item => { + const day = parseInt(item.persianDate.split('/')[2]) + map.set(day, item.count) + }) + return map + }, [items]) + + // Calculate total days in the month + const totalDays = useMemo(() => { + return getPersianMonthDays(year, month) + }, [year, month]) + + // Get the starting weekday of the month (0 = Saturday) + const startWeekday = useMemo(() => { + const sw = getPersianMonthStartWeekday(year, month) + return sw; + }, [year, month]) + + // Generate calendar grid with proper spacing + const calendarGrid = useMemo(() => { + const grid = [] + + // Add empty cells for days before the month starts + for (let i = 0; i < startWeekday; i++) { + grid.push({ type: 'empty', day: null }) + } + + // Add all days of the month + for (let day = 1; day <= totalDays; day++) { + grid.push({ type: 'day', day }) + } + + return grid + }, [startWeekday, totalDays]) + + if (loading) { + return + } + + return ( + +
+ {/* Calendar Grid */} + + {/* Weekday Headers */} + + + {/* Days Grid */} +
+ {calendarGrid.map((cell, index) => { + if (cell.type === 'empty') { + return ( +
+ ) + } + + const day = cell.day! + const hasData = dataByDay.has(day) + const recordCount = dataByDay.get(day) || 0 + + if (hasData) { + const dayStr = String(day).padStart(2, '0') + const monthStr = String(month).padStart(2, '0') + const dateStr = `${year}/${monthStr}/${dayStr}` + + return ( + router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)} + /> + ) + } else { + return ( + + ) + } + })} +
+ + + {/* Summary */} +
+ +
+
+ + ) +} + +export default function DayDetailsPage() { + return ( + +
+
+

در حال بارگذاری...

+
+
+ }> + + + ) +} + diff --git a/src/GreenHome.Web/src/app/device-settings/page.tsx b/src/GreenHome.Web/src/app/device-settings/page.tsx new file mode 100644 index 0000000..a52eb28 --- /dev/null +++ b/src/GreenHome.Web/src/app/device-settings/page.tsx @@ -0,0 +1,221 @@ +"use client" +import { useEffect, useState, useCallback, Suspense } from 'react' +import { useSearchParams } from '@/lib/router' +import { api, DeviceSettingsDto, DeviceRelayDto } from '@/lib/api' +import { Plug, Save, AlertCircle } from 'lucide-react' +import Loading from '@/components/Loading' +import { ErrorMessage, SuccessMessage, EmptyState, Button } from '@/components/common' +import { AppShell } from '@/components/layout' +import { RelayCard } from '@/components/device-settings/RelayCard' +import { SettingField } from '@/components/device-settings/SettingField' + +function makeRelay(index: number): DeviceRelayDto { + return { + id: 0, + deviceSettingsId: 0, + relayIndex: index, + name: '', + isEnabled: true, + relayKind: 0, + mode: 0, + minOnMinutes: 0, + minOffMinutes: 0, + rules: [], + } +} + +function normalizeRelays(data: DeviceSettingsDto): DeviceSettingsDto { + const count = Math.max(1, data.relayCount || 1) + const existing = new Map(data.relays?.map(r => [r.relayIndex, r]) ?? []) + const relays: DeviceRelayDto[] = [] + for (let i = 1; i <= count; i++) { + relays.push(existing.get(i) ?? makeRelay(i)) + } + return { ...data, relayCount: count, relays } +} + +function DeviceSettingsContent() { + const searchParams = useSearchParams() + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const deviceId = Number(searchParams.get('deviceId') ?? '1') + const deviceName = searchParams.get('deviceName') ?? 'دستگاه' + + const loadSettings = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await api.getDeviceSettings(deviceId) + setSettings(normalizeRelays(data)) + } catch (err) { + console.error('Error loading settings:', err) + setError('خطا در بارگذاری تنظیمات') + } finally { + setLoading(false) + } + }, [deviceId]) + + useEffect(() => { + loadSettings() + }, [loadSettings]) + + const handleSave = async () => { + if (!settings) return + + try { + setSaving(true) + setError(null) + setSuccess(null) + + if (settings.id === 0) { + await api.createDeviceSettings(settings) + setSuccess('تنظیمات رله‌ها با موفقیت ایجاد شد') + } else { + await api.updateDeviceSettings(settings) + setSuccess('تنظیمات رله‌ها با موفقیت ذخیره شد') + } + await loadSettings() + } catch (err) { + console.error('Error saving settings:', err) + setError('خطا در ذخیره تنظیمات') + } finally { + setSaving(false) + } + } + + const updateRelay = (relay: DeviceRelayDto) => { + setSettings(s => s + ? { ...s, relays: s.relays.map(r => r.relayIndex === relay.relayIndex ? relay : r) } + : s + ) + } + + const syncRelays = (count: number) => { + setSettings(s => { + if (!s) return s + const existing = new Map(s.relays.map(r => [r.relayIndex, r])) + const relays: DeviceRelayDto[] = [] + for (let i = 1; i <= count; i++) { + relays.push(existing.get(i) ?? makeRelay(i)) + } + return { ...s, relayCount: count, relays } + }) + } + + const initializeDefaultSettings = () => { + setSettings({ + id: 0, + deviceId, + deviceName, + province: '', + city: '', + productType: '', + minimumSmsIntervalMinutes: 15, + minimumCallIntervalMinutes: 60, + uploadIntervalMin: 5, + devicePhoneNumber: '', + relayCount: 4, + smsRecievers: '', + relays: [1, 2, 3, 4].map(makeRelay), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + } + + if (loading) { + return + } + + if (!deviceId) { + return ( + + + + ) + } + + return ( + +
+ {error && } + {success && } + + {!settings ? ( + + ایجاد تنظیمات پیش‌فرض + + } + /> + ) : ( +
+ {/* Relay count */} +
+
+ + syncRelays(Math.max(1, Math.min(16, parseInt(e.target.value) || 1)))} + className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm text-gray-700 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all" + /> + +
+
+ + {/* Relays */} + {settings.relays.map(relay => ( + + ))} + + {/* Save */} +
+ +
+
+ )} +
+
+ ) +} + +export default function DeviceSettingsPage() { + return ( + }> + + + ) +} diff --git a/src/GreenHome.Web/src/app/device/page.tsx b/src/GreenHome.Web/src/app/device/page.tsx new file mode 100644 index 0000000..98b0745 --- /dev/null +++ b/src/GreenHome.Web/src/app/device/page.tsx @@ -0,0 +1,71 @@ +"use client" +import { useState, Suspense } from 'react' +import { useSearchParams } from '@/lib/router' +import { MonitorCog, Plug, Bell, Info } from 'lucide-react' +import Loading from '@/components/Loading' +import { AppShell } from '@/components/layout' +import { RelaySettingsTab } from '@/components/device-settings/RelaySettingsTab' +import { DeviceInfoTab } from '@/components/device-settings/DeviceInfoTab' +import { AlertSettingsTab } from '@/components/alert-settings/AlertSettingsTab' +import { cn } from '@/lib/utils' + +type DeviceTab = 'relays' | 'alerts' | 'settings' + +const tabs: { key: DeviceTab; label: string; icon: typeof Plug }[] = [ + { key: 'settings', label: 'تنظیمات', icon: Info }, + { key: 'relays', label: 'رله‌ها', icon: Plug }, + { key: 'alerts', label: 'هشدارها', icon: Bell }, +] + +function DevicePageContent() { + const searchParams = useSearchParams() + const deviceId = Number(searchParams.get('deviceId') ?? '1') + const deviceName = searchParams.get('deviceName') ?? 'دستگاه' + const [tab, setTab] = useState('relays') + + return ( + +
+ {/* Tab bar */} +
+ {tabs.map(t => { + const Icon = t.icon + const isActive = tab === t.key + return ( + + ) + })} +
+ + {tab === 'relays' && } + {tab === 'alerts' && } + {tab === 'settings' && } +
+
+ ) +} + +export default function DevicePage() { + return ( + }> + + + ) +} diff --git a/src/GreenHome.Web/src/app/devices/page.tsx b/src/GreenHome.Web/src/app/devices/page.tsx new file mode 100644 index 0000000..dd43ca0 --- /dev/null +++ b/src/GreenHome.Web/src/app/devices/page.tsx @@ -0,0 +1,230 @@ +"use client" +import { useEffect, useState, useCallback } from 'react' +import { useRouter } from '@/lib/router' +import { api, DeviceDto, PagedResult } from '@/lib/api' +import { Settings, LogOut } from 'lucide-react' +import Loading from '@/components/Loading' +import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date' +import { ErrorMessage, EmptyState, Button } from '@/components/common' +import { SearchInput } from '@/components/forms' +import { DeviceCard } from '@/components/cards' +import { Pagination } from '@/components/navigation' +import { useDevice } from '@/components/layout' + +export default function DevicesPage() { + const router = useRouter() + const { setDevice } = useDevice() + const [pagedResult, setPagedResult] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [user, setUser] = useState<{ id: number; mobile: string; name: string; family: string; role: number } | null>(null) + const [searchTerm, setSearchTerm] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [searchInput, setSearchInput] = useState('') + + const pageSize = 10 + + const handleDeviceSelect = useCallback((device: DeviceDto) => { + setDevice(device.id, device.deviceName) + const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}` + router.push(`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`) + }, [setDevice, router]) + + const fetchDevices = useCallback(async (page: number, search?: string) => { + if (!user) return + + setLoading(true) + setError(null) + + try { + const result = await api.getDevicesFiltered({ + userId: user.id, + search: search || undefined, + page, + pageSize + }) + + if (result.items.length === 0 && page === 1) { + setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.') + } else if (result.items.length === 1 && result.totalCount === 1 && !search) { + // Single device - set context and redirect to today's daily report + handleDeviceSelect(result.items[0]) + return + } else { + setPagedResult(result) + } + } catch (err) { + console.error('Error fetching devices:', err) + setError('خطا در دریافت لیست دستگاه‌ها') + } finally { + setLoading(false) + } + }, [user, router, handleDeviceSelect]) + + useEffect(() => { + // Check authentication + const token = localStorage.getItem('authToken') + const userStr = localStorage.getItem('user') + + if (!token || !userStr) { + router.push('/') + return + } + + try { + const userData = JSON.parse(userStr) + setUser(userData) + } catch { + router.push('/') + } + }, [router]) + + useEffect(() => { + if (user) { + fetchDevices(currentPage, searchTerm || undefined) + } + }, [user, currentPage, searchTerm, fetchDevices]) + + const handleSearch = (searchValue?: string) => { + const valueToUse = searchValue ?? searchInput + setSearchTerm(valueToUse) + setSearchInput(valueToUse) + setCurrentPage(1) + } + + const handleLogout = () => { + localStorage.removeItem('authToken') + localStorage.removeItem('user') + router.push('/') + } + + const totalPages = pagedResult ? Math.ceil(pagedResult.totalCount / pageSize) : 0 + const showPagination = pagedResult && pagedResult.totalCount > pageSize + + if (loading && !pagedResult) { + return + } + + if (error && (!pagedResult || pagedResult.items.length === 0)) { + return ( + + خروج + + } + /> + ) + } + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

+ انتخاب دستگاه +

+ {user && ( +

+ {user.name} {user.family} ({user.mobile}) +

+ )} +
+
+
+ +
+
+
+ + {/* Search */} +
+ + {searchTerm && ( +
+ + نتایج جستجو برای: {searchTerm} + + +
+ )} +
+ + {/* Results Count */} + {pagedResult && ( +
+ نمایش {pagedResult.items.length} از {pagedResult.totalCount} دستگاه +
+ )} + + {/* Devices Grid */} + {pagedResult && pagedResult.items.length > 0 ? ( + <> +
+ {pagedResult.items.map((device) => ( + handleDeviceSelect(device)} + /> + ))} +
+ + {/* Pagination */} + {showPagination && ( + + )} + + ) : ( + !loading && ( + + ) + )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/app/globals.css b/src/GreenHome.Web/src/app/globals.css new file mode 100644 index 0000000..b574709 --- /dev/null +++ b/src/GreenHome.Web/src/app/globals.css @@ -0,0 +1,193 @@ +@import "tailwindcss"; + +:root { + --foreground: #171717; +} + +@theme inline { + --color-foreground: var(--foreground); + --font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; + --font-mono: 'Vazirmatn', monospace; +} + +/* Design tokens — single source of truth for brand colors. + Generated utilities: bg-primary, text-success, border-warning, ... */ +@theme { + --color-primary: #4f46e5; /* indigo-600 */ + --color-primary-hover: #4338ca; /* indigo-700 */ + --color-primary-soft: #eef2ff; /* indigo-50 */ + + --color-success: #16a34a; /* green-600 */ + --color-success-hover: #15803d; /* green-700 */ + --color-success-soft: #f0fdf4; /* green-50 */ + + --color-danger: #dc2626; /* red-600 */ + --color-danger-hover: #b91c1c; /* red-700 */ + --color-danger-soft: #fef2f2; /* red-50 */ + + --color-warning: #f97316; /* orange-500 */ + --color-warning-hover: #ea580c; /* orange-600 */ + --color-warning-soft: #fff7ed; /* orange-50 */ + + --color-info: #2563eb; /* blue-600 */ + --color-info-hover: #1d4ed8; /* blue-700 */ + --color-info-soft: #eff6ff; /* blue-50 */ +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground: #ededed; + } +} + +* { + font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html { + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; +} + +body { + background: #f8fafc; + color: var(--foreground); + font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; + font-weight: 400; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; +} + +/* Better touch targets (minimum 44x44px for mobile) */ +button, a, [role="button"] { + min-height: 44px; + min-width: 44px; + touch-action: manipulation; + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* NOTE: removed blanket will-change/translateZ(0) to reduce GPU memory usage on mobile. + Apply will-change only on elements that actually animate. */ + +.persian-number { + font-feature-settings: 'ss01'; + font-variant-numeric: persian; + font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; +} + +/* Markdown content styles */ +.markdown-content { + line-height: 1.8; + color: #374151; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4 { + color: #1f2937; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.markdown-content h1 { + font-size: 1.5rem; +} + +.markdown-content h2 { + font-size: 1.25rem; +} + +.markdown-content h3 { + font-size: 1.125rem; +} + +.markdown-content h4 { + font-size: 1rem; +} + +.markdown-content p { + margin-bottom: 1em; +} + +.markdown-content ul, +.markdown-content ol { + margin: 0.75em 0; + padding-right: 1.5em; +} + +.markdown-content li { + margin: 0.25em 0; +} + +.markdown-content strong { + font-weight: 700; + color: #1f2937; +} + +.markdown-content code { + background-color: #f3f4f6; + padding: 0.125em 0.375em; + border-radius: 0.25em; + font-size: 0.875em; +} + +.markdown-content blockquote { + border-right: 4px solid #6366f1; + padding-right: 1em; + margin: 1em 0; + color: #6b7280; + font-style: italic; +} + +.markdown-content hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 1.5em 0; +} + +/* Loading skeleton animation */ +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +.skeleton { + background: linear-gradient( + 90deg, + #f0f0f0 0%, + #e0e0e0 50%, + #f0f0f0 100% + ); + background-size: 2000px 100%; + animation: shimmer 2s infinite; +} + +/* Bottom Navigation Safe Area */ +@supports (padding-bottom: env(safe-area-inset-bottom)) { + .pb-safe { + padding-bottom: env(safe-area-inset-bottom); + } + .h-safe-bottom { + height: env(safe-area-inset-bottom); + } +} + +/* Prevent content from being hidden behind bottom nav */ +.has-bottom-nav { + padding-bottom: 5rem; +} +@media (max-width: 768px) { + .has-bottom-nav { + padding-bottom: 4.5rem; + } +} diff --git a/src/GreenHome.Web/src/app/login/page.tsx b/src/GreenHome.Web/src/app/login/page.tsx new file mode 100644 index 0000000..561d69b --- /dev/null +++ b/src/GreenHome.Web/src/app/login/page.tsx @@ -0,0 +1,118 @@ +"use client" +import { useState } from 'react' +import { api } from '@/lib/api' +import { Smartphone, ArrowLeft } from 'lucide-react' +import { useRouter } from '@/lib/router' +import Loading from '@/components/Loading' +import { MobileInput } from '@/components/forms' +import { ErrorMessage, Button } from '@/components/common' + +export default function LoginPage() { + const [mobile, setMobile] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const router = useRouter() + + const normalizeMobile = (mobile: string): string => { + const digitsOnly = mobile.replace(/\D/g, '') + if (digitsOnly.startsWith('9') && digitsOnly.length === 10) { + return '0' + digitsOnly + } + if (digitsOnly.startsWith('0098') && digitsOnly.length === 13) { + return '0' + digitsOnly.substring(3) + } + if (digitsOnly.startsWith('98') && digitsOnly.length === 12) { + return '0' + digitsOnly.substring(2) + } + return digitsOnly + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const normalizedMobile = normalizeMobile(mobile) + + if (!normalizedMobile || normalizedMobile.length !== 11 || !normalizedMobile.startsWith('09')) { + setError('لطفاً شماره موبایل معتبر وارد کنید (مثال: 09123456789)') + return + } + + setLoading(true) + + try { + const result = await api.sendCode({ mobile: normalizedMobile }) + + if (result.success) { + // Redirect to verify page with mobile number + router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`) + return // Don't set loading to false — component will unmount + } else { + setError(result.message || 'خطا در ارسال کد') + } + } catch (error: unknown) { + console.error('Error sending code:', error) + setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور') + } finally { + setLoading(false) + } + } + + const canSubmit = mobile.length >= 10 + + if (loading) { + return + } + + return ( +
+
+
+
+
+ +
+

+ ورود با شماره موبایل +

+

+ شماره موبایل خود را وارد کنید تا کد فعال‌سازی برای شما ارسال شود +

+
+ + +
+ + { + setMobile(value) + setError(null) + }} + disabled={loading} + /> +
+ + {error && } + + + +
+ +
+
+ ) +} + diff --git a/src/GreenHome.Web/src/app/page.tsx b/src/GreenHome.Web/src/app/page.tsx new file mode 100644 index 0000000..ac045fe --- /dev/null +++ b/src/GreenHome.Web/src/app/page.tsx @@ -0,0 +1,27 @@ +"use client" + +import { useEffect } from 'react' +import { useRouter } from '@/lib/router' +import Loading from '@/components/Loading' + +export default function Home() { + const router = useRouter() + + useEffect(() => { + const token = localStorage.getItem('authToken') + const userStr = localStorage.getItem('user') + const savedDeviceId = localStorage.getItem('selectedDeviceId') + + if (token && userStr) { + if (savedDeviceId) { + router.replace(`/daily-report?deviceId=${savedDeviceId}`) + } else { + router.replace('/devices') + } + } else { + router.replace('/login') + } + }, [router]) + + return +} diff --git a/src/GreenHome.Web/src/app/profile/page.tsx b/src/GreenHome.Web/src/app/profile/page.tsx new file mode 100644 index 0000000..2f8adb2 --- /dev/null +++ b/src/GreenHome.Web/src/app/profile/page.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useEffect, useState } from 'react' +import { useRouter } from '@/lib/router' +import { User, LogOut, Settings, ChevronLeft, Smartphone, Shield, Monitor } from 'lucide-react' +import { AppShell, useDevice } from '@/components/layout' +import { Card } from '@/components/common' + +type UserData = { + id: number + mobile: string + name: string + family: string + role: number +} + +export default function ProfilePage() { + const router = useRouter() + const { deviceId } = useDevice() + const [user, setUser] = useState(null) + + useEffect(() => { + const userStr = localStorage.getItem('user') + if (userStr) { + try { + setUser(JSON.parse(userStr)) + } catch { + // Invalid user data + } + } + }, []) + + const handleLogout = () => { + localStorage.removeItem('authToken') + localStorage.removeItem('user') + router.push('/') + } + + const handleDeviceSettings = () => { + router.push(`/device?deviceId=${deviceId}`) + } + + const handleSelectDevice = () => { + router.push('/devices') + } + + return ( + +
+ {/* User Info Card */} + +
+
+ +
+
+

+ {user ? `${user.name} ${user.family}` : 'کاربر'} +

+

+ + {user?.mobile || '---'} +

+
+
+ +
+
+ نقش کاربری + + + {user?.role === 1 ? 'مدیر' : 'کاربر'} + +
+
+
+ + {/* Actions */} + +
+ {/* Select Device — always visible so user can change device */} + + + + + +
+
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/app/verify-code/page.tsx b/src/GreenHome.Web/src/app/verify-code/page.tsx new file mode 100644 index 0000000..c1367fd --- /dev/null +++ b/src/GreenHome.Web/src/app/verify-code/page.tsx @@ -0,0 +1,254 @@ +"use client" +import { useState, useEffect, useCallback, Suspense } from 'react' +import { useSearchParams, useRouter } from '@/lib/router' +import { api } from '@/lib/api' +import { useDevice } from '@/components/layout' +import { Shield, ArrowRight, ArrowLeft } from 'lucide-react' +import { Link } from '@/lib/router' +import Loading from '@/components/Loading' +import { CodeInput } from '@/components/forms' +import { ErrorMessage, Button } from '@/components/common' +import { ResendButton } from '@/components/utils' + +function VerifyCodeContent() { + const searchParams = useSearchParams() + const router = useRouter() + const { setDevice } = useDevice() + const mobile = searchParams.get('mobile') || '' + + const [code, setCode] = useState(['', '', '', '']) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [resendCooldown, setResendCooldown] = useState(120) + const [canResend, setCanResend] = useState(false) + + const checkResendStatus = useCallback(async () => { + try { + const result = await api.canResend(mobile) + if (result.canResend) { + setCanResend(true) + setResendCooldown(0) + } else { + setCanResend(false) + setResendCooldown(120) + } + } catch (error) { + console.error('Error checking resend status:', error) + } + }, [mobile]) + + useEffect(() => { + if (!mobile || mobile.length !== 11) { + router.push('/login') + return + } + + // Check resend cooldown + checkResendStatus() + const interval = setInterval(() => { + setResendCooldown(prev => { + if (prev <= 1) { + setCanResend(true) + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [mobile, router, checkResendStatus]) + + + const handleVerify = async (codeToVerify?: string) => { + const codeValue = codeToVerify || code.join('') + + if (codeValue.length !== 4) { + setError('لطفاً کد ۴ رقمی را کامل وارد کنید') + return + } + + setLoading(true) + setError(null) + + try { + const result = await api.verifyCode({ mobile, code: codeValue }) + + if (result.success && result.token && result.user) { + // Store token in localStorage + localStorage.setItem('authToken', result.token) + localStorage.setItem('user', JSON.stringify(result.user)) + + // Check user devices + try { + const devicesResult = await api.getDevicesFiltered({ + userId: result.user.id, + page: 1, + pageSize: 10 + }) + + if (devicesResult.totalCount === 0) { + // No devices - show error and logout + setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.') + localStorage.removeItem('authToken') + localStorage.removeItem('user') + return + } else if (devicesResult.totalCount === 1) { + // Single device - remember it and go straight to today's report + const device = devicesResult.items[0] + setDevice(device.id, device.deviceName) + localStorage.setItem('selectedDeviceId', String(device.id)) + localStorage.setItem('selectedDeviceName', device.deviceName) + router.push(`/daily-report?deviceId=${device.id}`) + return // Don't set loading to false — component will unmount + } else { + // Multiple devices - redirect to device list + router.push('/devices') + return // Don't set loading to false — component will unmount + } + } catch (deviceError) { + console.error('Error fetching devices:', deviceError) + // Still redirect to devices page to handle error there + router.push('/devices') + return // Don't set loading to false — component will unmount + } + } else { + setError(result.message || 'کد وارد شده نادرست است') + setCode(['', '', '', '']) + } + } catch (error: unknown) { + console.error('Error verifying code:', error) + setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور') + setCode(['', '', '', '']) + } finally { + setLoading(false) + } + } + + const handleResend = async () => { + if (!canResend) return + + setLoading(true) + setError(null) + setCanResend(false) + setResendCooldown(120) + + try { + const result = await api.sendCode({ mobile }) + + if (result.success) { + setResendCooldown(result.resendAfterSeconds || 120) + setCode(['', '', '', '']) + } else { + setError(result.message || 'خطا در ارسال مجدد کد') + setCanResend(true) + } + } catch (error: unknown) { + console.error('Error resending code:', error) + setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور') + setCanResend(true) + } finally { + setLoading(false) + } + } + + const formatMobile = (mobile: string) => { + if (mobile.length === 11) { + //return `${mobile.slice(0, 4)}${mobile.slice(4, 7)} ${mobile.slice(7)}` + return mobile; + } + return mobile + } + + if (!mobile || mobile.length !== 11) { + return + } + + if (loading && code.join('').length !== 4) { + return + } + + return ( +
+
+
+
+
+ +
+

+ تایید کد فعال‌سازی +

+

+ کد ارسال شده به شماره +

+

+ {formatMobile(mobile)} +

+

+ را وارد کنید +

+
+ +
{ e.preventDefault(); handleVerify(); }} className="space-y-5"> + + + {error && } + + + +
+ + + + + تغییر شماره موبایل + +
+ +
+ +
+ + + بازگشت به صفحه اصلی + +
+
+
+ ) +} + +export default function VerifyCodePage() { + return ( + }> + + + ) +} + diff --git a/src/GreenHome.Web/src/app/weather/page.tsx b/src/GreenHome.Web/src/app/weather/page.tsx new file mode 100644 index 0000000..e5d6f66 --- /dev/null +++ b/src/GreenHome.Web/src/app/weather/page.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useState, useEffect, useCallback } from 'react' +import { CloudSun, RefreshCw, MapPin } from 'lucide-react' +import { AppShell, useDevice } from '@/components/layout' +import { WeatherData, fetchForecastWeather, fetchLocationName } from '@/features/weather' +import { QOM_LAT, QOM_LON } from '@/features/weather/helpers' +import { TodayWeather } from '@/components/daily-report/weather/TodayWeather' +import Loading from '@/components/Loading' +import { ErrorMessage } from '@/components/common' +import { Button } from '@/components/common/Button' + +export default function WeatherPage() { + const { deviceName } = useDevice() + const [weatherData, setWeatherData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedDayIndex, setExpandedDayIndex] = useState(null) + const [locationName, setLocationName] = useState('در حال دریافت...') + + const loadWeather = useCallback(async (force = false) => { + setLoading(true) + setError(null) + + try { + const [weather, location] = await Promise.all([ + fetchForecastWeather(force), + fetchLocationName(QOM_LAT, QOM_LON, force) + ]) + + setWeatherData(weather) + setLocationName(location) + } catch (error) { + console.error('Error loading weather:', error) + setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.') + setLocationName('کهک قم، ایران') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadWeather() + }, [loadWeather]) + + const handleRefresh = () => { + setWeatherData(null) + loadWeather(true) + } + + return ( + +
+ {/* Location Header */} +
+
+ + {locationName} +
+ +
+ + {loading && ( + + )} + + {error && ( + + تلاش مجدد + + } + /> + )} + + {weatherData && !loading && ( + + )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/Charts.tsx b/src/GreenHome.Web/src/components/Charts.tsx new file mode 100644 index 0000000..7352f6f --- /dev/null +++ b/src/GreenHome.Web/src/components/Charts.tsx @@ -0,0 +1,334 @@ +"use client" +import React, { useCallback, useMemo } from 'react' +import { Line } from 'react-chartjs-2' +import { + Chart, + LineElement, + CategoryScale, + LinearScale, + PointElement, + Legend, + Tooltip, + Filler +} from 'chart.js' +import { toPersianDigits } from '@/lib/format/persian-digits' + +Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler) + +// Constants +const FONT_FAMILY = "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" +const FONT_SIZE = 10 + +const GAP_ANNOTATION_COLORS = { + backgroundColor: 'rgba(239, 68, 68, 0.1)', + borderColor: 'rgba(239, 68, 68, 0.3)', +} as const + +const RESPONSIVE_CONFIG = { + mobile: { + pointRadius: 0.5, + pointHoverRadius: 3, + animationDuration: 300, + }, + desktop: { + pointRadius: 1, + pointHoverRadius: 4, + animationDuration: 1000, + }, +} as const + +const CHART_LAYOUT_PADDING = { + left: 0, + right: 0, + top: 10, + bottom: 0, +} as const + +const AXIS_BORDER_COLOR = 'rgba(0, 0, 0, 0.1)' + +type Series = { label: string; data: (number | null)[]; borderColor: string; backgroundColor?: string; fill?: boolean } + +type DataGapAnnotation = { + startIndex: number + endIndex: number +} + +type Props = { + labels: string[] + series: Series[] + title?: string + yAxisMin?: number + yAxisMax?: number + dataGaps?: DataGapAnnotation[] // Indices where gaps occur +} + +export type ChartStat = { label: string; value: string } + +type PanelProps = { + title: string + children: React.ReactNode + id?: string + stats?: ChartStat[] +} + +export function Panel({ title, children, id, stats }: PanelProps) { + return ( +
+
+

{title}

+ {stats && stats.length > 0 && ( +
+ {stats.map((stat, index) => ( + + {stat.label} {stat.value} + + ))} +
+ )} +
+
+ {children} +
+
+ ) +} + +export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) { + // Check if mobile device + const isMobile = useMemo(() => { + return typeof window !== 'undefined' && window.innerWidth < 768 + }, []) + + // Get responsive config + const responsiveConfig = useMemo(() => { + return isMobile ? RESPONSIVE_CONFIG.mobile : RESPONSIVE_CONFIG.desktop + }, [isMobile]) + + // Calculate hour range and determine which hours to show (not currently used but kept for potential future use) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hourConfig = React.useMemo(() => { + const validLabels = labels.filter(l => l) + if (validLabels.length === 0) { + return { startHour: 0, endHour: 24, hours: Array.from({length: 25}, (_, i) => i) } + } + + const firstTime = validLabels[0] + const lastTime = validLabels[validLabels.length - 1] + + if (!firstTime || !lastTime) { + return { startHour: 0, endHour: 24, hours: Array.from({length: 25}, (_, i) => i) } + } + + const [firstHour] = firstTime.split(':').map(Number) + const [lastHour] = lastTime.split(':').map(Number) + + // Create array of hours to show (from start to end, one per hour) + const startHour = firstHour + let endHour = lastHour + + // If last hour is the same as first, extend to next hour + if (endHour <= startHour) { + endHour = startHour + 24 + } + + // Create array of hours + const hours: number[] = [] + for (let h = startHour; h <= endHour; h++) { + hours.push(h % 24) + } + + return { startHour, endHour, hours } + }, [labels]) + + // Create map of label index to hour + const labelHours = useMemo(() => { + return labels.map(label => { + if (!label) return null + const [hour] = label.split(':').map(Number) + return hour + }) + }, [labels]) + // X-axis tick callback - optimized with useCallback + const xAxisTickCallback = useCallback((value: unknown, index: number) => { + // Skip if index is out of bounds + if (index < 0 || index >= labelHours.length) return '' + + const hour = labelHours[index] + if (hour === null) return '' + + // Find if this is the first occurrence of this hour + const firstIndexOfHour = labelHours.findIndex(h => h === hour) + + // Only show label if this is the first occurrence of this hour + if (firstIndexOfHour !== index) return '' + + // Show only the hour number + return toPersianDigits(hour.toString()) + }, [labelHours]) + + // Tooltip callbacks + const tooltipCallbacks = useMemo(() => ({ + label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { + const label = context.dataset.label || '' + return `${label}: ${toPersianDigits((context.parsed.y ?? 0).toFixed(2))}` + }, + title: (context: Array<{ label?: string }>) => { + const label = context[0]?.label || '' + return `ساعت: ${label}` + } + }), []) + + // Font configuration + const fontConfig = useMemo(() => ({ + family: FONT_FAMILY, + size: FONT_SIZE, + }), []) + + // Dataset configuration with segment for gaps + const datasets = useMemo(() => { + return series.map((s, seriesIndex) => { + return { + label: s.label, + data: s.data, + borderColor: s.borderColor, + backgroundColor: s.backgroundColor ?? s.borderColor, + fill: s.fill ?? false, + tension: 0.3, + pointRadius: responsiveConfig.pointRadius, + pointHoverRadius: responsiveConfig.pointHoverRadius, + borderWidth: 1, + spanGaps: true, // Must be true for segment to work on gap connections + // Use segment to style gaps + segment: { + borderDash: (ctx: {p0DataIndex: number, p1DataIndex: number}) => { + const index0 = ctx.p0DataIndex + const index1 = ctx.p1DataIndex + const data = s.data + + // If either point is null, it's a gap + if (data[index0] === null || data[index1] === null) { + return [10, 10] + } + + // Check if there are any null values between these two points + const start = Math.min(index0, index1) + const end = Math.max(index0, index1) + for (let i = start + 1; i < end; i++) { + if (data[i] === null) { + return [10, 10] // There's a gap between these points + } + } + + return [] + }, + borderColor: (ctx: {p0DataIndex: number, p1DataIndex: number}) => { + const index0 = ctx.p0DataIndex + const index1 = ctx.p1DataIndex + const data = s.data + + // If either point is null, use gap color + if (data[index0] === null || data[index1] === null) { + return GAP_ANNOTATION_COLORS.borderColor + } + + // Check if there are any null values between these two points + const start = Math.min(index0, index1) + const end = Math.max(index0, index1) + for (let i = start + 1; i < end; i++) { + if (data[i] === null) { + return GAP_ANNOTATION_COLORS.borderColor // There's a gap between these points + } + } + + return s.borderColor + } + } + } + }) + }, [series, responsiveConfig]) + + // Chart options + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + animation: { + duration: responsiveConfig.animationDuration, + }, + interaction: { + intersect: false, + mode: 'index' as const, + }, + layout: { + padding: CHART_LAYOUT_PADDING, + }, + font: { + family: FONT_FAMILY, + }, + plugins: { + legend: { + display: false + }, + tooltip: { + displayColors: false, + titleFont: fontConfig, + bodyFont: fontConfig, + callbacks: tooltipCallbacks, + } + }, + scales: { + x: { + display: true, + ticks: { + font: fontConfig, + maxRotation: 0, + minRotation: 0, + autoSkip: false, + callback: xAxisTickCallback, + }, + grid: { + display: false + }, + border: { + display: true, + color: AXIS_BORDER_COLOR + } + }, + y: { + display: true, + min: yAxisMin !== undefined ? yAxisMin : undefined, + max: yAxisMax !== undefined ? yAxisMax : undefined, + ticks: { + font: fontConfig, + padding: 8, + callback: (value: unknown) => { + if (typeof value === 'number' || typeof value === 'string') { + return toPersianDigits(value.toString()) + } + return '' + } + }, + grid: { + display: true + }, + border: { + display: true, + color: AXIS_BORDER_COLOR + } + } + } + }), [responsiveConfig, fontConfig, tooltipCallbacks, xAxisTickCallback, yAxisMin, yAxisMax]) + + return ( +
+ +
+ ) +} + + diff --git a/src/GreenHome.Web/src/components/DashboardCards.tsx b/src/GreenHome.Web/src/components/DashboardCards.tsx new file mode 100644 index 0000000..91ffe4e --- /dev/null +++ b/src/GreenHome.Web/src/components/DashboardCards.tsx @@ -0,0 +1,114 @@ +"use client" +import React from 'react' +import { Sun, Wind, Droplets, Thermometer, Database, TrendingUp, TrendingDown } from 'lucide-react' + +type CardProps = { + title: string + value: string | number + subtitle?: string + icon?: string + color?: 'light' | 'gas' | 'soil' | 'humidity' | 'temperature' | 'data' +} + +const iconMap: Record> = { + '💡': Sun, + '🫁': Wind, + '🌱': Droplets, + '💧': Droplets, + '🌡️': Thermometer, + '📊': Database, +} + +const colorConfig: Record = { + light: { + gradient: 'from-yellow-500 to-orange-500', + border: 'border-yellow-200', + iconBg: 'from-yellow-500 to-orange-500' + }, + gas: { + gradient: 'from-gray-600 to-gray-700', + border: 'border-gray-300', + iconBg: 'from-gray-600 to-gray-700' + }, + soil: { + gradient: 'from-green-500 to-emerald-600', + border: 'border-green-200', + iconBg: 'from-green-500 to-emerald-600' + }, + humidity: { + gradient: 'from-blue-500 to-cyan-500', + border: 'border-blue-200', + iconBg: 'from-blue-500 to-cyan-500' + }, + temperature: { + gradient: 'from-red-500 to-orange-500', + border: 'border-red-200', + iconBg: 'from-red-500 to-orange-500' + }, + data: { + gradient: 'from-purple-500 to-indigo-600', + border: 'border-purple-200', + iconBg: 'from-purple-500 to-indigo-600' + } +} + +// Auto-detect color based on title +function detectColor(title: string, icon?: string): 'light' | 'gas' | 'soil' | 'humidity' | 'temperature' | 'data' { + const lowerTitle = title.toLowerCase() + if (lowerTitle.includes('نور') || icon === '💡') return 'light' + if (lowerTitle.includes('گاز') || icon === '🫁') return 'gas' + if (lowerTitle.includes('خاک') || icon === '🌱') return 'soil' + if (lowerTitle.includes('رطوبت') || icon === '💧') return 'humidity' + if (lowerTitle.includes('دما') || icon === '🌡️') return 'temperature' + if (lowerTitle.includes('داده') || icon === '📊') return 'data' + return 'data' // default +} + +export function DashboardGrid({ children }: { children: React.ReactNode }) { + return
{children}
+} + +export function Card({ title, value, subtitle, icon, color }: CardProps) { + const IconComponent = icon && iconMap[icon] ? iconMap[icon] : null + const cardColor = color || detectColor(title, icon) + const colors = colorConfig[cardColor] + + // Extract max and min from subtitle if available + const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل') + + return ( +
+
+
+
+ {title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()} +
+ {IconComponent && ( +
+ +
+ )} +
+
{value}
+ {subtitle && ( +
+ {hasStats ? ( + <> + + + {subtitle.match(/حداکثر:\s*([0-9.]+)/)?.[1]} + + + + {subtitle.match(/حداقل:\s*([0-9.]+)/)?.[1]} + + + ) : ( + subtitle + )} +
+ )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/Gauges.tsx b/src/GreenHome.Web/src/components/Gauges.tsx new file mode 100644 index 0000000..f6f138f --- /dev/null +++ b/src/GreenHome.Web/src/components/Gauges.tsx @@ -0,0 +1,334 @@ +"use client" + +import { useId } from 'react' + +// تابع تبدیل ارقام انگلیسی به فارسی +function toPersianDigits(num: number | string): string { + const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) +} + +// Temperature Gauge - Semi-circular gauge with blue to red gradient +type TemperatureGaugeProps = { + value: number + min?: number + max?: number +} + +export function TemperatureGauge({ value, min = -20, max = 80 }: TemperatureGaugeProps) { + const range = max - min + const percentage = Math.max(0, Math.min(100, ((value - min) / range) * 100)) + const valueAngle = (percentage / 100) * 180 // 0 to 180 degrees + + // Create gradient stops for temperature (blue to red) + const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const gradientId = `temp-gradient-${uid}` + const glowId = `temp-glow-${uid}` + + // Center point and radius for the arc + const cx = 75 + const cy = 75 + const radius = 50 + const gaugeWidth = 18 + + // Calculate pointer position (triangle pointing inward from outside) + const pointerAngle = (180 - valueAngle) * (Math.PI / 180) + const pointerOuterRadius = radius - gaugeWidth / 2 - 2 + const pointerX = cx + pointerOuterRadius * Math.cos(pointerAngle) + const pointerY = cy - pointerOuterRadius * Math.sin(pointerAngle) + + // Triangle points for the pointer + const triangleSize = 6 + const perpAngle = pointerAngle + Math.PI / 2 + const p1x = pointerX + triangleSize * Math.cos(perpAngle) + const p1y = pointerY - triangleSize * Math.sin(perpAngle) + const p2x = pointerX - triangleSize * Math.cos(perpAngle) + const p2y = pointerY + triangleSize * Math.sin(perpAngle) + const p3x = cx + (pointerOuterRadius - 12) * Math.cos(pointerAngle) + const p3y = cy - (pointerOuterRadius - 12) * Math.sin(pointerAngle) + + // Generate tick marks and labels every 10 degrees + const ticks = [] + for (let temp = min; temp <= max; temp += 10) { + const tickPercentage = ((temp - min) / range) * 100 + const tickAngle = (180 - (tickPercentage / 100) * 180) * (Math.PI / 180) + // خطوط بیرون گیج با فاصله کم - کوتاه‌تر (نصف) + const innerX = cx + (radius + gaugeWidth / 2 + 3) * Math.cos(tickAngle) + const innerY = cy - (radius + gaugeWidth / 2 + 3) * Math.sin(tickAngle) + const outerX = cx + (radius + gaugeWidth / 2 + 6) * Math.cos(tickAngle) + const outerY = cy - (radius + gaugeWidth / 2 + 6) * Math.sin(tickAngle) + const labelX = cx + (radius + gaugeWidth / 2 + 14) * Math.cos(tickAngle) + const labelY = cy - (radius + gaugeWidth / 2 + 14) * Math.sin(tickAngle) + + ticks.push({ temp, outerX, outerY, innerX, innerY, labelX, labelY }) + } + + return ( +
+ + + + + + + + + + + + + + + + + + + + + {/* Colored arc (full gradient) */} + + + {/* Small rounded caps at the ends */} + + + + {/* Tick marks and labels */} + {ticks.map(({ temp, outerX, outerY, innerX, innerY, labelX, labelY }) => ( + + + + {toPersianDigits(temp)} + + + ))} + + {/* Triangle pointer pointing inward */} + + + + {/* Center value */} +
+ {toPersianDigits(value.toFixed(1))} + °C +
+
+ ) +} + +// Humidity Gauge - Water droplet style fill +type HumidityGaugeProps = { + value: number + type?: 'air' | 'soil' +} + +export function HumidityGauge({ value, type = 'air' }: HumidityGaugeProps) { + const percentage = Math.max(0, Math.min(100, value)) + const fillColor = type === 'air' ? '#3b82f6' : '#16a34a' + const bgColor = type === 'air' ? '#dbeafe' : '#dcfce7' + const textColor = type === 'air' ? '#1e40af' : '#166534' + const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const gradientId = `humidity-gradient-${uid}` + const clipId = `humidity-clip-${uid}` + + return ( +
+ + + + + + + + + + + + {/* Background droplet */} + + + {/* Filled portion */} + + + {/* Percentage text inside droplet - larger and with stroke for visibility */} + + {toPersianDigits(Math.round(percentage))}% + + +
+ ) +} + +// Light/Lux Gauge - Sun rays style +type LuxGaugeProps = { + value: number + max?: number +} + +export function LuxGauge({ value, max = 2000 }: LuxGaugeProps) { + const percentage = Math.max(0, Math.min(100, (value / max) * 100)) + const numRays = 12 + const activeRays = Math.round((percentage / 100) * numRays) + + return ( +
+ + {/* Rays */} + {Array.from({ length: numRays }).map((_, i) => { + const angle = (i * 360) / numRays - 90 + const isActive = i < activeRays + const x1 = 40 + 22 * Math.cos((angle * Math.PI) / 180) + const y1 = 40 + 22 * Math.sin((angle * Math.PI) / 180) + const x2 = 40 + 35 * Math.cos((angle * Math.PI) / 180) + const y2 = 40 + 35 * Math.sin((angle * Math.PI) / 180) + + return ( + + ) + })} + + {/* Center circle */} + 0 ? '#fbbf24' : '#f3f4f6'} + className="transition-all duration-300" + /> + + {/* Inner glow */} + {percentage > 20 && ( + + )} + + + {/* Value below */} +
+ {toPersianDigits(Math.round(value))} + Lux +
+
+ ) +} + +// Gas/CO Gauge - Circular progress with warning colors +type GasGaugeProps = { + value: number + max?: number +} + +export function GasGauge({ value, max = 100 }: GasGaugeProps) { + const percentage = Math.max(0, Math.min(100, (value / max) * 100)) + const circumference = 2 * Math.PI * 35 + const strokeDashoffset = circumference - (percentage / 100) * circumference + + // Color based on level (green -> yellow -> orange -> red) + const getColor = () => { + if (percentage < 25) return '#22c55e' + if (percentage < 50) return '#eab308' + if (percentage < 75) return '#f97316' + return '#ef4444' + } + + const color = getColor() + + return ( +
+ + {/* Background circle */} + + + {/* Progress circle */} + + + + {/* Center content */} +
+ {toPersianDigits(Math.round(value))} + ppm +
+
+ ) +} + diff --git a/src/GreenHome.Web/src/components/Loading.tsx b/src/GreenHome.Web/src/components/Loading.tsx new file mode 100644 index 0000000..b12821b --- /dev/null +++ b/src/GreenHome.Web/src/components/Loading.tsx @@ -0,0 +1,63 @@ +'use client' + +import { Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +type LoadingProps = { + message?: string + fullScreen?: boolean +} + +export default function Loading({ message = 'در حال بارگذاری...', fullScreen = true }: LoadingProps) { + const containerClass = fullScreen + ? 'min-h-screen flex items-center justify-center p-4' + : 'flex items-center justify-center p-8' + + return ( +
+
+
+ +
+
+

{message}

+
+
+ ) +} + +// Skeleton loading component +type SkeletonProps = { + className?: string + width?: string | number + height?: string | number + rounded?: boolean +} + +export function Skeleton({ className, width, height, rounded = true }: SkeletonProps) { + return ( +
+ ) +} + +// Card skeleton for loading states +export function CardSkeleton() { + return ( +
+ + + +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/MiniChart.tsx b/src/GreenHome.Web/src/components/MiniChart.tsx new file mode 100644 index 0000000..2f0b853 --- /dev/null +++ b/src/GreenHome.Web/src/components/MiniChart.tsx @@ -0,0 +1,50 @@ +"use client" + +type MiniLineChartProps = { + data: number[] + color: string +} + +export function MiniLineChart({ data, color }: MiniLineChartProps) { + const width = 200 + const height = 56 + + // Downsample to at most 60 points for a cheap, smooth sparkline + const points = data.length > 60 + ? data.filter((_, i) => i % Math.ceil(data.length / 60) === 0) + : data + + if (points.length < 2) { + return
+ } + + const min = Math.min(...points) + const max = Math.max(...points) + const range = max - min || 1 + + const coords = points.map((v, i) => { + const x = (i / (points.length - 1)) * width + const y = height - 2 - ((v - min) / range) * (height - 4) + return [x, y] as const + }) + + const linePath = coords + .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`) + .join(' ') + + const areaPath = `${linePath} L${coords[coords.length - 1][0].toFixed(2)},${height} L${coords[0][0].toFixed(2)},${height} Z` + + return ( + + + + + ) +} diff --git a/src/GreenHome.Web/src/components/README.md b/src/GreenHome.Web/src/components/README.md new file mode 100644 index 0000000..daa27fa --- /dev/null +++ b/src/GreenHome.Web/src/components/README.md @@ -0,0 +1,100 @@ +# ساختار کامپوننت‌ها + +این پوشه شامل تمام کامپوننت‌های قابل استفاده مجدد اپلیکیشن است که به صورت استاندارد سازماندهی شده‌اند. + +## ساختار پوشه‌بندی + +``` +components/ +├── common/ # کامپوننت‌های عمومی و پایه +├── forms/ # کامپوننت‌های فرم و ورودی +├── navigation/ # کامپوننت‌های ناوبری +├── cards/ # کامپوننت‌های کارت و نمایش داده +├── alerts/ # کامپوننت‌های هشدار +├── settings/ # کامپوننت‌های تنظیمات +├── calendar/ # کامپوننت‌های تقویم +├── utils/ # کامپوننت‌های کمکی +└── daily-report/ # کامپوننت‌های مخصوص گزارش روزانه +``` + +## کامپوننت‌های Common + +کامپوننت‌های پایه که در همه جا استفاده می‌شوند: + +- **PageHeader**: هدر صفحات با icon و title +- **ErrorMessage**: نمایش پیام خطا +- **SuccessMessage**: نمایش پیام موفقیت +- **EmptyState**: حالت خالی +- **Card**: wrapper برای کارت‌ها +- **Badge**: نشان/برچسب +- **IconButton**: دکمه با icon +- **Modal**: مودال/دیالوگ +- **BackLink**: لینک بازگشت +- **Button**: دکمه (موجود) +- **Dialog**: دیالوگ (موجود) +- **Tabs**: تب‌ها (موجود) + +## کامپوننت‌های Forms + +کامپوننت‌های ورودی و فرم: + +- **MobileInput**: ورودی شماره موبایل با normalization +- **CodeInput**: ورودی کد 4 رقمی با auto-focus +- **SearchInput**: ورودی جستجو با icon +- **FormInput**: wrapper برای input با label و error + +## کامپوننت‌های Navigation + +کامپوننت‌های ناوبری: + +- **DateNavigation**: ناوبری تاریخ (قبل/بعد/تقویم) +- **Pagination**: صفحه‌بندی + +## کامپوننت‌های Cards + +کامپوننت‌های نمایش داده: + +- **DeviceCard**: کارت دستگاه +- **MonthCard**: کارت ماه در تقویم +- **CalendarDayCell**: سلول روز در تقویم +- **StatsCard**: کارت آمار + +## کامپوننت‌های Alerts + +کامپوننت‌های هشدار: + +- **WeatherAlertBanner**: بنر هشدار آب و هوا +- **AlertBadge**: نشان هشدار + +## کامپوننت‌های Settings + +کامپوننت‌های تنظیمات: + +- **SettingsInputGroup**: گروه input برای min/max +- **SettingsSection**: بخش تنظیمات با icon + +## کامپوننت‌های Calendar + +کامپوننت‌های تقویم: + +- **YearSelector**: انتخاب سال +- **WeekdayHeaders**: هدر روزهای هفته + +## کامپوننت‌های Utils + +کامپوننت‌های کمکی: + +- **ResendButton**: دکمه ارسال مجدد با countdown +- **ConfirmDialog**: hook و function برای dialog تأیید + +## نحوه استفاده + +```typescript +// Import از index اصلی +import { PageHeader, ErrorMessage, DeviceCard } from '@/components' + +// یا import مستقیم +import { PageHeader } from '@/components/common' +import { MobileInput } from '@/components/forms' +``` + diff --git a/src/GreenHome.Web/src/components/ServiceWorkerManager.tsx b/src/GreenHome.Web/src/components/ServiceWorkerManager.tsx new file mode 100644 index 0000000..f6f699f --- /dev/null +++ b/src/GreenHome.Web/src/components/ServiceWorkerManager.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState, useEffect } from 'react' +import { RefreshCw, X } from 'lucide-react' + +export default function ServiceWorkerManager() { + const [showUpdate, setShowUpdate] = useState(false) + const [isUpdating, setIsUpdating] = useState(false) + + useEffect(() => { + if (typeof window === 'undefined' || !('serviceWorker' in navigator)) { + return + } + + let checkInterval: ReturnType | null = null + + const handleMessage = (event: MessageEvent) => { + if (event.data && event.data.type === 'SW_UPDATED') { + window.location.reload() + } + } + + const handleVisibilityChange = () => { + if (!document.hidden) { + navigator.serviceWorker.getRegistration().then((reg) => reg?.update()) + } + } + + const handleFocus = () => { + navigator.serviceWorker.getRegistration().then((reg) => reg?.update()) + } + + const register = async () => { + try { + const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }) + + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing + if (newWorker) { + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + setShowUpdate(true) + } + }) + } + }) + + navigator.serviceWorker.addEventListener('message', handleMessage) + checkInterval = setInterval(() => registration.update(), 60000) + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('focus', handleFocus) + } catch (error) { + console.error('Service Worker registration failed:', error) + } + } + + if (document.readyState === 'complete') { + register() + } else { + window.addEventListener('load', register, { once: true }) + } + + return () => { + if (checkInterval) { + clearInterval(checkInterval) + } + navigator.serviceWorker.removeEventListener('message', handleMessage) + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('focus', handleFocus) + } + }, []) + + const handleUpdate = () => { + setIsUpdating(true) + + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' }) + } + + setTimeout(() => { + window.location.reload() + }, 500) + } + + if (!showUpdate) return null + + return ( +
+
+
+ +
+
+

+ نسخه جدید آماده است +

+

+ برای دریافت آخرین تغییرات، اپ را به‌روزرسانی کنید +

+
+
+ + +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/AlertFormModal.tsx b/src/GreenHome.Web/src/components/alert-settings/AlertFormModal.tsx new file mode 100644 index 0000000..0fa0353 --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/AlertFormModal.tsx @@ -0,0 +1,139 @@ +'use client' + +import { Plus } from 'lucide-react' +import { CreateAlertConditionDto, CreateAlertRuleRequest } from '@/lib/api' +import { Modal, Button } from '@/components/common' +import { Checkbox } from '@/components/forms' +import { AlertPreview } from './AlertPreview' +import { NotificationTypeSelector } from './NotificationTypeSelector' +import { TimeTypeSelector } from './TimeTypeSelector' +import { RuleFormCard } from './RuleFormCard' + +type AlertFormModalProps = { + isOpen: boolean + editingAlert: boolean + saving: boolean + formData: CreateAlertConditionDto + onClose: () => void + onSubmit: (e: React.FormEvent) => void + onFormDataChange: (data: CreateAlertConditionDto) => void + onAddRule: () => void + onRemoveRule: (index: number) => void + onUpdateRule: (index: number, updates: Partial) => void +} + +export function AlertFormModal({ + isOpen, + editingAlert, + saving, + formData, + onClose, + onSubmit, + onFormDataChange, + onAddRule, + onRemoveRule, + onUpdateRule +}: AlertFormModalProps) { + return ( + +
+ + +
+ onFormDataChange({ ...formData, notificationType: value })} + /> + + onFormDataChange({ ...formData, timeType: value })} + /> + + {/* Rules */} +
+
+
+ +

+ {formData.rules.length === 0 + ? 'هیچ شرطی تعریف نشده است' + : formData.rules.length === 1 + ? 'یک شرط تعریف شده است' + : `${formData.rules.length} شرط تعریف شده است` + } +

+
+ +
+ +
+ {formData.rules.map((rule, index) => ( + + ))} +
+
+ + {/* Active Status */} +
+ onFormDataChange({ ...formData, isEnabled: e.target.checked })} + label="هشدار فعال باشد" + className="text-orange-600 focus:ring-orange-500" + /> +
+ + {/* Modal Footer */} +
+ + +
+
+ +
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/AlertListItem.tsx b/src/GreenHome.Web/src/components/alert-settings/AlertListItem.tsx new file mode 100644 index 0000000..76d819f --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/AlertListItem.tsx @@ -0,0 +1,77 @@ +'use client' + +import { Pencil, Trash2, Check, X } from 'lucide-react' +import { AlertConditionDto } from '@/lib/api' +import { IconButton, Badge } from '@/components/common' +import { NOTIFICATION_TYPES, TIME_TYPES } from './constants' +import { getSensorIcon, getSensorLabel, getNotificationLabel, getTimeTypeLabel, formatRuleText } from './utils' + +type AlertListItemProps = { + alert: AlertConditionDto + onEdit: (alert: AlertConditionDto) => void + onDelete: (id: number) => void + onToggleActive: (alert: AlertConditionDto) => void +} + +export function AlertListItem({ alert, onEdit, onDelete, onToggleActive }: AlertListItemProps) { + return ( +
+
+
+ {/* Header */} +
+ n.value === alert.notificationType)?.icon} + > + {getNotificationLabel(alert.notificationType)} + + t.value === alert.timeType)?.icon} + > + {getTimeTypeLabel(alert.timeType)} + + +
+ + {/* Rules */} +
+
شرایط:
+
+ {alert.rules.map((rule, idx) => { + const Icon = getSensorIcon(rule.sensorType) + return ( +
+ + {getSensorLabel(rule.sensorType)} + {formatRuleText(rule)} +
+ ) + })} +
+
+
+ + {/* Actions */} +
+ onEdit(alert)} title="ویرایش" /> + onDelete(alert.id)} title="حذف" /> +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/AlertPreview.tsx b/src/GreenHome.Web/src/components/alert-settings/AlertPreview.tsx new file mode 100644 index 0000000..a727d0c --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/AlertPreview.tsx @@ -0,0 +1,26 @@ +'use client' + +import { SensorType, ComparisonType, NotificationType, TimeType } from './constants' +import { generatePreviewText } from './utils' + +type AlertPreviewProps = { + rules: { sensorType: SensorType; comparisonType: ComparisonType; value1: number; value2?: number | null }[] + notificationType: NotificationType + timeType: TimeType +} + +export function AlertPreview({ rules, notificationType, timeType }: AlertPreviewProps) { + return ( +
+
+
+
+
+ {generatePreviewText(rules, notificationType, timeType)} +
+
+
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/AlertSettingsTab.tsx b/src/GreenHome.Web/src/components/alert-settings/AlertSettingsTab.tsx new file mode 100644 index 0000000..a09a5b6 --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/AlertSettingsTab.tsx @@ -0,0 +1,137 @@ +'use client' +import { useEffect, useState, useCallback } from 'react' +import { api, AlertConditionDto, CreateAlertConditionDto, CreateAlertRuleRequest } from '@/lib/api' +import { Plus, AlertTriangle } from 'lucide-react' +import Loading from '@/components/Loading' +import { EmptyState, Button } from '@/components/common' +import { confirmDialog } from '@/components/utils' +import { AlertListItem } from './AlertListItem' +import { AlertFormModal } from './AlertFormModal' + +export function AlertSettingsTab({ deviceId }: { deviceId: number }) { + const [alerts, setAlerts] = useState([]) + const [loading, setLoading] = useState(true) + const [modal, setModal] = useState<{ open: boolean; editing: AlertConditionDto | null }>({ open: false, editing: null }) + const [saving, setSaving] = useState(false) + const [formData, setFormData] = useState({ + deviceId, + notificationType: 0, + timeType: 2, + isEnabled: true, + rules: [{ sensorType: 0, comparisonType: 0, value1: 30, order: 0 }] + }) + + const loadAlerts = useCallback(async () => { + setLoading(true) + try { setAlerts(await api.getAlertConditions(deviceId)) } + catch (e) { console.error('Error loading alerts:', e) } + finally { setLoading(false) } + }, [deviceId]) + + useEffect(() => { loadAlerts() }, [loadAlerts]) + + const openCreate = () => { + setModal({ open: true, editing: null }) + setFormData({ + deviceId, + notificationType: 0, + timeType: 2, + isEnabled: true, + rules: [{ sensorType: 0, comparisonType: 0, value1: 30, order: 0 }] + }) + } + + const openEdit = (alert: AlertConditionDto) => { + setModal({ open: true, editing: alert }) + setFormData({ + notificationType: alert.notificationType, + timeType: alert.timeType, + isEnabled: alert.isEnabled, + deviceId: alert.deviceId, + rules: alert.rules.map((r, i) => ({ + sensorType: r.sensorType, comparisonType: r.comparisonType, + value1: r.value1, value2: r.value2, order: i + })) + }) + } + + const closeModal = () => setModal({ open: false, editing: null }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!formData.rules.length) { window.alert('لطفاً حداقل یک قانون اضافه کنید'); return } + setSaving(true) + try { + if (modal.editing) await api.updateAlertCondition({ id: modal.editing.id, ...formData }) + else await api.createAlertCondition(formData) + await loadAlerts() + closeModal() + } catch (error) { + console.error('Error saving alert:', error) + window.alert('خطا در ذخیره هشدار. لطفاً دوباره تلاش کنید.') + } finally { setSaving(false) } + } + + const handleDelete = async (id: number) => { + if (!await confirmDialog({ message: 'آیا از حذف این هشدار اطمینان دارید؟', variant: 'danger', confirmText: 'حذف', cancelText: 'انصراف' })) return + try { await api.deleteAlertCondition(id); await loadAlerts() } + catch (e) { console.error('Error deleting alert:', e); window.alert('خطا در حذف هشدار.') } + } + + const toggleActive = async (alert: AlertConditionDto) => { + try { + await api.updateAlertCondition({ + id: alert.id, notificationType: alert.notificationType, timeType: alert.timeType, + isEnabled: !alert.isEnabled, + rules: alert.rules.map((r, i) => ({ sensorType: r.sensorType, comparisonType: r.comparisonType, value1: r.value1, value2: r.value2, order: i })) + }) + await loadAlerts() + } catch (e) { console.error('Error toggling alert:', e); window.alert('خطا در تغییر وضعیت هشدار.') } + } + + const addRule = () => setFormData(f => ({ ...f, rules: [...f.rules, { sensorType: 0, comparisonType: 0, value1: 30, order: f.rules.length }] })) + const removeRule = (index: number) => setFormData(f => ({ ...f, rules: f.rules.filter((_, i) => i !== index).map((r, i) => ({ ...r, order: i })) })) + const updateRule = (index: number, updates: Partial) => setFormData(f => { + const rules = [...f.rules]; rules[index] = { ...rules[index], ...updates }; return { ...f, rules } + }) + + if (loading) return + + return ( +
+
+
+

لیست هشدارها ({alerts.length})

+ +
+ + {alerts.length === 0 ? ( + افزودن اولین هشدار} + /> + ) : ( +
+ {alerts.map(alert => ( + + ))} +
+ )} +
+ + +
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/NotificationTypeSelector.tsx b/src/GreenHome.Web/src/components/alert-settings/NotificationTypeSelector.tsx new file mode 100644 index 0000000..5d6606b --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/NotificationTypeSelector.tsx @@ -0,0 +1,39 @@ +'use client' + +import { NOTIFICATION_TYPES, NotificationType } from './constants' + +type NotificationTypeSelectorProps = { + value: NotificationType + onChange: (value: NotificationType) => void +} + +export function NotificationTypeSelector({ value, onChange }: NotificationTypeSelectorProps) { + return ( +
+ +
+ {NOTIFICATION_TYPES.map(notif => { + const Icon = notif.icon + const isSelected = value === notif.value + return ( + + ) + })} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/RuleFormCard.tsx b/src/GreenHome.Web/src/components/alert-settings/RuleFormCard.tsx new file mode 100644 index 0000000..000fdbd --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/RuleFormCard.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useState, useCallback, useRef } from 'react' +import { Trash2 } from 'lucide-react' +import { SENSOR_TYPES, COMPARISON_TYPES, SensorType, ComparisonType } from './constants' +import { getSensorIcon, getSensorLabel, getSensorUnit, getComparisonLabel } from './utils' +import { CreateAlertRuleRequest } from '@/lib/api' + +type RuleFormCardProps = { + rule: CreateAlertRuleRequest + index: number + totalRules: number + onUpdate: (index: number, updates: Partial) => void + onRemove: (index: number) => void +} + +/** Validate and sanitize a decimal number string with max 1 decimal place */ +function sanitizeDecimal(input: string): string { + // Remove any non-digit, non-dot, non-minus characters + let cleaned = input.replace(/[^0-9.\-]/g, '') + // Allow only one minus sign at the start + const minusCount = (cleaned.match(/-/g) || []).length + if (minusCount > 1) { + cleaned = cleaned.replace(/-/g, '') + if (input.startsWith('-')) cleaned = '-' + cleaned + } else if (minusCount === 1 && !cleaned.startsWith('-')) { + cleaned = cleaned.replace(/-/g, '') + } + // Allow only one decimal point + const dotIndex = cleaned.indexOf('.') + if (dotIndex !== -1) { + const beforeDot = cleaned.slice(0, dotIndex + 1) + const afterDot = cleaned.slice(dotIndex + 1).replace(/\./g, '') + // Max 1 decimal place + cleaned = beforeDot + afterDot.slice(0, 1) + } + return cleaned +} + +export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: RuleFormCardProps) { + const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax + const SensorIcon = getSensorIcon(rule.sensorType) + + // Helper to safely convert a number to string, handling null/undefined + const toStr = (v: number | null | undefined): string => v != null ? String(v) : '' + + // Local string state for each input to allow decimal point typing + // without losing the dot on every keystroke (Number("5.") → 5 strips the dot) + const [rawValue1, setRawValue1] = useState(toStr(rule.value1)) + const [rawValue2, setRawValue2] = useState(toStr(rule.value2)) + + // Track if each input is currently focused (being edited) + const isFocused1 = useRef(false) + const isFocused2 = useRef(false) + + // Use the prop value when input is NOT focused (external updates) + const displayValue1 = isFocused1.current ? rawValue1 : toStr(rule.value1) + const displayValue2 = isFocused2.current ? rawValue2 : toStr(rule.value2) + + const commitValue1 = useCallback((raw: string) => { + const sanitized = sanitizeDecimal(raw) + const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized) + if (!isNaN(num)) { + onUpdate(index, { value1: num }) + } + }, [index, onUpdate]) + + const commitValue2 = useCallback((raw: string) => { + const sanitized = sanitizeDecimal(raw) + const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized) + if (!isNaN(num)) { + onUpdate(index, { value2: num }) + } + }, [index, onUpdate]) + + return ( +
+ {/* Header - Compact */} +
+
+ + {totalRules === 1 ? '⚡ شرط' : `⚡ شرط ${index + 1}`} + + {/* Compact summary of current selection */} + + + {getSensorLabel(rule.sensorType)} + {' | '} + {getComparisonLabel(rule.comparisonType)} + {' | '} + {rule.value1}{needsMax ? ` - ${rule.value2 ?? '?'}` : ''} {getSensorUnit(rule.sensorType)} + +
+ {totalRules > 1 && ( + + )} +
+ + {/* Compact row: Sensor | Comparison | Value */} +
+ {/* Sensor Type - Compact Select */} +
+ +
+ + {/* Comparison Type - Compact Select */} +
+ +
+ + {/* Threshold Values - Compact Inputs */} +
+
+ { + const sanitized = sanitizeDecimal(e.target.value) + setRawValue1(sanitized) + }} + onFocus={() => { isFocused1.current = true }} + onBlur={() => { + isFocused1.current = false + commitValue1(rawValue1) + }} + className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500" + placeholder={needsMax ? 'از' : 'مقدار'} + required + /> + + {getSensorUnit(rule.sensorType)} + +
+ {needsMax && ( + <> + - +
+ { + const sanitized = sanitizeDecimal(e.target.value) + setRawValue2(sanitized) + }} + onFocus={() => { isFocused2.current = true }} + onBlur={() => { + isFocused2.current = false + commitValue2(rawValue2) + }} + className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500" + placeholder="تا" + required + /> + + {getSensorUnit(rule.sensorType)} + +
+ + )} +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/TimeTypeSelector.tsx b/src/GreenHome.Web/src/components/alert-settings/TimeTypeSelector.tsx new file mode 100644 index 0000000..4b34a8c --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/TimeTypeSelector.tsx @@ -0,0 +1,40 @@ +'use client' + +import { TIME_TYPES, TimeType } from './constants' + +type TimeTypeSelectorProps = { + value: TimeType + onChange: (value: TimeType) => void +} + +export function TimeTypeSelector({ value, onChange }: TimeTypeSelectorProps) { + return ( +
+ +
+ {TIME_TYPES.map(time => { + const Icon = time.icon + const isSelected = value === time.value + return ( + + ) + })} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/alert-settings/constants.tsx b/src/GreenHome.Web/src/components/alert-settings/constants.tsx new file mode 100644 index 0000000..d4f6992 --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/constants.tsx @@ -0,0 +1,50 @@ +import { + Thermometer, + Droplets, + Leaf, + Wind, + Sun, + ArrowLeftRight, + Maximize2, + Phone, + MessageSquare, + Moon, + Zap, + LucideIcon +} from 'lucide-react' + +export type SensorType = 0 | 1 | 2 | 3 | 4 +export type ComparisonType = 0 | 1 | 2 | 3 +export type NotificationType = number +export type TimeType = 0 | 1 | 2 + +export const NotifText: Record = { + [-1]: 'بهت زنگ بزنم', + 0: 'برات پیامک بفرستم' +} + +export const SENSOR_TYPES: { value: SensorType; label: string; icon: LucideIcon; unit: string }[] = [ + { value: 0, label: 'دما', icon: Thermometer, unit: '°C' }, + { value: 1, label: 'رطوبت هوا', icon: Droplets, unit: '%' }, + { value: 2, label: 'رطوبت خاک', icon: Leaf, unit: '%' }, + { value: 3, label: 'گاز', icon: Wind, unit: 'PPM' }, + { value: 4, label: 'نور', icon: Sun, unit: 'Lux' } +] + +export const COMPARISON_TYPES: { value: ComparisonType; label: string; icon: LucideIcon | (() => React.JSX.Element); needsMax: boolean }[] = [ + { value: 0, label: 'بزرگتر از', icon: () => {'>'}, needsMax: false }, + { value: 1, label: 'کوچکتر از', icon: () => {'<'}, needsMax: false }, + { value: 2, label: 'بین', icon: ArrowLeftRight, needsMax: true }, + { value: 3, label: 'خارج از محدوده', icon: Maximize2, needsMax: true } +] + +export const NOTIFICATION_TYPES: { value: NotificationType; label: string; icon: LucideIcon }[] = [ + { value: -1, label: 'تماس تلفنی', icon: Phone }, + { value: 0, label: 'پیامک', icon: MessageSquare } +] + +export const TIME_TYPES: { value: TimeType; label: string; icon: LucideIcon; description: string }[] = [ + { value: 0, label: 'روز', icon: Sun, description: 'فقط در روز بررسی شود' }, + { value: 1, label: 'شب', icon: Moon, description: 'فقط در شب بررسی شود' }, + { value: 2, label: 'همیشه', icon: Zap, description: 'در هر زمان بررسی شود' } +] diff --git a/src/GreenHome.Web/src/components/alert-settings/utils.ts b/src/GreenHome.Web/src/components/alert-settings/utils.ts new file mode 100644 index 0000000..9321a46 --- /dev/null +++ b/src/GreenHome.Web/src/components/alert-settings/utils.ts @@ -0,0 +1,89 @@ +import { AlertRuleDto } from '@/lib/api' +import { SENSOR_TYPES, COMPARISON_TYPES, NOTIFICATION_TYPES, TIME_TYPES, NotifText, SensorType, ComparisonType, NotificationType, TimeType } from './constants' +import { AlertTriangle, LucideIcon } from 'lucide-react' + +export const getSensorIcon = (type: SensorType): LucideIcon => { + const sensor = SENSOR_TYPES.find(s => s.value === type) + return sensor ? sensor.icon : AlertTriangle +} + +export const getSensorLabel = (type: SensorType) => { + const sensor = SENSOR_TYPES.find(s => s.value === type) + return sensor?.label ?? type.toString() +} + +export const getSensorUnit = (type: SensorType) => { + const sensor = SENSOR_TYPES.find(s => s.value === type) + return sensor?.unit ?? '' +} + +export const getComparisonLabel = (type: ComparisonType) => { + const comp = COMPARISON_TYPES.find(c => c.value === type) + return comp?.label ?? type.toString() +} + +export const getNotificationLabel = (type: NotificationType) => { + const notif = NOTIFICATION_TYPES.find(n => n.value === type) + return notif?.label ?? type.toString() +} + +export const getTimeTypeLabel = (type: TimeType) => { + const time = TIME_TYPES.find(t => t.value === type) + return time?.label ?? type.toString() +} + +export const formatRuleText = (rule: AlertRuleDto) => { + const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType) + if (comp?.needsMax) { + return `${getComparisonLabel(rule.comparisonType)}: ${rule.value1} - ${rule.value2 ?? '?'} ${getSensorUnit(rule.sensorType)}` + } + const symbol = rule.comparisonType === 0 ? '>' : rule.comparisonType === 1 ? '<' : '?' + return `${symbol} ${rule.value1} ${getSensorUnit(rule.sensorType)}` +} + +export const generatePreviewText = ( + rules: { sensorType: SensorType; comparisonType: ComparisonType; value1: number; value2?: number | null }[], + notificationType: NotificationType, + timeType: TimeType +) => { + if (rules.length === 0) { + return '🤔 هنوز شرطی تعریف نکردی!' + } + + const notifText = NotifText[notificationType] || '' + const timeText = timeType === 0 + ? 'فقط توی روز' + : timeType === 1 + ? 'فقط توی شب' + : '' + + const rulesText = rules.map(rule => { + const sensorName = getSensorLabel(rule.sensorType) + const unit = getSensorUnit(rule.sensorType) + const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType) + + if (comp?.needsMax) { + if (rule.comparisonType === 2) { + return `${sensorName} بین ${rule.value1} تا ${rule.value2 ?? '؟'} ${unit} باشه` + } else { + return `${sensorName} از بازه ${rule.value1} تا ${rule.value2 ?? '؟'} ${unit} خارج شه` + } + } else { + const direction = rule.comparisonType === 0 ? 'بالاتر' : 'پایین‌تر' + return `${sensorName} ${direction} از ${rule.value1} ${unit} بره` + } + }) + + let baseText = '' + if (rulesText.length === 1) { + baseText = `وقتی ${rulesText[0]}` + } else { + const lastRule = rulesText[rulesText.length - 1] + const otherRules = rulesText.slice(0, -1).join(' و ') + baseText = `وقتی ${otherRules} و ${lastRule}` + } + + const timePrefix = timeText ? `${timeText}، ` : '' + + return `${timePrefix}${baseText}، ${notifText} 📱` +} diff --git a/src/GreenHome.Web/src/components/alerts/AlertBadge.tsx b/src/GreenHome.Web/src/components/alerts/AlertBadge.tsx new file mode 100644 index 0000000..237a9ae --- /dev/null +++ b/src/GreenHome.Web/src/components/alerts/AlertBadge.tsx @@ -0,0 +1,17 @@ +import { LucideIcon } from 'lucide-react' +import { Badge } from '@/components/common/Badge' + +type AlertBadgeProps = { + icon: LucideIcon + label: string + variant?: 'default' | 'success' | 'warning' | 'error' | 'info' +} + +export function AlertBadge({ icon: Icon, label, variant = 'default' }: AlertBadgeProps) { + return ( + + {label} + + ) +} + diff --git a/src/GreenHome.Web/src/components/alerts/WeatherAlertBanner.tsx b/src/GreenHome.Web/src/components/alerts/WeatherAlertBanner.tsx new file mode 100644 index 0000000..fc8e539 --- /dev/null +++ b/src/GreenHome.Web/src/components/alerts/WeatherAlertBanner.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle, ChevronLeft } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type WeatherAlertBannerProps = { + alertsCount: number + onClick: () => void + className?: string +} + +export function WeatherAlertBanner({ + alertsCount, + onClick, + className +}: WeatherAlertBannerProps) { + return ( +
+ +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/alerts/index.ts b/src/GreenHome.Web/src/components/alerts/index.ts new file mode 100644 index 0000000..00be97b --- /dev/null +++ b/src/GreenHome.Web/src/components/alerts/index.ts @@ -0,0 +1,3 @@ +export { WeatherAlertBanner } from './WeatherAlertBanner' +export { AlertBadge } from './AlertBadge' + diff --git a/src/GreenHome.Web/src/components/calendar/WeekdayHeaders.tsx b/src/GreenHome.Web/src/components/calendar/WeekdayHeaders.tsx new file mode 100644 index 0000000..3dfb168 --- /dev/null +++ b/src/GreenHome.Web/src/components/calendar/WeekdayHeaders.tsx @@ -0,0 +1,27 @@ +import { cn } from '@/lib/utils' + +type WeekdayHeadersProps = { + weekdays?: string[] + className?: string +} + +const defaultWeekdays = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'] + +export function WeekdayHeaders({ + weekdays = defaultWeekdays, + className +}: WeekdayHeadersProps) { + return ( +
+ {weekdays.map((day) => ( +
+ {day} +
+ ))} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/calendar/YearSelector.tsx b/src/GreenHome.Web/src/components/calendar/YearSelector.tsx new file mode 100644 index 0000000..70dd88d --- /dev/null +++ b/src/GreenHome.Web/src/components/calendar/YearSelector.tsx @@ -0,0 +1,37 @@ +import { Calendar as CalendarIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type YearSelectorProps = { + years: number[] + selectedYear: number + onYearChange: (year: number) => void + className?: string +} + +export function YearSelector({ + years, + selectedYear, + onYearChange, + className +}: YearSelectorProps) { + return ( +
+ + +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/calendar/index.ts b/src/GreenHome.Web/src/components/calendar/index.ts new file mode 100644 index 0000000..5447904 --- /dev/null +++ b/src/GreenHome.Web/src/components/calendar/index.ts @@ -0,0 +1,3 @@ +export { YearSelector } from './YearSelector' +export { WeekdayHeaders } from './WeekdayHeaders' + diff --git a/src/GreenHome.Web/src/components/cards/CalendarDayCell.tsx b/src/GreenHome.Web/src/components/cards/CalendarDayCell.tsx new file mode 100644 index 0000000..6d1a1b2 --- /dev/null +++ b/src/GreenHome.Web/src/components/cards/CalendarDayCell.tsx @@ -0,0 +1,48 @@ +import { Database } from 'lucide-react' +import { cn } from '@/lib/utils' + +type CalendarDayCellProps = { + day: number + hasData: boolean + recordCount?: number + onClick?: () => void + className?: string +} + +export function CalendarDayCell({ + day, + hasData, + recordCount = 0, + onClick, + className +}: CalendarDayCellProps) { + if (hasData) { + return ( + + ) + } + + return ( +
+
{day}
+
+ ) +} + diff --git a/src/GreenHome.Web/src/components/cards/DeviceCard.tsx b/src/GreenHome.Web/src/components/cards/DeviceCard.tsx new file mode 100644 index 0000000..0e8a036 --- /dev/null +++ b/src/GreenHome.Web/src/components/cards/DeviceCard.tsx @@ -0,0 +1,69 @@ +import { Link } from '@/lib/router' +import { Settings, Calendar } from 'lucide-react' +import { DeviceDto } from '@/lib/api' + +type DeviceCardProps = { + device: DeviceDto + href?: string + onClick?: () => void + className?: string +} + +export function DeviceCard({ device, href, onClick, className }: DeviceCardProps) { + if (onClick) { + return ( + + ) + } + + return ( + +
+
+ +
+
+

+ {device.deviceName} +

+

+ {device.location || 'بدون موقعیت'} +

+
+ {device.userName} {device.userFamily} +
+
+
+ +
+
+ + ) +} diff --git a/src/GreenHome.Web/src/components/cards/MonthCard.tsx b/src/GreenHome.Web/src/components/cards/MonthCard.tsx new file mode 100644 index 0000000..46efe85 --- /dev/null +++ b/src/GreenHome.Web/src/components/cards/MonthCard.tsx @@ -0,0 +1,50 @@ +import { Database } from 'lucide-react' +import { cn } from '@/lib/utils' + +type MonthCardProps = { + name: string + isActive: boolean + stats?: { days: number; records: number } + onClick?: () => void + className?: string +} + +export function MonthCard({ + name, + isActive, + stats, + onClick, + className +}: MonthCardProps) { + return ( + + ) +} + diff --git a/src/GreenHome.Web/src/components/cards/StatsCard.tsx b/src/GreenHome.Web/src/components/cards/StatsCard.tsx new file mode 100644 index 0000000..4c4a8a6 --- /dev/null +++ b/src/GreenHome.Web/src/components/cards/StatsCard.tsx @@ -0,0 +1,32 @@ +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type StatsCardProps = { + icon: LucideIcon + label: string + value: number | string + unit?: string + className?: string + iconColor?: string +} + +export function StatsCard({ + icon: Icon, + label, + value, + unit, + className, + iconColor = 'text-green-600' +}: StatsCardProps) { + return ( +
+ + + {toPersianDigits(value.toString())} + {unit && ` ${unit}`} {label} + +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/cards/index.ts b/src/GreenHome.Web/src/components/cards/index.ts new file mode 100644 index 0000000..f13baac --- /dev/null +++ b/src/GreenHome.Web/src/components/cards/index.ts @@ -0,0 +1,5 @@ +export { DeviceCard } from './DeviceCard' +export { MonthCard } from './MonthCard' +export { CalendarDayCell } from './CalendarDayCell' +export { StatsCard } from './StatsCard' + diff --git a/src/GreenHome.Web/src/components/common/BackLink.tsx b/src/GreenHome.Web/src/components/common/BackLink.tsx new file mode 100644 index 0000000..45f2fbc --- /dev/null +++ b/src/GreenHome.Web/src/components/common/BackLink.tsx @@ -0,0 +1,29 @@ +import { Link } from '@/lib/router' +import { ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' + +type BackLinkProps = { + href: string + label?: string + className?: string +} + +export function BackLink({ + href, + label = 'بازگشت', + className +}: BackLinkProps) { + return ( + + + {label} + + ) +} + diff --git a/src/GreenHome.Web/src/components/common/Badge.tsx b/src/GreenHome.Web/src/components/common/Badge.tsx new file mode 100644 index 0000000..5dc4608 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/Badge.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' + +type BadgeProps = { + children: ReactNode + variant?: BadgeVariant + icon?: LucideIcon + className?: string + size?: 'sm' | 'md' +} + +const variantStyles: Record = { + default: 'bg-gray-100 text-gray-800', + success: 'bg-green-100 text-green-800', + warning: 'bg-orange-100 text-orange-800', + error: 'bg-red-100 text-red-800', + info: 'bg-blue-100 text-blue-800', +} + +const sizeStyles = { + sm: 'text-xs px-2 py-1', + md: 'text-sm px-3 py-1.5', +} + +export function Badge({ + children, + variant = 'default', + icon: Icon, + className, + size = 'md' +}: BadgeProps) { + return ( + + {Icon && } + {children} + + ) +} + diff --git a/src/GreenHome.Web/src/components/common/Button.tsx b/src/GreenHome.Web/src/components/common/Button.tsx new file mode 100644 index 0000000..6a63838 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/Button.tsx @@ -0,0 +1,137 @@ +import { ButtonHTMLAttributes } from 'react' +import { LucideIcon, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ButtonVariant = + | 'default' + | 'primary' + | 'secondary' + | 'outline' + | 'ghost' + | 'success' + | 'danger' + | 'warning' + | 'info' +type ButtonSize = 'sm' | 'md' | 'lg' + +type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant + size?: ButtonSize + icon?: LucideIcon + iconPosition?: 'left' | 'right' + responsiveText?: { + mobile: string + desktop: string + } + tooltip?: string + loading?: boolean + fullWidth?: boolean +} + +const variantStyles: Record = { + default: 'bg-white border border-gray-300 hover:border-gray-400 hover:bg-gray-50 text-gray-700', + primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm', + secondary: 'bg-white border border-orange-300 hover:border-orange-400 hover:bg-orange-50 text-orange-700', + outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700', + ghost: 'hover:bg-gray-100 text-gray-700', + success: 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white shadow-sm', + danger: 'bg-red-600 hover:bg-red-700 text-white shadow-sm', + warning: 'bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white shadow-sm', + info: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm', +} + +const sizeStyles: Record = { + sm: { + padding: 'px-2 py-1.5', + text: 'text-xs', + icon: 'w-3 h-3', + }, + md: { + padding: 'px-2 sm:px-4 py-2.5', + text: 'text-xs sm:text-sm', + icon: 'w-4 h-4 sm:w-5 sm:h-5', + }, + lg: { + padding: 'px-4 sm:px-6 py-3', + text: 'text-sm sm:text-base', + icon: 'w-5 h-5 sm:w-6 sm:h-6', + }, +} + +export function Button({ + variant = 'default', + size = 'md', + icon: Icon, + iconPosition = 'left', + responsiveText, + tooltip, + loading = false, + fullWidth = false, + children, + className, + disabled, + ...props +}: ButtonProps) { + const sizeStyle = sizeStyles[size] + const variantStyle = variantStyles[variant] + + const buttonClasses = cn( + 'inline-flex items-center justify-center gap-1 sm:gap-2', + 'rounded-lg transition-all duration-150 font-medium', + 'whitespace-nowrap', + 'active:scale-[0.98]', + 'touch-manipulation', + 'select-none', + 'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100', + sizeStyle.padding, + sizeStyle.text, + variantStyle, + fullWidth && 'w-full', + className + ) + + const iconClasses = cn(sizeStyle.icon, 'flex-shrink-0') + + const buttonContent = ( + <> + {loading ? ( + + ) : ( + Icon && iconPosition === 'left' && + )} + + {responsiveText ? ( + <> + {responsiveText.desktop} + {responsiveText.mobile} + + ) : ( + {children} + )} + + {!loading && Icon && iconPosition === 'right' && } + + ) + + if (tooltip) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/GreenHome.Web/src/components/common/Card.tsx b/src/GreenHome.Web/src/components/common/Card.tsx new file mode 100644 index 0000000..992baaa --- /dev/null +++ b/src/GreenHome.Web/src/components/common/Card.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +type CardProps = { + children: ReactNode + className?: string + hover?: boolean + padding?: 'sm' | 'md' | 'lg' | 'none' +} + +export function Card({ + children, + className, + hover = false, + padding = 'md' +}: CardProps) { + const paddingClasses = { + sm: 'p-4', + md: 'p-6', + lg: 'p-8', + none: 'p-0' + } + + return ( +
+ {children} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/common/Dialog.tsx b/src/GreenHome.Web/src/components/common/Dialog.tsx new file mode 100644 index 0000000..f2a9c86 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/Dialog.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react' +import { Modal } from './Modal' + +type DialogProps = { + isOpen: boolean + onClose: () => void + title: string + children: ReactNode +} + +/** + * Dialog is now a thin wrapper around Modal to avoid duplicate overlay logic. + * Kept for backward compatibility with existing call sites. + */ +export function Dialog({ isOpen, onClose, title, children }: DialogProps) { + return ( + + {children} + + ) +} diff --git a/src/GreenHome.Web/src/components/common/EmptyState.tsx b/src/GreenHome.Web/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..15ab910 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/EmptyState.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type EmptyStateProps = { + icon?: LucideIcon + title?: string + message: string + action?: ReactNode + className?: string +} + +export function EmptyState({ + icon: Icon, + title, + message, + action, + className +}: EmptyStateProps) { + return ( +
+ {Icon && ( +
+ +
+ )} + {title && ( +

{title}

+ )} +

{message}

+ {action && ( +
+ {action} +
+ )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/common/ErrorMessage.tsx b/src/GreenHome.Web/src/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..bdfcf60 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/ErrorMessage.tsx @@ -0,0 +1,61 @@ +import { AlertCircle, X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ErrorMessageProps = { + message: string + onClose?: () => void + className?: string + fullPage?: boolean + action?: React.ReactNode +} + +export function ErrorMessage({ + message, + onClose, + className, + fullPage = false, + action +}: ErrorMessageProps) { + if (fullPage) { + return ( +
+
+
+
+ +
+

خطا

+

{message}

+ {action} +
+
+
+ ) + } + + return ( +
+
+ + {message} + {onClose && ( + + )} +
+ {action && ( +
+ {action} +
+ )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/common/IconButton.tsx b/src/GreenHome.Web/src/components/common/IconButton.tsx new file mode 100644 index 0000000..8c8ae30 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/IconButton.tsx @@ -0,0 +1,51 @@ +import { ButtonHTMLAttributes } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type IconButtonProps = ButtonHTMLAttributes & { + icon: LucideIcon + variant?: 'default' | 'primary' | 'danger' | 'ghost' + size?: 'sm' | 'md' | 'lg' +} + +const variantStyles = { + default: 'text-gray-600 hover:bg-gray-100', + primary: 'text-blue-600 hover:bg-blue-50', + danger: 'text-red-600 hover:bg-red-50', + ghost: 'text-gray-400 hover:text-gray-600 hover:bg-gray-100', +} + +const sizeStyles = { + sm: 'p-1.5', + md: 'p-2', + lg: 'p-2.5', +} + +const iconSizes = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', +} + +export function IconButton({ + icon: Icon, + variant = 'default', + size = 'md', + className, + ...props +}: IconButtonProps) { + return ( + + ) +} + diff --git a/src/GreenHome.Web/src/components/common/Modal.tsx b/src/GreenHome.Web/src/components/common/Modal.tsx new file mode 100644 index 0000000..148c56c --- /dev/null +++ b/src/GreenHome.Web/src/components/common/Modal.tsx @@ -0,0 +1,96 @@ +import { ReactNode, useEffect } from 'react' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ModalProps = { + isOpen: boolean + onClose: () => void + title?: string + children: ReactNode + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' + className?: string + contentClassName?: string + showCloseButton?: boolean +} + +const sizeStyles = { + sm: 'max-w-md', + md: 'max-w-2xl', + lg: 'max-w-4xl', + xl: 'max-w-6xl', + full: 'max-w-full', +} + +export function Modal({ + isOpen, + onClose, + title, + children, + size = 'md', + className, + contentClassName, + showCloseButton = true +}: ModalProps) { + useEffect(() => { + if (!isOpen) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleEscape) + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = previousOverflow + } + }, [isOpen, onClose]) + + if (!isOpen) return null + + return ( +
+ {/* Backdrop */} + + ) +} diff --git a/src/GreenHome.Web/src/components/common/PageHeader.tsx b/src/GreenHome.Web/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..028ed6d --- /dev/null +++ b/src/GreenHome.Web/src/components/common/PageHeader.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type PageHeaderProps = { + icon: LucideIcon + title: string + subtitle?: string + action?: ReactNode + iconGradient?: string + className?: string +} + +export function PageHeader({ + icon: Icon, + title, + subtitle, + action, + iconGradient = 'from-indigo-500 to-purple-600', + className +}: PageHeaderProps) { + return ( +
+
+
+
+ +
+
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ {action && ( +
+ {action} +
+ )} +
+
+ ) +} + diff --git a/src/GreenHome.Web/src/components/common/SegmentTab.tsx b/src/GreenHome.Web/src/components/common/SegmentTab.tsx new file mode 100644 index 0000000..8c0c8b7 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/SegmentTab.tsx @@ -0,0 +1,26 @@ +type SegmentTabProps = { + tabs: { value: T; label: string }[] + activeTab: T + className?: string + setActiveTab: React.Dispatch> +} + +export function SegmentTab({ tabs, activeTab, className, setActiveTab }: SegmentTabProps) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/src/GreenHome.Web/src/components/common/SuccessMessage.tsx b/src/GreenHome.Web/src/components/common/SuccessMessage.tsx new file mode 100644 index 0000000..3a08a1d --- /dev/null +++ b/src/GreenHome.Web/src/components/common/SuccessMessage.tsx @@ -0,0 +1,33 @@ +import { CheckCircle2, X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type SuccessMessageProps = { + message: string + onClose?: () => void + className?: string +} + +export function SuccessMessage({ + message, + onClose, + className +}: SuccessMessageProps) { + return ( +
+ + {message} + {onClose && ( + + )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/common/Tabs.tsx b/src/GreenHome.Web/src/components/common/Tabs.tsx new file mode 100644 index 0000000..a3b8d57 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/Tabs.tsx @@ -0,0 +1,62 @@ +import { ReactNode, Dispatch, SetStateAction } from 'react' +import { SegmentTab } from './SegmentTab' + +type TabConfig = { + value: T + label: string +} + +type TabsProps = { + tabs: TabConfig[] + activeTab: T + setActiveTab: Dispatch> + children: Record + className?: string +} + +export function Tabs({ + tabs, + activeTab, + setActiveTab, + children, + className +}: TabsProps) { + return ( +
+ {/* Segmented Control for Mobile */} +
+ + + {/* Desktop Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+
+ + {/* Tab Content */} + {children[activeTab] !== null && children[activeTab] !== undefined && ( +
+ {children[activeTab]} +
+ )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/common/index.ts b/src/GreenHome.Web/src/components/common/index.ts new file mode 100644 index 0000000..11e5575 --- /dev/null +++ b/src/GreenHome.Web/src/components/common/index.ts @@ -0,0 +1,14 @@ +export { Button } from './Button' +export { Dialog } from './Dialog' +export { Tabs } from './Tabs' +export { SegmentTab } from './SegmentTab' +export { PageHeader } from './PageHeader' +export { ErrorMessage } from './ErrorMessage' +export { SuccessMessage } from './SuccessMessage' +export { EmptyState } from './EmptyState' +export { Card } from './Card' +export { Badge } from './Badge' +export { IconButton } from './IconButton' +export { Modal } from './Modal' +export { BackLink } from './BackLink' + diff --git a/src/GreenHome.Web/src/components/daily-report/AnalysisTab.tsx b/src/GreenHome.Web/src/components/daily-report/AnalysisTab.tsx new file mode 100644 index 0000000..0673687 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/AnalysisTab.tsx @@ -0,0 +1,114 @@ +import { useState, useEffect, useCallback } from 'react' +import { RefreshCw } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import { DailyReportDto, api } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import Loading from '@/components/Loading' +import { ErrorMessage, EmptyState } from '@/components/common' +import { Button } from '@/components/common/Button' +import { FileText } from 'lucide-react' + +type AnalysisTabProps = { + deviceId: number + selectedDate: string +} + +export function AnalysisTab({ deviceId, selectedDate }: AnalysisTabProps) { + const [dailyReport, setDailyReport] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadAnalysis = useCallback(async () => { + // اگر قبلاً لود شده، دوباره لود نکن + if (dailyReport) return + + setLoading(true) + setError(null) + + try { + const report = await api.getDailyReport(deviceId, selectedDate) + setDailyReport(report) + } catch (error) { + console.error('Error loading analysis:', error) + setError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.') + } finally { + setLoading(false) + } + }, [deviceId, selectedDate, dailyReport]) + + // Reset analysis data when selectedDate changes + useEffect(() => { + setDailyReport(null) + setError(null) + }, [selectedDate]) + + // Load analysis when component mounts (user clicked on tab) + useEffect(() => { + loadAnalysis() + }, [loadAnalysis]) + if (loading) { + return + } + + if (error) { + return ( + { + setDailyReport(null) + loadAnalysis() + }} + variant="primary" + icon={RefreshCw} + > + تلاش مجدد + + } + /> + ) + } + + if (!dailyReport) { + return ( + + ) + } + + return ( +
+ {/* Report Info */} +
+
+
+

تعداد رکوردها

+

{toPersianDigits(dailyReport.recordCount)}

+
+
+

رکوردهای نمونه

+

{toPersianDigits(dailyReport.sampledRecordCount)}

+
+
+

توکن‌های مصرفی

+

{toPersianDigits(dailyReport.totalTokens)}

+
+
+

منبع

+

{dailyReport.fromCache ? '💾 کش' : '🆕 جدید'}

+
+
+
+ + {/* Analysis Content */} +
+ {dailyReport.analysis} +
+
+ ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/ChartsTab.tsx b/src/GreenHome.Web/src/components/daily-report/ChartsTab.tsx new file mode 100644 index 0000000..7e9f701 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/ChartsTab.tsx @@ -0,0 +1,156 @@ +import { useState, useMemo, memo, useCallback } from 'react' +import { BarChart3 } from 'lucide-react' +import { LineChart, Panel } from '@/components/Charts' +import { TimeRangeSelector } from './TimeRangeSelector' +import { DataGap, detectDataGaps, normalizeTelemetryData } from '@/features/daily-report/utils' +import { TelemetryDto } from '@/lib/api' +import { EmptyState } from '@/components/common' +import { useTelemetryCharts } from '@/features/daily-report/hooks/useTelemetryCharts' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type ChartsTabProps = { + sortedTelemetry: TelemetryDto[] + dataGaps?: DataGap[] + visibleParams?: string[] | null +} + +// Map server param keys to chart keys +const paramToChartKey: Record = { + temperature: 'temp', + humidity: 'hum', + gas: 'gas', + soil: 'soil', + lux: 'lux', +} + +const UNIT_BY_CHART_KEY: Record = { + temp: '°C', + hum: '%', + gas: 'ppm', + soil: '%', + lux: 'Lux', +} + +function buildChartStats(data: (number | null)[], unit: string) { + const valid = data.filter((v): v is number => v !== null) + if (valid.length === 0) return [] + + const min = Math.min(...valid) + const max = Math.max(...valid) + const avg = valid.reduce((sum, value) => sum + value, 0) / valid.length + + return [ + { label: 'حداقل', value: `${toPersianDigits(min.toFixed(1))} ${unit}` }, + { label: 'میانگین', value: `${toPersianDigits(avg.toFixed(1))} ${unit}` }, + { label: 'حداکثر', value: `${toPersianDigits(max.toFixed(1))} ${unit}` }, + ] +} + +export const ChartsTab = memo(function ChartsTab({ + sortedTelemetry, + dataGaps = [], + visibleParams, +}: ChartsTabProps) { + const [chartStartMinute, setChartStartMinute] = useState(0) + const [chartEndMinute, setChartEndMinute] = useState(1439) + + const handleStartMinuteChange = useCallback((minute: number) => { + setChartStartMinute(minute) + }, []) + + const handleEndMinuteChange = useCallback((minute: number) => { + setChartEndMinute(minute) + }, []) + + // Normalize telemetry data + const normalizedTelemetry = useMemo( + () => normalizeTelemetryData(sortedTelemetry), + [sortedTelemetry] + ) + + // Filter by time range + const filteredTelemetry = useMemo(() => { + return normalizedTelemetry.filter( + t => t.minute >= chartStartMinute && t.minute <= chartEndMinute + ) + }, [normalizedTelemetry, chartStartMinute, chartEndMinute]) + + // Detect data gaps in filtered data + const timestamps = useMemo( + () => filteredTelemetry.map(t => t.timestamp), + [filteredTelemetry] + ) + + const filteredDataGaps = useMemo( + () => detectDataGaps(timestamps, 30), + [timestamps] + ) + + // Build charts using custom hook + const { charts, chartLabels } = useTelemetryCharts({ + filteredTelemetry, + filteredDataGaps, + }) + + // If server returned a visibility list, compute allowed chart keys + const allowedChartKeys = useMemo(() => { + if (!visibleParams || visibleParams.length === 0) return null + return new Set(visibleParams.map(p => paramToChartKey[p] ?? p)) + }, [visibleParams]) + + const chartsWithStats = useMemo(() => { + return charts.map(chart => ({ + chart, + stats: buildChartStats(chart.data, UNIT_BY_CHART_KEY[chart.key] ?? ''), + })) + }, [charts]) + + return ( +
+ + + {filteredTelemetry.length === 0 ? ( + + ) : ( +
+ {chartsWithStats + .filter(({ chart }) => !allowedChartKeys || allowedChartKeys.has(chart.key)) + .map(({ chart, stats }) => ( + + + + ))} +
+ )} +
+ ) +}, (prevProps, nextProps) => { + // Custom comparison for better performance + return prevProps.sortedTelemetry.length === nextProps.sortedTelemetry.length && + (prevProps.dataGaps?.length ?? 0) === (nextProps.dataGaps?.length ?? 0) && + prevProps.sortedTelemetry[0]?.id === nextProps.sortedTelemetry[0]?.id +}) diff --git a/src/GreenHome.Web/src/components/daily-report/GreenhouseForecastAlerts.tsx b/src/GreenHome.Web/src/components/daily-report/GreenhouseForecastAlerts.tsx new file mode 100644 index 0000000..8eed8d9 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/GreenhouseForecastAlerts.tsx @@ -0,0 +1,226 @@ +"use client" + +import { useMemo } from 'react' +import { Thermometer, Sun, Droplets, Wind, AlertTriangle } from 'lucide-react' +import { WeatherData, GreenhouseAlert } from '@/features/weather' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { getPersianDayName } from '@/features/daily-report/utils' + +type GreenhouseForecastAlert = GreenhouseAlert & { + daysAhead: number // تعداد روزهای آینده + date: string // تاریخ روز پیش‌بینی +} + +type GreenhouseForecastAlertsProps = { + weatherData: WeatherData +} + +/** + * محاسبه تعداد هشدارهای پیش‌بینی آب و هوا + */ +export function getForecastAlertsCount(weatherData: WeatherData | null): number { + if (!weatherData) return 0 + + let count = 0 + + // بررسی روزهای آینده (از روز دوم به بعد، چون روز اول امروز است) + for (let i = 1; i < weatherData.daily.length; i++) { + const day = weatherData.daily[i] + + if (day.tempMin < 5) count++ // یخ‌زدگی + if (day.tempMax > 35) count++ // گرمای شدید + if (day.uvIndexMax > 8) count++ // UV بالا + if (day.windSpeedMax > 40) count++ // باد شدید + if (day.precipitation > 10) count++ // بارش قابل توجه + if (day.tempMin >= 5 && day.tempMin < 10) count++ // دمای پایین + if (day.tempMax >= 30 && day.tempMax <= 35) count++ // دمای بالا + } + + return count +} + +/** + * کامپوننت هشدارهای پیش‌بینی آب و هوای آینده برای گلخانه + * این کامپوننت فقط برای روزهای آینده (نه امروز) هشدار می‌دهد + */ +export function GreenhouseForecastAlerts({ weatherData }: GreenhouseForecastAlertsProps) { + const alertsByDay = useMemo(() => { + const alertsMap = new Map() + + // بررسی روزهای آینده (از روز دوم به بعد، چون روز اول امروز است) + for (let i = 1; i < weatherData.daily.length; i++) { + const day = weatherData.daily[i] + const daysAhead = i // تعداد روزهای آینده + const dayAlerts: GreenhouseForecastAlert[] = [] + + // هشدار یخ‌زدگی (دمای حداقل کمتر از 5 درجه) + if (day.tempMin < 5) { + dayAlerts.push({ + type: 'danger', + title: '⚠️ هشدار یخ‌زدگی', + description: `دمای حداقل ${toPersianDigits(Math.round(day.tempMin))}°C پیش‌بینی شده. سیستم گرمایش را آماده کنید و پوشش محافظ روی گیاهان حساس قرار دهید.`, + icon: Thermometer, + daysAhead, + date: day.date + }) + } + + // هشدار گرمای شدید (دمای حداکثر بیشتر از 35 درجه) + if (day.tempMax > 35) { + dayAlerts.push({ + type: 'danger', + title: '🌡️ هشدار گرمای شدید', + description: `دمای حداکثر ${toPersianDigits(Math.round(day.tempMax))}°C پیش‌بینی شده. سایه‌بان‌ها را فعال کنید، تهویه را افزایش دهید و آبیاری را در ساعات خنک انجام دهید.`, + icon: Sun, + daysAhead, + date: day.date + }) + } + + // هشدار شاخص UV بالا (بیشتر از 8) + if (day.uvIndexMax > 8) { + dayAlerts.push({ + type: 'warning', + title: '☀️ شاخص UV بالا', + description: `شاخص UV ${toPersianDigits(Math.round(day.uvIndexMax))} است. برای گیاهان حساس به نور از سایه‌بان استفاده کنید.`, + icon: Sun, + daysAhead, + date: day.date + }) + } + + // هشدار باد شدید (بیشتر از 40 کیلومتر بر ساعت) + if (day.windSpeedMax > 40) { + dayAlerts.push({ + type: 'warning', + title: '💨 باد شدید', + description: `سرعت باد به ${toPersianDigits(Math.round(day.windSpeedMax))} کیلومتر بر ساعت می‌رسد. دریچه‌ها و پنجره‌ها را ببندید و سازه را بررسی کنید.`, + icon: Wind, + daysAhead, + date: day.date + }) + } + + // هشدار بارش قابل توجه (بیشتر از 10 میلی‌متر) + if (day.precipitation > 10) { + dayAlerts.push({ + type: 'info', + title: '🌧️ بارش قابل توجه', + description: `بارش ${toPersianDigits(Math.round(day.precipitation))} میلی‌متر پیش‌بینی شده. سیستم زهکشی را بررسی کنید و آبیاری را کاهش دهید.`, + icon: Droplets, + daysAhead, + date: day.date + }) + } + + // هشدار دمای پایین (بین 5 تا 10 درجه) - هشدار خفیف + if (day.tempMin >= 5 && day.tempMin < 10) { + dayAlerts.push({ + type: 'warning', + title: '🌡️ دمای پایین', + description: `دمای حداقل ${toPersianDigits(Math.round(day.tempMin))}°C پیش‌بینی شده. مراقب گیاهان حساس به سرما باشید.`, + icon: Thermometer, + daysAhead, + date: day.date + }) + } + + // هشدار دمای بالا (بین 30 تا 35 درجه) - هشدار خفیف + if (day.tempMax >= 30 && day.tempMax <= 35) { + dayAlerts.push({ + type: 'warning', + title: '🌡️ دمای بالا', + description: `دمای حداکثر ${toPersianDigits(Math.round(day.tempMax))}°C پیش‌بینی شده. تهویه را افزایش دهید و آبیاری را در ساعات صبح انجام دهید.`, + icon: Sun, + daysAhead, + date: day.date + }) + } + + // اگر هشداری برای این روز وجود دارد، به map اضافه کن + if (dayAlerts.length > 0) { + alertsMap.set(daysAhead, dayAlerts) + } + } + + return alertsMap + }, [weatherData]) + + if (alertsByDay.size === 0) { + return null + } + + // تبدیل map به array و مرتب‌سازی بر اساس daysAhead + const sortedDays = Array.from(alertsByDay.entries()).sort((a, b) => a[0] - b[0]) + + // تابع برای نمایش نام روزهای آینده + const getDayLabel = (daysAhead: number) => { + if (daysAhead === 1) return 'فردا' + if (daysAhead === 2) return 'پس فردا' + return `${toPersianDigits(daysAhead)} روز آینده` + } + + return ( +
+

+ + هشدارهای پیش‌بینی آب و هوا برای روزهای آینده +

+ + {sortedDays.map(([daysAhead, alerts]) => { + const dayName = getPersianDayName(weatherData.daily[daysAhead].date) + const dayLabel = getDayLabel(daysAhead) + + return ( +
+ {/* Header for the day */} +
+

+ روز {dayName} ({dayLabel}) +

+
+ + {/* Alerts for this day */} + {alerts.map((alert, index) => { + const IconComponent = alert.icon + return ( +
+
+ +
+

+ {alert.title} +

+

+ {alert.description} +

+
+
+
+ ) + })} +
+ ) + })} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/LiveSummaryCards.tsx b/src/GreenHome.Web/src/components/daily-report/LiveSummaryCards.tsx new file mode 100644 index 0000000..3a17580 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/LiveSummaryCards.tsx @@ -0,0 +1,142 @@ +'use client' + +import { Thermometer, Droplets, Sprout, Wind, Sun, Activity } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { paramConfig } from '@/features/daily-report/utils' + +type LiveSummaryCardsProps = { + temperature: number + humidity: number + soil: number + gas: number + lux: number + lastUpdated?: string + onCardClick?: (param: string) => void +} + +const cardMeta = { + temperature: { + icon: Thermometer, + color: 'text-rose-600', + bgColor: 'bg-rose-50', + }, + humidity: { + icon: Droplets, + color: 'text-sky-600', + bgColor: 'bg-sky-50', + }, + soil: { + icon: Sprout, + color: 'text-emerald-600', + bgColor: 'bg-emerald-50', + }, + gas: { + icon: Wind, + color: 'text-amber-600', + bgColor: 'bg-amber-50', + }, + lux: { + icon: Sun, + color: 'text-yellow-600', + bgColor: 'bg-yellow-50', + }, +} as const + +type ParamKey = keyof typeof cardMeta + +export function LiveSummaryCards({ + temperature, + humidity, + soil, + gas, + lux, + lastUpdated, + onCardClick, +}: LiveSummaryCardsProps) { + const values: Record = { + temperature, + humidity, + soil, + gas, + lux, + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

خلاصه لحظه‌ای

+
+ {lastUpdated && ( + + آخرین به‌روزرسانی: {toPersianDigits(lastUpdated)} + + )} +
+ + {/* Cards Grid */} +
+ {(Object.keys(cardMeta) as ParamKey[]).map((key) => { + const meta = cardMeta[key] + const config = paramConfig[key] + const Icon = meta.icon + const value = values[key] + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/PowerOutageCard.tsx b/src/GreenHome.Web/src/components/daily-report/PowerOutageCard.tsx new file mode 100644 index 0000000..972de5a --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/PowerOutageCard.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useMemo } from 'react' +import { Clock, PowerOff, Power, HelpCircle, Zap } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import type { PowerOutagePairDto } from '@/lib/api' + +type PowerOutageCardProps = { + currentPower: 0 | 1 | null + outages: PowerOutagePairDto[] + isToday?: boolean + stale?: boolean +} + +const TIME_FORMATTER = new Intl.DateTimeFormat('fa-IR', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Asia/Tehran', + hour12: false, +}) + +function formatTime(utcString: string): string { + const date = new Date(utcString) + if (Number.isNaN(date.getTime())) return '—' + return toPersianDigits(TIME_FORMATTER.format(date)) +} + +function formatDuration(seconds: number | null, isToday: boolean): string { + if (seconds === null) return isToday ? 'ادامه دارد' : 'نامشخص' + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + + if (hours > 0) { + return `${toPersianDigits(String(hours))} ساعت و ${toPersianDigits(String(minutes))} دقیقه` + } + return `${toPersianDigits(String(minutes))} دقیقه` +} + +export function PowerOutageCard({ currentPower, outages, isToday = true, stale = false }: PowerOutageCardProps) { + const totalDowntimeSeconds = useMemo(() => { + let total = 0 + const now = Date.now() + for (const outage of outages) { + if (outage.durationSeconds != null) { + total += outage.durationSeconds + continue + } + // For past days an open-ended outage has an unknown duration; only + // count it towards "now" when viewing today. + if (!isToday) continue + const start = new Date(outage.disconnectedAt).getTime() + if (!Number.isNaN(start) && start < now) { + total += Math.floor((now - start) / 1000) + } + } + return total + }, [outages, isToday]) + + const hasOpenOutage = outages.some(o => o.durationSeconds == null) + + const totalDowntimeFormatted = useMemo( + () => formatDuration(totalDowntimeSeconds, isToday), + [totalDowntimeSeconds, isToday], + ) + + const totalDowntimeLabel = !isToday && hasOpenOutage ? 'نامشخص' : totalDowntimeFormatted + + const hasOutages = outages.length > 0 + + // Current power status is only meaningful for "today" with fresh data and an + // actual power reading from the selected day. + const showCurrentStatus = isToday && !stale && currentPower !== null + + const StatusIcon = currentPower === 1 ? Power : currentPower === 0 ? PowerOff : HelpCircle + const statusBadgeClass = + currentPower === 1 ? 'bg-emerald-100 dark:bg-emerald-500/20' : currentPower === 0 ? 'bg-red-100 dark:bg-red-500/20' : 'bg-gray-100 dark:bg-slate-700' + const statusIconClass = + currentPower === 1 ? 'text-emerald-600 dark:text-emerald-400' : currentPower === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-500 dark:text-slate-400' + const statusTitleClass = + currentPower === 1 ? 'text-emerald-700 dark:text-emerald-300' : currentPower === 0 ? 'text-red-700 dark:text-red-300' : 'text-gray-700 dark:text-slate-300' + const statusBgClass = + currentPower === 1 ? 'bg-emerald-50 dark:bg-emerald-500/10' : currentPower === 0 ? 'bg-red-50 dark:bg-red-500/10' : 'bg-gray-50 dark:bg-slate-800/60' + const statusTitle = + currentPower === 1 ? 'برق وصل است' : currentPower === 0 ? 'برق قطع است' : 'وضعیت برق نامشخص' + const statusSubtitle = + currentPower === 1 ? 'برق در حال حاضر وصل است' : currentPower === 0 ? 'دستگاه در حال حاضر با باتری کار می‌کند' : 'اطلاعات وضعیت برق ثبت نشده است' + + return ( +
+
+ {/* Header */} +
+ + + +

قطعی‌های برق

+ {hasOutages && ( + + {toPersianDigits(String(outages.length))} قطعی + + )} +
+ + {/* Current status — only for today with fresh data */} + {showCurrentStatus && ( +
+
+ +
+
+

{statusTitle}

+

{statusSubtitle}

+
+
+ )} + + {!hasOutages ? ( +
+

هیچ قطعی برقی ثبت نشده

+

+ {isToday ? 'در این روز هیچ قطعی برقی ثبت نشده است' : 'برای این روز هیچ قطعی برقی ثبت نشده است'} +

+
+ ) : ( + <> +
+ زمان‌بندی قطعی‌ها + + + مجموع: {totalDowntimeLabel} + +
+ +
+ {outages.map((outage, index) => ( +
+ +
+

+ {formatTime(outage.disconnectedAt)} تا{' '} + {outage.connectedAt + ? formatTime(outage.connectedAt) + : isToday + ? 'اکنون' + : 'پایان روز'} +

+
+ 3600 ? 'text-red-600 dark:text-red-400' : 'text-amber-700 dark:text-amber-400'}`}> + {formatDuration(outage.durationSeconds ?? null, isToday)} + +
+ ))} +
+ + )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/SummaryCard.tsx b/src/GreenHome.Web/src/components/daily-report/SummaryCard.tsx new file mode 100644 index 0000000..ad1ba50 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/SummaryCard.tsx @@ -0,0 +1,69 @@ +import { Thermometer, Droplets, Sprout, Flame, Sun, LucideIcon } from 'lucide-react' +import { MiniLineChart } from '@/components/MiniChart' +import { paramConfig } from '@/features/daily-report/utils' +import { toPersianDigits } from '@/lib/format/persian-digits' + +const PARAM_ICONS: Record = { + temperature: Thermometer, + humidity: Droplets, + soil: Sprout, + gas: Flame, + lux: Sun, +} + +type SummaryCardProps = { + param: string + currentValue: number + minValue: number + maxValue: number + data: number[] + onClick?: () => void + headlineLabel?: string + stale?: boolean +} + +export function SummaryCard({ param, currentValue, minValue, maxValue, data, onClick, headlineLabel, stale = false }: SummaryCardProps) { + const config = paramConfig[param] + if (!config) return null + + const Icon = PARAM_ICONS[param] + + return ( +
+ {/* Header: icon chip + title + headline label */} +
+ + {Icon && } + +
+

{config.title}

+ {headlineLabel && ( +

{headlineLabel}

+ )} +
+
+ + {/* Headline value */} +
+ + {toPersianDigits(currentValue.toFixed(1))} + + {config.unit} +
+ + {/* Mini trend chart */} +
+ +
+ + {/* Min / Max footer */} +
+ حداقل {toPersianDigits(minValue.toFixed(1))} + حداکثر {toPersianDigits(maxValue.toFixed(1))} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/SummaryTab.tsx b/src/GreenHome.Web/src/components/daily-report/SummaryTab.tsx new file mode 100644 index 0000000..eb87388 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/SummaryTab.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { SummaryCard } from './SummaryCard' +import { PowerOutageCard } from './PowerOutageCard' +import { WeatherData } from '@/features/weather' +import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts' +import { Dialog } from '@/components/common/Dialog' +import { WeatherAlertBanner } from '@/components/alerts' +import { Loader2 } from 'lucide-react' +import type { PowerOutagePairDto } from '@/lib/api' + +function summarize(data: number[]) { + if (data.length === 0) return { current: 0, min: 0, max: 0, avg: 0, count: 0 } + let min = Infinity + let max = -Infinity + let sum = 0 + for (const v of data) { + if (v < min) min = v + if (v > max) max = v + sum += v + } + return { current: data[data.length - 1] ?? 0, min, max, avg: sum / data.length, count: data.length } +} + +type SummaryTabProps = { + temperature: number[] + humidity: number[] + soil: number[] + gas: number[] + lux: number[] + forecastWeather?: WeatherData | null + forecastWeatherLoading?: boolean + onCardClick?: (param: string) => void + visibleParams?: string[] | null + currentPower: 0 | 1 | null + powerOutages?: PowerOutagePairDto[] + isToday: boolean + stale?: boolean +} + +export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams, currentPower, powerOutages = [], isToday, stale = false }: SummaryTabProps) { + const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false) + + const alertsCount = useMemo(() => { + return getForecastAlertsCount(forecastWeather ?? null) + }, [forecastWeather]) + // Memoized summary statistics for each parameter (loop-based, avoids spread stack overflow) + const temperatureSummary = useMemo(() => summarize(temperature), [temperature]) + const humiditySummary = useMemo(() => summarize(humidity), [humidity]) + const soilSummary = useMemo(() => summarize(soil), [soil]) + const gasSummary = useMemo(() => summarize(gas), [gas]) + const luxSummary = useMemo(() => summarize(lux), [lux]) + + const hasAnyData = temperature.length > 0 || humidity.length > 0 || soil.length > 0 || gas.length > 0 || lux.length > 0 + + // For today the headline is the latest reading; for past days it's the daily average. + const headlineValue = (summary: { current: number; avg: number }) => + isToday ? summary.current : summary.avg + + const headlineLabel = () => + isToday ? (stale ? 'آخرین مقدار دریافتی' : 'مقدار فعلی') : 'میانگین روز' + + return ( + <> + {/* Greenhouse Forecast Alerts Section */} + {forecastWeatherLoading ? ( +
+
+
+
+ +
+
+

+ در حال بارگذاری هشدارهای آب و هوایی... +

+

+ لطفاً صبر کنید +

+
+
+
+
+ ) : alertsCount > 0 && forecastWeather ? ( + setIsAlertsDialogOpen(true)} + /> + ) : null} + + {/* Summary Cards Grid */} + {hasAnyData ? ( +
+ {(!visibleParams || visibleParams.length === 0 || visibleParams.includes('temperature')) && temperature.length > 0 && ( + onCardClick?.('temperature')} + /> + )} + + {(!visibleParams || visibleParams.length === 0 || visibleParams.includes('humidity')) && humidity.length > 0 && ( + onCardClick?.('humidity')} + /> + )} + + {(!visibleParams || visibleParams.length === 0 || visibleParams.includes('gas')) && gas.length > 0 && ( + onCardClick?.('gas')} + /> + )} + + {(!visibleParams || visibleParams.length === 0 || visibleParams.includes('soil')) && soil.length > 0 && ( + onCardClick?.('soil')} + /> + )} + + {(!visibleParams || visibleParams.length === 0 || visibleParams.includes('lux')) && lux.length > 0 && ( + onCardClick?.('lux')} + /> + )} +
+ ) : ( +
+

داده‌ای برای این روز ثبت نشده است

+

برای این تاریخ هیچ رکورد تلمتری دریافت نشده است.

+
+ )} + + {/* Power Outage Card */} +
+ +
+ + {/* Alerts Dialog */} + {forecastWeather && ( + setIsAlertsDialogOpen(false)} + title="هشدارهای پیش‌بینی آب و هوا" + > + + + )} + + ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/TimeRangeSelector.tsx b/src/GreenHome.Web/src/components/daily-report/TimeRangeSelector.tsx new file mode 100644 index 0000000..f1580ae --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/TimeRangeSelector.tsx @@ -0,0 +1,76 @@ +import { useMemo, useState } from 'react' +import { DataGap } from '@/features/daily-report/utils' +import { calculateSunTimes } from '@/lib/utils/sun-utils' +import { + TimeRangeHeader, + TimelineTrack, + TimelineSlider, + TimeLabel, + TimeRangeInfo, +} from './timeline' + +type TimeRangeSelectorProps = { + startMinute: number // دقیقه از نیمه شب (0-1439) + endMinute: number // دقیقه از نیمه شب (0-1439) + onStartMinuteChange: (minute: number) => void + onEndMinuteChange: (minute: number) => void + totalRecords: number + dataGaps?: DataGap[] // گپ‌های داده +} + +export function TimeRangeSelector({ + startMinute, + endMinute, + onStartMinuteChange, + onEndMinuteChange, + totalRecords, + dataGaps = [] +}: TimeRangeSelectorProps) { + const [isOpen, setIsOpen] = useState(true) + const sunTimes = useMemo(() => calculateSunTimes(), []) + + const handleReset = () => { + onStartMinuteChange(0) + onEndMinuteChange(1439) // 23:59 + } + + const handleToggle = () => { + setIsOpen(open => !open) + } + + return ( +
+ + + {isOpen && ( + <> + {/* Timeline Selector */} +
+ + + + + + +
+ + + + )} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/WeatherTab.tsx b/src/GreenHome.Web/src/components/daily-report/WeatherTab.tsx new file mode 100644 index 0000000..3ad5501 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/WeatherTab.tsx @@ -0,0 +1,121 @@ +import { useState, useEffect, useCallback } from 'react' +import { RefreshCw, MapPin } from 'lucide-react' +import { WeatherData } from '@/features/weather' +import { fetchHistoricalWeather, fetchForecastWeather, isToday as checkIsToday, fetchLocationName } from '@/features/weather' +import { QOM_LAT, QOM_LON } from '@/features/weather/helpers' +import { TodayWeather } from './weather/TodayWeather' +import { HistoricalWeather } from './weather/HistoricalWeather' +import Loading from '@/components/Loading' +import { ErrorMessage } from '@/components/common' +import { Button } from '@/components/common/Button' + +type WeatherTabProps = { + selectedDate: string // Persian date in format "yyyy/MM/dd" +} + +export function WeatherTab({ selectedDate }: WeatherTabProps) { + const [weatherData, setWeatherData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedDayIndex, setExpandedDayIndex] = useState(null) + const [locationName, setLocationName] = useState('در حال دریافت...') + + const loadWeather = useCallback(async () => { + // اگر قبلاً لود شده، دوباره لود نکن + if (weatherData) return + + setLoading(true) + setError(null) + + try { + const isTodayDate = checkIsToday(selectedDate) + + // Load weather data and location name in parallel + const [weather, location] = await Promise.all([ + isTodayDate + ? fetchForecastWeather() + : fetchHistoricalWeather(selectedDate), + fetchLocationName(QOM_LAT, QOM_LON) + ]) + + setWeatherData(weather) + setLocationName(location) + } catch (error) { + console.error('Error loading weather:', error) + setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.') + // Set fallback location name on error + setLocationName('کهک قم، ایران') + } finally { + setLoading(false) + } + }, [selectedDate, weatherData]) + + // Reset weather data when selectedDate changes + useEffect(() => { + setWeatherData(null) + setError(null) + setExpandedDayIndex(null) + setLocationName('در حال دریافت...') + }, [selectedDate]) + + if (loading) { + return + } + + if (error) { + return ( + { + setWeatherData(null) + loadWeather() + }} + variant="primary" + icon={RefreshCw} + > + تلاش مجدد + + } + /> + ) + } + + if (!weatherData) { + // Trigger load when component is mounted (user clicked on weather tab) + loadWeather() + return null + } + + const isTodayDate = checkIsToday(selectedDate) + + return ( +
+ {/* Location Header */} +
+
+ + {locationName} +
+ + {isTodayDate ? 'پیش‌بینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'} + +
+ + {isTodayDate ? ( + + ) : ( + + )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/index.ts b/src/GreenHome.Web/src/components/daily-report/index.ts new file mode 100644 index 0000000..a4a4213 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/index.ts @@ -0,0 +1,11 @@ +// Daily report UI components only +export { SummaryCard } from './SummaryCard' +export { SummaryTab } from './SummaryTab' +export { LiveSummaryCards } from './LiveSummaryCards' +export { TimeRangeSelector } from './TimeRangeSelector' +export { ChartsTab } from './ChartsTab' +export { WeatherTab } from './WeatherTab' +export { AnalysisTab } from './AnalysisTab' +export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts' +export { PowerOutageCard } from './PowerOutageCard' + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/DataGapMarker.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/DataGapMarker.tsx new file mode 100644 index 0000000..412127b --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/DataGapMarker.tsx @@ -0,0 +1,47 @@ +import { AlertTriangle } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { DataGap } from '@/features/daily-report/utils' +import { minuteToPercent } from '@/lib/utils/time-utils' + +type DataGapMarkerProps = { + gap: DataGap + index: number +} + +export function DataGapMarker({ gap, index }: DataGapMarkerProps) { + const gapStartPercent = minuteToPercent(gap.startMinute) + const gapEndPercent = minuteToPercent(gap.endMinute) + const gapWidth = gapStartPercent - gapEndPercent + const gapHours = Math.floor(gap.durationMinutes / 60) + const gapMins = gap.durationMinutes % 60 + + return ( +
+ {/* Gap area */} +
+ {/* Warning icon in gap */} +
+ +
+
+ + {/* Gap tooltip */} + {gapWidth > 5 && ( +
+ گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))} +
+ )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/DataGapsOverlay.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/DataGapsOverlay.tsx new file mode 100644 index 0000000..761c590 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/DataGapsOverlay.tsx @@ -0,0 +1,19 @@ +import { DataGap } from '@/features/daily-report/utils' +import { DataGapMarker } from './DataGapMarker' + +type DataGapsOverlayProps = { + dataGaps: DataGap[] +} + +export function DataGapsOverlay({ dataGaps }: DataGapsOverlayProps) { + if (dataGaps.length === 0) return null + + return ( + <> + {dataGaps.map((gap, idx) => ( + + ))} + + ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/SunTimeLabel.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/SunTimeLabel.tsx new file mode 100644 index 0000000..16eb2c3 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/SunTimeLabel.tsx @@ -0,0 +1,26 @@ +import { formatSunTimeLabel, minuteToPercent } from '@/lib/utils/time-utils' + +type SunTimeLabelProps = { + hour: number + minute: number + decimal: number + label: 'طلوع' | 'غروب' + position?: 'top' | 'bottom' +} + +export function SunTimeLabel({ hour, minute, decimal, label, position = 'bottom' }: SunTimeLabelProps) { + const percent = minuteToPercent(decimal * 60) + const timeStr = formatSunTimeLabel(label, hour, minute) + + return ( +
+ {timeStr} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/TimeLabel.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/TimeLabel.tsx new file mode 100644 index 0000000..0a13d4f --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/TimeLabel.tsx @@ -0,0 +1,39 @@ +import { memo, useMemo } from 'react' +import { formatTimeLabel, minuteToHours, minuteToPercent } from '@/lib/utils/time-utils' + +type TimeLabelProps = { + minute: number + variant?: 'start' | 'end' + className?: string +} + +const variantStyles = { + start: 'bg-emerald-500', + end: 'bg-rose-500', +} + +function TimeLabelComponent({ minute, variant = 'start', className }: TimeLabelProps) { + const { hour, min, timeStr, percent } = useMemo(() => { + const { hour, minute: min } = minuteToHours(minute) + const timeStr = formatTimeLabel(hour, min) + const percent = minuteToPercent(minute) + return { hour, min, timeStr, percent } + }, [minute]) + + return ( +
+
+ {timeStr} +
+
+ ) +} + +export const TimeLabel = memo(TimeLabelComponent) + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/TimeRangeHeader.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/TimeRangeHeader.tsx new file mode 100644 index 0000000..739be61 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/TimeRangeHeader.tsx @@ -0,0 +1,52 @@ +import { AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { Badge } from '@/components/common' +import { DataGap } from '@/features/daily-report/utils' + +type TimeRangeHeaderProps = { + dataGaps?: DataGap[] + onReset: () => void + onToggle?: () => void + isOpen?: boolean +} + +export function TimeRangeHeader({ dataGaps = [], onReset, onToggle, isOpen = true }: TimeRangeHeaderProps) { + return ( +
+ +
+ + {onToggle && ( + + )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/TimeRangeInfo.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/TimeRangeInfo.tsx new file mode 100644 index 0000000..c906c28 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/TimeRangeInfo.tsx @@ -0,0 +1,30 @@ +import { toPersianDigits } from '@/lib/format/persian-digits' +import { calculateDuration } from '@/lib/utils/time-utils' + +type TimeRangeInfoProps = { + startMinute: number + endMinute: number + totalRecords: number +} + +export function TimeRangeInfo({ startMinute, endMinute, totalRecords }: TimeRangeInfoProps) { + const { hour, minute } = calculateDuration(startMinute, endMinute) + + return ( +
+
+ + {toPersianDigits(hour)}:{toPersianDigits(minute.toString().padStart(2, '0'))} + + بازه انتخاب شده +
+
+ {totalRecords > 0 + ? <>{toPersianDigits(totalRecords)} رکورد + : <>بدون رکورد + } +
+
+ ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/TimelineHourMarkers.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/TimelineHourMarkers.tsx new file mode 100644 index 0000000..9da6266 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/TimelineHourMarkers.tsx @@ -0,0 +1,25 @@ +import { toPersianDigits } from '@/lib/format/persian-digits' + +const HOURS_TO_MARK = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22] + +export function TimelineHourMarkers() { + return ( + <> + {HOURS_TO_MARK.map(hour => ( +
+
+ + {toPersianDigits(hour.toString().padStart(2, '0'))} + +
+
+
+ ))} + + ) +} + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/TimelineSlider.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/TimelineSlider.tsx new file mode 100644 index 0000000..9323916 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/TimelineSlider.tsx @@ -0,0 +1,109 @@ +import { memo, useState, useEffect, useRef } from 'react' +import { useDebounce } from '@/hooks/useDebounce' + +type TimelineSliderProps = { + startMinute: number + endMinute: number + onStartMinuteChange: (minute: number) => void + onEndMinuteChange: (minute: number) => void +} + +function TimelineSliderComponent({ + startMinute, + endMinute, + onStartMinuteChange, + onEndMinuteChange, +}: TimelineSliderProps) { + // State محلی برای input values (برای نمایش فوری) + const [localStartValue, setLocalStartValue] = useState(1439 - startMinute) + const [localEndValue, setLocalEndValue] = useState(1439 - endMinute) + + // Refs برای track کردن تغییرات props و نگه داشتن مقادیر valid + const prevStartMinuteRef = useRef(startMinute) + const prevEndMinuteRef = useRef(endMinute) + const endMinuteRef = useRef(endMinute) + const startMinuteRef = useRef(startMinute) + + // Sync refs with props + useEffect(() => { + endMinuteRef.current = endMinute + startMinuteRef.current = startMinute + }, [startMinute, endMinute]) + + // Sync local state with props (فقط وقتی props از بیرون تغییر می‌کنن) + useEffect(() => { + if (prevStartMinuteRef.current !== startMinute) { + setLocalStartValue(1439 - startMinute) + prevStartMinuteRef.current = startMinute + } + }, [startMinute]) + + useEffect(() => { + if (prevEndMinuteRef.current !== endMinute) { + setLocalEndValue(1439 - endMinute) + prevEndMinuteRef.current = endMinute + } + }, [endMinute]) + + // Debounce values + const debouncedStartValue = useDebounce(localStartValue, 600) + const debouncedEndValue = useDebounce(localEndValue, 600) + + // Update parent when debounced values change (فقط وقتی از user input باشه) + useEffect(() => { + const val = 1439 - debouncedStartValue + const currentEndMinute = endMinuteRef.current + const currentStartMinute = startMinuteRef.current + // فقط اگه مقدار debounced با prop فعلی متفاوت باشه و valid باشه + if (val !== currentStartMinute && val <= currentEndMinute) { + onStartMinuteChange(val) + } + }, [debouncedStartValue, onStartMinuteChange]) + + useEffect(() => { + const val = 1439 - debouncedEndValue + const currentStartMinute = startMinuteRef.current + const currentEndMinute = endMinuteRef.current + // فقط اگه مقدار debounced با prop فعلی متفاوت باشه و valid باشه + if (val !== currentEndMinute && val >= currentStartMinute) { + onEndMinuteChange(val) + } + }, [debouncedEndValue, onEndMinuteChange]) + + const handleStartChange = (e: React.ChangeEvent) => { + const inputValue = Number(e.target.value) + setLocalStartValue(inputValue) + } + + const handleEndChange = (e: React.ChangeEvent) => { + const inputValue = Number(e.target.value) + setLocalEndValue(inputValue) + } + + return ( +
+ {/* Start time slider */} + + + {/* End time slider */} + +
+ ) +} + +export const TimelineSlider = memo(TimelineSliderComponent) + diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/TimelineTrack.tsx b/src/GreenHome.Web/src/components/daily-report/timeline/TimelineTrack.tsx new file mode 100644 index 0000000..b9086f9 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/TimelineTrack.tsx @@ -0,0 +1,73 @@ +import { memo, useMemo } from 'react' +import { DataGap } from '@/features/daily-report/utils' +import { minuteToPercent } from '@/lib/utils/time-utils' +import { DataGapsOverlay } from './DataGapsOverlay' +import { TimelineHourMarkers } from './TimelineHourMarkers' +import { SunTimeLabel } from './SunTimeLabel' + +type SunTimes = { + sunrise: { hour: number; minute: number; decimal: number } + sunset: { hour: number; minute: number; decimal: number } +} + +type TimelineTrackProps = { + sunTimes: SunTimes + dataGaps?: DataGap[] +} + +function TimelineTrackComponent({ sunTimes, dataGaps = [] }: TimelineTrackProps) { + const { sunrisePercent, sunsetPercent } = useMemo(() => ({ + sunrisePercent: minuteToPercent(sunTimes.sunrise.decimal * 60), + sunsetPercent: minuteToPercent(sunTimes.sunset.decimal * 60), + }), [sunTimes.sunrise.decimal, sunTimes.sunset.decimal]) + + return ( +
+ {/* Sunrise dashed line */} +
+ + {/* Sunset dashed line */} +
+ + {/* Sun time labels */} + + + + {/* Night/day background areas */} +
+
+ + {/* Data gaps visualization */} + + + {/* Hour markers */} + +
+ ) +} + +export const TimelineTrack = memo(TimelineTrackComponent) diff --git a/src/GreenHome.Web/src/components/daily-report/timeline/index.ts b/src/GreenHome.Web/src/components/daily-report/timeline/index.ts new file mode 100644 index 0000000..0da3c36 --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/timeline/index.ts @@ -0,0 +1,10 @@ +export { TimeRangeHeader } from './TimeRangeHeader' +export { TimeLabel } from './TimeLabel' +export { SunTimeLabel } from './SunTimeLabel' +export { TimelineHourMarkers } from './TimelineHourMarkers' +export { DataGapMarker } from './DataGapMarker' +export { DataGapsOverlay } from './DataGapsOverlay' +export { TimelineSlider } from './TimelineSlider' +export { TimelineTrack } from './TimelineTrack' +export { TimeRangeInfo } from './TimeRangeInfo' + diff --git a/src/GreenHome.Web/src/components/daily-report/weather/HistoricalWeather.tsx b/src/GreenHome.Web/src/components/daily-report/weather/HistoricalWeather.tsx new file mode 100644 index 0000000..bcfe4ea --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/weather/HistoricalWeather.tsx @@ -0,0 +1,31 @@ +import { WeatherData } from '@/features/weather' +import { TemperatureCard, PrecipitationHistoricalCard, SunlightCard, WindHumidityCard } from './WeatherCards' + +type HistoricalWeatherProps = { + weatherData: WeatherData + selectedDate: string +} + +export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeatherProps) { + return ( +
+ {/* Past Date Header */} +
+
+
+

📅 وضعیت آب و هوای روز

+

{selectedDate}

+
+
+ + {/* Status Grid */} +
+ + + + +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/weather/TodayWeather.tsx b/src/GreenHome.Web/src/components/daily-report/weather/TodayWeather.tsx new file mode 100644 index 0000000..c1c81ba --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/weather/TodayWeather.tsx @@ -0,0 +1,267 @@ +import { Calendar as CalendarIcon, ChevronDown } from 'lucide-react' +import { WeatherData } from '@/features/weather' +import { getWeatherInfo, getPersianDayName } from '@/features/daily-report/utils' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { TemperatureCard, PrecipitationForecastCard, SunlightCard, WindHumidityCard } from './WeatherCards' +import { getGreenhouseAlerts } from '@/features/weather' + +type TodayWeatherProps = { + weatherData: WeatherData + expandedDayIndex: number | null + onDayToggle: (index: number | null) => void +} + +export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: TodayWeatherProps) { + const alerts = getGreenhouseAlerts(weatherData) + + return ( +
+ {/* Greenhouse Alerts */} + {alerts.length > 0 && ( +
+

🌱 هشدارها و توصیه‌های گلخانه

+ {alerts.map((alert, index) => { + const IconComponent = alert.icon + return ( +
+
+ +
+

{alert.title}

+

{alert.description}

+
+
+
+ ) + })} +
+ )} + + {/* Current Weather Header */} +
+
+
+
+

🌡️ الان

+
+ + {toPersianDigits(Math.round(weatherData.current.temperature))} + + درجه +
+
+
+ {(() => { + const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon + return + })()} +

{getWeatherInfo(weatherData.current.weatherCode).description}

+
+
+
+ + {/* Status Grid */} +
+ + + + +
+
+ + {/* Hourly Forecast */} +
+
+

+ 🕐 وضعیت ساعت به ساعت امروز +

+
+ +
+
+
+ {weatherData.hourly.map((hour) => { + const hourNum = new Date(hour.time).getHours() + const isNow = hourNum === new Date().getHours() + const IconComponent = getWeatherInfo(hour.weatherCode).icon + const isHot = hour.temperature > 35 + const isCold = hour.temperature < 10 + + return ( +
+

+ {isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`} +

+ +
+ +
+ +

+ {toPersianDigits(Math.round(hour.temperature))}° +

+ +
+ {toPersianDigits(hour.humidity)}% +
+ + {hour.precipitation > 0 && ( +
+ 🌧️ {toPersianDigits(hour.precipitation.toFixed(1))} +
+ )} +
+ ) + })} +
+
+

👈 برای دیدن ساعت‌های بیشتر به چپ بکشید

+
+
+ + {/* 7-Day Forecast */} +
+

+ + پیش‌بینی ۷ روز آینده +

+
+ {weatherData.daily.map((day, index) => { + const weatherInfo = getWeatherInfo(day.weatherCode) + const IconComponent = weatherInfo.icon + const isToday = index === 0 + const hasFrost = day.tempMin < 5 + const hasHeat = day.tempMax > 35 + const isExpanded = expandedDayIndex === index + + return ( +
+ + + {isExpanded && ( +
+
+
+
+ دما +
+

{toPersianDigits(Math.round(day.tempMax))}°

+

حداکثر

+

{toPersianDigits(Math.round(day.tempMin))}°

+

حداقل

+
+
+
+ بارش +
+

{toPersianDigits(day.precipitationProbability)}%

+

احتمال

+

{toPersianDigits(day.precipitation.toFixed(1))}

+

میلی‌متر

+
+
+
+ ساعات آفتابی +
+

{toPersianDigits(Math.round(day.sunshineDuration / 3600))}

+

ساعت

+

{toPersianDigits(Math.round(day.uvIndexMax))}

+

UV Index

+
+
+
+ باد +
+

{toPersianDigits(Math.round(day.windSpeedMax))}

+

کیلومتر/ساعت

+
+
+
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/daily-report/weather/WeatherCards.tsx b/src/GreenHome.Web/src/components/daily-report/weather/WeatherCards.tsx new file mode 100644 index 0000000..898fe4d --- /dev/null +++ b/src/GreenHome.Web/src/components/daily-report/weather/WeatherCards.tsx @@ -0,0 +1,226 @@ +import { Thermometer, Sun, CloudRain, Wind } from 'lucide-react' +import { WeatherData } from '@/features/weather' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type WeatherCardsProps = { + weatherData: WeatherData + isToday: boolean +} + +/** + * Temperature Card - مشترک بین امروز و گذشته + */ +export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) { + const tempMin = weatherData.daily[0]?.tempMin || 0 + const tempMax = weatherData.daily[0]?.tempMax || 0 + + const isCold = tempMin < 5 + const isHot = tempMax > 35 + + return ( +
+
+
+ +
+
+

{isToday ? 'دمای امروز' : 'دمای روز'}

+

+ {isCold ? '❄️ سرد!' : + isHot ? '🔥 گرم!' : + '✅ مناسب'} +

+
+
+
+
+

🌙 شب

+

{toPersianDigits(Math.round(tempMin))}°

+
+
←
+
+

☀️ روز

+

{toPersianDigits(Math.round(tempMax))}°

+
+
+
+ ) +} + +/** + * Precipitation Card - برای امروز (احتمال بارش) + */ +export function PrecipitationForecastCard({ weatherData }: { weatherData: WeatherData }) { + const probability = weatherData.daily[0]?.precipitationProbability || 0 + + const isHigh = probability > 60 + const isMedium = probability > 30 + + return ( +
+
+
+ {isMedium ? + : + + } +
+
+

بارش

+

+ {isHigh ? '🌧️ باران می‌آید' : + isMedium ? '🌦️ شاید ببارد' : + '☀️ خشک است'} +

+
+
+
+

{toPersianDigits(probability)}%

+

احتمال بارش

+
+
+ ) +} + +/** + * Precipitation Card - برای روزهای گذشته (میزان بارش واقعی) + */ +export function PrecipitationHistoricalCard({ weatherData }: { weatherData: WeatherData }) { + const precipitation = weatherData.daily[0]?.precipitation || 0 + + const isHigh = precipitation > 5 + const hasPrecipitation = precipitation > 0 + + return ( +
+
+
+ {hasPrecipitation ? + : + + } +
+
+

بارش

+

+ {isHigh ? '🌧️ بارش زیاد' : + hasPrecipitation ? '🌦️ بارش کم' : + '☀️ بدون بارش'} +

+
+
+
+

{toPersianDigits(precipitation.toFixed(1))}

+

میلی‌متر بارش

+
+
+ ) +} + +/** + * Sunlight Card - مشترک بین امروز و گذشته + */ +export function SunlightCard({ weatherData }: { weatherData: WeatherData }) { + const sunshineHours = (weatherData.daily[0]?.sunshineDuration || 0) / 3600 + const uvIndex = weatherData.daily[0]?.uvIndexMax || 0 + + return ( +
+
+
+ +
+
+

نور آفتاب

+

+ {sunshineHours > 8 ? '☀️ آفتاب زیاد' : + sunshineHours > 4 ? '🌤️ آفتاب متوسط' : + '☁️ کم‌آفتاب'} +

+
+
+
+
+

{toPersianDigits(Math.round(sunshineHours))}

+

ساعت آفتاب

+
+
+

{toPersianDigits(Math.round(uvIndex))}

+

شاخص UV

+
+
+
+ ) +} + +/** + * Wind & Humidity Card - مشترک بین امروز و گذشته + */ +export function WindHumidityCard({ weatherData }: { weatherData: WeatherData }) { + const windSpeed = weatherData.daily[0]?.windSpeedMax || 0 + const humidity = weatherData.current.humidity + + return ( +
+
+
+ +
+
+

باد و رطوبت

+

+ {windSpeed > 40 ? '💨 باد شدید!' : + windSpeed > 20 ? '🍃 وزش باد' : + '😌 آرام'} +

+
+
+
+
+

{toPersianDigits(Math.round(windSpeed))}

+

کیلومتر/ساعت باد

+
+
+

{toPersianDigits(humidity)}%

+

رطوبت هوا

+
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/DeviceInfoTab.tsx b/src/GreenHome.Web/src/components/device-settings/DeviceInfoTab.tsx new file mode 100644 index 0000000..bd91d90 --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/DeviceInfoTab.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { Save, X } from 'lucide-react' +import { api, DeviceSettingsDto } from '@/lib/api' +import Loading from '@/components/Loading' +import { ErrorMessage, SuccessMessage, EmptyState, Button } from '@/components/common' +import { SettingField } from './SettingField' + +const inputClass = + 'w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm text-gray-700 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all' + +export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; deviceName: string }) { + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [smsInput, setSmsInput] = useState('') + + const loadSettings = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await api.getDeviceSettings(deviceId) + setSettings(data) + } catch { + setError('خطا در بارگذاری اطلاعات دستگاه') + } finally { + setLoading(false) + } + }, [deviceId]) + + useEffect(() => { + loadSettings() + }, [loadSettings]) + + const update = (patch: Partial) => { + setSettings(s => s ? { ...s, ...patch } : s) + } + + const addReceiver = () => { + if (!settings) return + const num = smsInput.replace(/[^\d]/g, '').trim() + if (!/^09\d{9}$/.test(num)) { + window.alert('شماره موبایل معتبر وارد کنید (مثلاً 09123456789)') + return + } + const receivers = settings.smsRecievers.split(',').map(s => s.trim()).filter(Boolean) + if (receivers.includes(num)) { + window.alert('این شماره قبلاً اضافه شده است') + return + } + update({ smsRecievers: [...receivers, num].join(',') }) + setSmsInput('') + } + + const removeReceiver = (index: number) => { + if (!settings) return + const receivers = settings.smsRecievers.split(',').map(s => s.trim()).filter(Boolean) + receivers.splice(index, 1) + update({ smsRecievers: receivers.join(',') }) + } + + const handleSave = async () => { + if (!settings) return + try { + setSaving(true) + setError(null) + setSuccess(null) + if (settings.id === 0) { + await api.createDeviceSettings(settings) + setSuccess('اطلاعات دستگاه ایجاد شد') + } else { + await api.updateDeviceSettings(settings) + setSuccess('اطلاعات دستگاه ذخیره شد') + } + await loadSettings() + } catch { + setError('خطا در ذخیره اطلاعات') + } finally { + setSaving(false) + } + } + + if (loading) return + + if (!settings) { + return ( + + ) + } + + const receivers = settings.smsRecievers.split(',').map(s => s.trim()).filter(Boolean) + + return ( +
+ {error && } + {success && } + +
+

اطلاعات موقعیت و نوع دستگاه

+
+ + update({ province: e.target.value })} /> + + + update({ city: e.target.value })} /> + + + update({ productType: e.target.value })} /> + + + update({ areaSquareMeters: parseFloat(e.target.value) || undefined })} + /> + + + update({ latitude: parseFloat(e.target.value) || undefined })} + /> + + + update({ longitude: parseFloat(e.target.value) || undefined })} + /> + +
+
+ +
+

ارتباطات و اطلاع‌رسانی

+
+ + update({ devicePhoneNumber: e.target.value })} /> + + +
+ setSmsInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + addReceiver() + } + }} + placeholder="09123456789" + inputMode="tel" + /> + +
+ {receivers.length > 0 ? ( +
+ {receivers.map((num, i) => ( + + {num} + + + ))} +
+ ) : ( +

هنوز شماره‌ای اضافه نشده است

+ )} +
+ + update({ minimumSmsIntervalMinutes: parseInt(e.target.value) || 0 })} + /> + + + update({ minimumCallIntervalMinutes: parseInt(e.target.value) || 0 })} + /> + + + update({ uploadIntervalMin: parseInt(e.target.value) || 0 })} + /> + +
+
+ +
+ +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx b/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx new file mode 100644 index 0000000..e48210e --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx @@ -0,0 +1,194 @@ +'use client' + +import { Plus, Plug, ToggleLeft, ToggleRight } from 'lucide-react' +import { DeviceRelayDto, DeviceRelayRuleDto } from '@/lib/api' +import { RELAY_KINDS, RELAY_MODES, RELAY_HINTS, RELAY_KIND_COLORS, RelayKindValue, RelayModeValue } from './relay-constants' +import { SettingField } from './SettingField' +import { RelayRuleCard } from './RelayRuleCard' +import { cn } from '@/lib/utils' + +type RelayCardProps = { + relay: DeviceRelayDto + onChange: (relay: DeviceRelayDto) => void + embedded?: boolean +} + +export function RelayCard({ relay, onChange, embedded = false }: RelayCardProps) { + const KindIcon = RELAY_KINDS.find(k => k.value === relay.relayKind)?.icon ?? Plug + const kindColors = RELAY_KIND_COLORS[relay.relayKind] + + const update = (patch: Partial) => onChange({ ...relay, ...patch }) + + const updateRule = (index: number, updates: Partial) => { + const rules = [...relay.rules] + rules[index] = { ...rules[index], ...updates } + update({ rules }) + } + + const addRule = () => { + const rules = [...relay.rules, { + id: 0, + sensorType: 0 as const, + comparisonType: 0 as const, + value1: 30, + value2: undefined, + action: 0 as const, + timeType: 2 as const, + order: relay.rules.length, + }] + update({ rules }) + } + + const removeRule = (index: number) => { + update({ rules: relay.rules.filter((_, i) => i !== index).map((r, i) => ({ ...r, order: i })) }) + } + + return ( +
+ {/* Header */} +
+
+ + + +
+
+ + رله {relay.relayIndex} + + {relay.name && ( + — {relay.name} + )} +
+

+ {relay.isEnabled ? 'فعال' : 'غیرفعال'} · {RELAY_MODES.find(m => m.value === relay.mode)?.label} +

+
+
+ + {/* Enable toggle */} + +
+ +
+ {/* Name + Kind */} +
+ + update({ name: e.target.value })} + placeholder="مثلاً فن، هیتر" + className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm text-gray-700 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all" + /> + + + + + +
+ + {/* Mode + min on/off */} +
+ + + + + + update({ minOnMinutes: Math.max(0, parseInt(e.target.value) || 0) })} + className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm text-gray-700 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all" + /> + + + + update({ minOffMinutes: Math.max(0, parseInt(e.target.value) || 0) })} + className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm text-gray-700 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all" + /> + +
+ + {/* Rules */} +
+
+
+
+ قوانین روشن/خاموش شدن +
+

{RELAY_HINTS.rules}

+
+ +
+ + {relay.rules.length === 0 ? ( +
+

+ هنوز قانونی تعریف نشده است. برای کنترل خودکار این رله، یک قانون اضافه کنید. +

+
+ ) : ( +
+ {relay.rules.map((rule, index) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx b/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx new file mode 100644 index 0000000..ec65b4f --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx @@ -0,0 +1,103 @@ +'use client' + +import { Trash2 } from 'lucide-react' +import { DeviceRelayRuleDto } from '@/lib/api' +import { SENSOR_TYPES, COMPARISON_TYPES, TIME_TYPES } from '@/components/alert-settings/constants' +import { RELAY_ACTIONS } from './relay-constants' + +type RelayRuleCardProps = { + rule: DeviceRelayRuleDto + index: number + onChange: (index: number, updates: Partial) => void + onRemove: (index: number) => void +} + +const controlClass = + 'h-9 px-2.5 rounded-lg border border-gray-200 bg-gray-50 text-xs text-gray-700 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all cursor-pointer' + +const selectClass = `${controlClass} appearance-none min-w-0` + +export function RelayRuleCard({ rule, index, onChange, onRemove }: RelayRuleCardProps) { + const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax + const unit = SENSOR_TYPES.find(s => s.value === rule.sensorType)?.unit ?? '' + + return ( +
+ وقتی + + + + + + onChange(index, { value1: parseFloat(e.target.value) || 0 })} + className={`${controlClass} w-20 text-center`} + /> + {needsMax && ( + <> + تا + onChange(index, { value2: parseFloat(e.target.value) || 0 })} + className={`${controlClass} w-20 text-center`} + /> + + )} + {unit && {unit}} + + شد، رله + + + + در + + + + +
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx b/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx new file mode 100644 index 0000000..c1b4fce --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { Save } from 'lucide-react' +import { api, DeviceSettingsDto, DeviceRelayDto } from '@/lib/api' +import Loading from '@/components/Loading' +import { ErrorMessage, SuccessMessage, EmptyState, Button } from '@/components/common' +import { RelayCard } from './RelayCard' +import { RELAY_KINDS, RELAY_KIND_COLORS } from './relay-constants' +import { cn } from '@/lib/utils' + +function makeRelay(index: number): DeviceRelayDto { + return { + id: 0, + deviceSettingsId: 0, + relayIndex: index, + name: '', + isEnabled: true, + relayKind: 0, + mode: 0, + minOnMinutes: 0, + minOffMinutes: 0, + rules: [], + } +} + +function normalizeRelays(data: DeviceSettingsDto): DeviceSettingsDto { + const count = Math.max(1, data.relayCount || 1) + const existing = new Map(data.relays?.map(r => [r.relayIndex, r]) ?? []) + const relays: DeviceRelayDto[] = [] + for (let i = 1; i <= count; i++) { + relays.push(existing.get(i) ?? makeRelay(i)) + } + return { ...data, relayCount: count, relays } +} + +export function RelaySettingsTab({ deviceId }: { deviceId: number }) { + const [settings, setSettings] = useState(null) + const [activeRelay, setActiveRelay] = useState(1) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const loadSettings = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await api.getDeviceSettings(deviceId) + setSettings(normalizeRelays(data)) + } catch { + setError('خطا در بارگذاری تنظیمات رله‌ها') + } finally { + setLoading(false) + } + }, [deviceId]) + + useEffect(() => { + loadSettings() + }, [loadSettings]) + + const handleSave = async () => { + if (!settings) return + try { + setSaving(true) + setError(null) + setSuccess(null) + if (settings.id === 0) { + await api.createDeviceSettings(settings) + setSuccess('تنظیمات رله‌ها ایجاد شد') + } else { + await api.updateDeviceSettings(settings) + setSuccess('تنظیمات رله‌ها ذخیره شد') + } + await loadSettings() + } catch { + setError('خطا در ذخیره تنظیمات') + } finally { + setSaving(false) + } + } + + const updateRelay = (relay: DeviceRelayDto) => { + setSettings(s => s + ? { ...s, relays: s.relays.map(r => r.relayIndex === relay.relayIndex ? relay : r) } + : s + ) + } + + if (loading) return + + if (!settings) { + return ( + + ) + } + + const active = settings.relays.find(r => r.relayIndex === activeRelay) ?? settings.relays[0] + + return ( +
+ {error && } + {success && } + + {/* Relay selector + active relay (unified card) */} +
+
+ {settings.relays.map(relay => { + const isActive = relay.relayIndex === active.relayIndex + const kind = RELAY_KINDS.find(k => k.value === relay.relayKind) ?? RELAY_KINDS[0] + const colors = RELAY_KIND_COLORS[relay.relayKind] + const KindIcon = kind.icon + return ( + + ) + })} +
+ + +
+ +
+ +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/SettingField.tsx b/src/GreenHome.Web/src/components/device-settings/SettingField.tsx new file mode 100644 index 0000000..6ca818c --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/SettingField.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +type SettingFieldProps = { + label: string + hint?: string + htmlFor?: string + children: ReactNode + className?: string +} + +export function SettingField({ label, hint, htmlFor, children, className }: SettingFieldProps) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/relay-constants.ts b/src/GreenHome.Web/src/components/device-settings/relay-constants.ts new file mode 100644 index 0000000..a8da6c4 --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/relay-constants.ts @@ -0,0 +1,56 @@ +import { + Plug, + Fan, + Flame, + Droplets, + CloudFog, + Lightbulb, + Droplet, + type LucideIcon, +} from 'lucide-react' + +export type RelayKindValue = 0 | 1 | 2 | 3 | 4 | 5 | 6 +export type RelayModeValue = 0 | 1 +export type RelayActionValue = 0 | 1 + +export const RELAY_KINDS: { value: RelayKindValue; label: string; icon: LucideIcon; hint: string }[] = [ + { value: 0, label: 'سایر', icon: Plug, hint: 'برای تجهیزات متفرقه یا نامشخص' }, + { value: 1, label: 'فن', icon: Fan, hint: 'برای تهویه و خنک‌سازی گلخانه' }, + { value: 2, label: 'هیتر', icon: Flame, hint: 'برای گرمایش گلخانه' }, + { value: 3, label: 'پمپ آب', icon: Droplets, hint: 'برای آبیاری' }, + { value: 4, label: 'مه‌پاش', icon: CloudFog, hint: 'برای افزایش رطوبت هوا' }, + { value: 5, label: 'روشنایی', icon: Lightbulb, hint: 'برای نور تکمیلی' }, + { value: 6, label: 'شیر برقی', icon: Droplet, hint: 'برای کنترل جریان آب یا گاز' }, +] + +export const RELAY_KIND_COLORS: Record = { + 0: { iconBg: 'bg-gray-100', iconText: 'text-gray-500', solid: 'bg-gradient-to-br from-gray-500 to-gray-600', activeBorder: 'border-gray-400', activeBg: 'bg-gray-50', text: 'text-gray-700' }, + 1: { iconBg: 'bg-blue-100', iconText: 'text-blue-600', solid: 'bg-gradient-to-br from-blue-500 to-blue-600', activeBorder: 'border-blue-500', activeBg: 'bg-blue-50', text: 'text-blue-700' }, + 2: { iconBg: 'bg-red-100', iconText: 'text-red-600', solid: 'bg-gradient-to-br from-red-500 to-red-600', activeBorder: 'border-red-500', activeBg: 'bg-red-50', text: 'text-red-700' }, + 3: { iconBg: 'bg-cyan-100', iconText: 'text-cyan-600', solid: 'bg-gradient-to-br from-cyan-500 to-cyan-600', activeBorder: 'border-cyan-500', activeBg: 'bg-cyan-50', text: 'text-cyan-700' }, + 4: { iconBg: 'bg-sky-100', iconText: 'text-sky-600', solid: 'bg-gradient-to-br from-sky-500 to-sky-600', activeBorder: 'border-sky-500', activeBg: 'bg-sky-50', text: 'text-sky-700' }, + 5: { iconBg: 'bg-amber-100', iconText: 'text-amber-600', solid: 'bg-gradient-to-br from-amber-500 to-orange-500', activeBorder: 'border-amber-500', activeBg: 'bg-amber-50', text: 'text-amber-700' }, + 6: { iconBg: 'bg-slate-100', iconText: 'text-slate-600', solid: 'bg-gradient-to-br from-slate-500 to-slate-600', activeBorder: 'border-slate-500', activeBg: 'bg-slate-50', text: 'text-slate-700' }, +} + +export const RELAY_MODES: { value: RelayModeValue; label: string; hint: string }[] = [ + { value: 0, label: 'خودکار', hint: 'رله بر اساس قوانین تعریف‌شده روشن و خاموش می‌شود' }, + { value: 1, label: 'دستی', hint: 'قوانین خودکار نادیده گرفته می‌شوند و فقط کنترل دستی انجام می‌شود' }, +] + +export const RELAY_ACTIONS: { value: RelayActionValue; label: string; hint: string }[] = [ + { value: 0, label: 'روشن شود', hint: 'وقتی شرط برقرار شد، رله روشن می‌شود' }, + { value: 1, label: 'خاموش شود', hint: 'وقتی شرط برقرار شد، رله خاموش می‌شود' }, +] + +export const RELAY_HINTS = { + name: 'یک نام دلخواه برای این رله بگذارید تا در گزارش‌ها و هشدارها به‌جای «رله ۱» همین نام نمایش داده شود.', + enabled: 'اگر این گزینه خاموش باشد، رله در هیچ شرایطی فعال نمی‌شود.', + kind: 'نوع تجهیز متصل به رله را مشخص کنید تا آیکون و پیشنهادهای مناسب نمایش داده شود.', + mode: 'در حالت خودکار، رله طبق قوانین عمل می‌کند. در حالت دستی، فقط با کنترل دستی روشن یا خاموش می‌شود.', + minOn: 'حداقل مدت‌زمانی که رله باید روشن بماند (به دقیقه). از قطع و وصل شدن مکرر جلوگیری می‌کند.', + minOff: 'حداقل مدت‌زمانی که رله باید خاموش بماند (به دقیقه). از تجهیزاتی مثل کمپرسور محافظت می‌کند.', + rules: 'شرایطی را تعریف کنید که با برقرار شدن آن، رله روشن یا خاموش شود. می‌توانید چند قانون اضافه کنید.', + action: 'مشخص کنید با برقرار شدن شرط، رله روشن شود یا خاموش.', + timeType: 'این قانون فقط در روز، فقط در شب، یا همیشه بررسی شود.', +} diff --git a/src/GreenHome.Web/src/components/forms/Checkbox.tsx b/src/GreenHome.Web/src/components/forms/Checkbox.tsx new file mode 100644 index 0000000..50dd530 --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/Checkbox.tsx @@ -0,0 +1,41 @@ +import { InputHTMLAttributes, useId } from 'react' +import { cn } from '@/lib/utils' + +type CheckboxProps = Omit, 'type'> & { + label?: string + error?: string +} + +export function Checkbox({ + label, + error, + className, + id, + ...props +}: CheckboxProps) { + const generatedId = useId() + const checkboxId = id || `checkbox-${generatedId}` + const errorId = `${checkboxId}-error` + + return ( +
+ + {label && ( + + )} + {error &&

{error}

} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/forms/CodeInput.tsx b/src/GreenHome.Web/src/components/forms/CodeInput.tsx new file mode 100644 index 0000000..ac1d8de --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/CodeInput.tsx @@ -0,0 +1,80 @@ +'use client' + +import { useRef, KeyboardEvent, ClipboardEvent } from 'react' +import { cn } from '@/lib/utils' + +type CodeInputProps = { + length?: number + value: string[] + onChange: (value: string[]) => void + onComplete?: (code: string) => void + disabled?: boolean + className?: string +} + +export function CodeInput({ + length = 4, + value, + onChange, + onComplete, + disabled = false, + className +}: CodeInputProps) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + const handleCodeChange = (index: number, inputValue: string) => { + if (!/^\d*$/.test(inputValue)) return + + const newCode = [...value] + newCode[index] = inputValue.slice(-1) + onChange(newCode) + + // Auto-focus next input + if (inputValue && index < length - 1) { + inputRefs.current[index + 1]?.focus() + } + + // Auto-submit when all fields are filled + if (newCode.every(c => c !== '') && newCode.join('').length === length) { + onComplete?.(newCode.join('')) + } + } + + const handleKeyDown = (index: number, e: KeyboardEvent) => { + if (e.key === 'Backspace' && !value[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + } + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault() + const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length) + if (pastedData.length === length) { + const newCode = pastedData.split('') + onChange(newCode) + inputRefs.current[length - 1]?.focus() + onComplete?.(pastedData) + } + } + + return ( +
+ {Array.from({ length }, (_, index) => ( + { inputRefs.current[index] = el }} + type="text" + inputMode="numeric" + maxLength={1} + value={value[index] || ''} + onChange={(e) => handleCodeChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={index === 0 ? handlePaste : undefined} + className="w-14 h-14 text-center text-2xl font-bold rounded-lg border border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150" + disabled={disabled} + /> + ))} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/forms/FormInput.tsx b/src/GreenHome.Web/src/components/forms/FormInput.tsx new file mode 100644 index 0000000..23e176b --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/FormInput.tsx @@ -0,0 +1,75 @@ +import { InputHTMLAttributes, LabelHTMLAttributes, useId } from 'react' +import { cn } from '@/lib/utils' + +type FormInputProps = InputHTMLAttributes & { + label?: string + labelProps?: LabelHTMLAttributes + error?: string + helperText?: string + leftAddon?: React.ReactNode + rightAddon?: React.ReactNode +} + +export function FormInput({ + label, + labelProps, + error, + helperText, + leftAddon, + rightAddon, + className, + id, + ...props +}: FormInputProps) { + const generatedId = useId() + const inputId = id || `input-${generatedId}` + const errorId = `${inputId}-error` + const helperId = `${inputId}-helper` + + return ( +
+ {label && ( + + )} +
+ {leftAddon && ( +
+ {leftAddon} +
+ )} + + {rightAddon && ( +
+ {rightAddon} +
+ )} +
+ {error && ( +

{error}

+ )} + {helperText && !error && ( +

{helperText}

+ )} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/forms/MobileInput.tsx b/src/GreenHome.Web/src/components/forms/MobileInput.tsx new file mode 100644 index 0000000..c19046b --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/MobileInput.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useRef, useEffect, useCallback, InputHTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +type MobileInputProps = Omit, 'onInput' | 'value'> & { + value: string + onValueChange: (value: string) => void +} + +export function MobileInput({ + value, + onValueChange, + className, + ...props +}: MobileInputProps) { + const inputRef = useRef(null) + + const handleInputChange = useCallback((e: React.FormEvent) => { + const inputValue = e.currentTarget.value + const digitsOnly = inputValue.replace(/\D/g, '') + onValueChange(digitsOnly) + }, [onValueChange]) + + useEffect(() => { + const checkAutoFill = () => { + setTimeout(() => { + if (inputRef.current) { + const filledMobile = inputRef.current.value + if (filledMobile && filledMobile !== value) { + handleInputChange({ currentTarget: { value: filledMobile } } as React.FormEvent) + } + } + }, 100) + } + + checkAutoFill() + window.addEventListener('load', checkAutoFill) + + return () => { + window.removeEventListener('load', checkAutoFill) + } + }, [value, handleInputChange]) + + return ( + + ) +} + diff --git a/src/GreenHome.Web/src/components/forms/SearchInput.tsx b/src/GreenHome.Web/src/components/forms/SearchInput.tsx new file mode 100644 index 0000000..6b99c80 --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/SearchInput.tsx @@ -0,0 +1,73 @@ +'use client' + +import { FormEvent, InputHTMLAttributes } from 'react' +import { Search, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/common/Button' + +type SearchInputProps = Omit, 'onSubmit'> & { + value: string + onValueChange: (value: string) => void + onSubmit?: (value: string) => void + onClear?: () => void + placeholder?: string + showClearButton?: boolean + showSearchButton?: boolean +} + +export function SearchInput({ + value, + onValueChange, + onSubmit, + onClear, + placeholder = 'جستجو...', + showClearButton = true, + showSearchButton = true, + className, + ...props +}: SearchInputProps) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + onSubmit?.(value) + } + + const handleClear = () => { + onValueChange('') + onClear?.() + } + + return ( +
+
+ + onValueChange(e.target.value)} + placeholder={placeholder} + className="w-full pr-12 pl-4 py-3 rounded-lg border border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150" + {...props} + /> + {showClearButton && value && ( + + )} +
+ {showSearchButton && ( + + )} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/forms/Select.tsx b/src/GreenHome.Web/src/components/forms/Select.tsx new file mode 100644 index 0000000..ce9e0cc --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/Select.tsx @@ -0,0 +1,54 @@ +import { SelectHTMLAttributes, useId } from 'react' +import { ChevronDown } from 'lucide-react' +import { cn } from '@/lib/utils' + +type SelectProps = SelectHTMLAttributes & { + label?: string + error?: string + helperText?: string +} + +export function Select({ + label, + error, + helperText, + className, + id, + children, + ...props +}: SelectProps) { + const generatedId = useId() + const selectId = id || `select-${generatedId}` + const errorId = `${selectId}-error` + const helperId = `${selectId}-helper` + + return ( +
+ {label && ( + + )} +
+ + +
+ {error &&

{error}

} + {helperText && !error &&

{helperText}

} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/forms/index.ts b/src/GreenHome.Web/src/components/forms/index.ts new file mode 100644 index 0000000..541d8fa --- /dev/null +++ b/src/GreenHome.Web/src/components/forms/index.ts @@ -0,0 +1,6 @@ +export { MobileInput } from './MobileInput' +export { CodeInput } from './CodeInput' +export { SearchInput } from './SearchInput' +export { FormInput } from './FormInput' +export { Select } from './Select' +export { Checkbox } from './Checkbox' diff --git a/src/GreenHome.Web/src/components/index.ts b/src/GreenHome.Web/src/components/index.ts new file mode 100644 index 0000000..d6f2d49 --- /dev/null +++ b/src/GreenHome.Web/src/components/index.ts @@ -0,0 +1,29 @@ +// Common components +export * from './common' + +// Form components +export * from './forms' + +// Navigation components +export * from './navigation' + +// Card components +export * from './cards' + +// Alert components +export * from './alerts' + +// Settings components +export * from './settings' + +// Calendar components +export * from './calendar' + +// Utils components +export * from './utils' + +// Other components +export { default as Loading } from './Loading' +export { Skeleton, CardSkeleton } from './Loading' +export { LineChart, Panel } from './Charts' + diff --git a/src/GreenHome.Web/src/components/layout/AppShell.tsx b/src/GreenHome.Web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..0440b61 --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/AppShell.tsx @@ -0,0 +1,46 @@ +"use client" + +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { TopHeader } from './TopHeader' +import { BottomNav } from './BottomNav' + +type AppShellProps = { + children: ReactNode + pageTitle: string + pageIcon: LucideIcon + pageInfo?: string + iconGradient?: string + hideBottomNav?: boolean +} + +export function AppShell({ + children, + pageTitle, + pageIcon, + pageInfo, + iconGradient, + hideBottomNav = false +}: AppShellProps) { + return ( +
+ + +
+ {children} +
+ + {!hideBottomNav && } + + {/* Spacer for bottom nav */} + {!hideBottomNav && ( +
+ )} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/layout/AuthGuard.tsx b/src/GreenHome.Web/src/components/layout/AuthGuard.tsx new file mode 100644 index 0000000..28435b0 --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/AuthGuard.tsx @@ -0,0 +1,54 @@ +"use client" + +import { useEffect, useState, ReactNode } from 'react' +import { usePathname, useRouter } from '@/lib/router' +import Loading from '@/components/Loading' + +const PUBLIC_PATHS = ['/', '/login', '/verify-code'] + +function isAuthenticated(): boolean { + if (typeof window === 'undefined') return false + return Boolean(localStorage.getItem('authToken') && localStorage.getItem('user')) +} + +function isPublicPath(pathname: string): boolean { + return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)) +} + +export function AuthGuard({ children }: { children: ReactNode }) { + const pathname = usePathname() + const router = useRouter() + // Public pages render immediately; protected pages show a loader until validated. + const [ready, setReady] = useState(() => isPublicPath(pathname)) + const [authed, setAuthed] = useState(false) + + useEffect(() => { + const isPublic = isPublicPath(pathname) + + if (isPublic) { + setAuthed(true) + setReady(true) + return + } + + if (!isAuthenticated()) { + setAuthed(false) + setReady(true) + router.replace('/login') + return + } + + setAuthed(true) + setReady(true) + }, [pathname, router]) + + if (!ready) { + return + } + + if (!authed) { + return + } + + return <>{children} +} diff --git a/src/GreenHome.Web/src/components/layout/BottomNav.tsx b/src/GreenHome.Web/src/components/layout/BottomNav.tsx new file mode 100644 index 0000000..cfa23bd --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/BottomNav.tsx @@ -0,0 +1,101 @@ +"use client" + +import { usePathname, useRouter } from '@/lib/router' +import { User, MonitorCog, BarChart3, CloudSun } from 'lucide-react' +import { useDevice } from './DeviceProvider' +import { cn } from '@/lib/utils' + +type NavItem = { + label: string + icon: typeof User + route: string + matchPatterns: string[] // Routes that should highlight this tab +} + +const navItems: NavItem[] = [ + { + label: 'پروفایل', + icon: User, + route: '/profile', + matchPatterns: ['/profile'] + }, + { + label: 'دستگاه', + icon: MonitorCog, + route: '/device', + matchPatterns: ['/device'] + }, + { + label: 'گزارش', + icon: BarChart3, + route: '/daily-report', + matchPatterns: ['/daily-report', '/calendar', '/day-details'] + }, + { + label: 'آب و هوا', + icon: CloudSun, + route: '/weather', + matchPatterns: ['/weather'] + } +] + +export function BottomNav() { + const pathname = usePathname() + const router = useRouter() + const { deviceId } = useDevice() + + const getActiveIndex = () => { + const idx = navItems.findIndex(item => + item.matchPatterns.some(pattern => pathname.startsWith(pattern)) + ) + return idx >= 0 ? idx : 2 // Default to Report (index 2) if no match + } + + const activeIndex = getActiveIndex() + + const handleNavigate = (item: NavItem) => { + const params = deviceId ? `?deviceId=${deviceId}` : '' + router.push(`${item.route}${params}`) + } + + return ( + + ) +} diff --git a/src/GreenHome.Web/src/components/layout/DeviceProvider.tsx b/src/GreenHome.Web/src/components/layout/DeviceProvider.tsx new file mode 100644 index 0000000..6e743db --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/DeviceProvider.tsx @@ -0,0 +1,51 @@ +"use client" + +import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react' + +const STORAGE_KEY_DEVICE_ID = 'selectedDeviceId' +const STORAGE_KEY_DEVICE_NAME = 'selectedDeviceName' + +type DeviceContextType = { + deviceId: number + deviceName: string + setDevice: (id: number, name: string) => void +} + +const DeviceContext = createContext({ + deviceId: 0, + deviceName: '', + setDevice: () => {} +}) + +export function DeviceProvider({ children }: { children: ReactNode }) { + const [deviceId, setDeviceId] = useState(0) + const [deviceName, setDeviceName] = useState('') + + // Initialize from localStorage on mount + useEffect(() => { + const savedId = localStorage.getItem(STORAGE_KEY_DEVICE_ID) + const savedName = localStorage.getItem(STORAGE_KEY_DEVICE_NAME) + if (savedId) { + setDeviceId(Number(savedId)) + setDeviceName(savedName || '') + } + }, []) + + const setDevice = useCallback((id: number, name: string) => { + setDeviceId(id) + setDeviceName(name) + // Persist to localStorage + localStorage.setItem(STORAGE_KEY_DEVICE_ID, String(id)) + localStorage.setItem(STORAGE_KEY_DEVICE_NAME, name) + }, []) + + return ( + + {children} + + ) +} + +export function useDevice() { + return useContext(DeviceContext) +} diff --git a/src/GreenHome.Web/src/components/layout/TopHeader.tsx b/src/GreenHome.Web/src/components/layout/TopHeader.tsx new file mode 100644 index 0000000..7a03290 --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/TopHeader.tsx @@ -0,0 +1,57 @@ +"use client" + +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useDevice } from './DeviceProvider' + +type TopHeaderProps = { + pageTitle: string + pageIcon: LucideIcon + pageInfo?: string + iconGradient?: string + className?: string +} + +export function TopHeader({ + pageTitle, + pageIcon: Icon, + pageInfo, + iconGradient = 'from-indigo-500 to-purple-600', + className +}: TopHeaderProps) { + const { deviceName } = useDevice() + + return ( +
+
+ {/* Right: Device Name */} +
+ {deviceName || 'دستگاه'} +
+ + {/* Center: Icon + Title + PageInfo */} +
+
+ +
+
+

+ {pageTitle} +

+ {pageInfo && ( + + {pageInfo} + + )} +
+
+ + {/* Left: spacer for balance (empty) */} +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/layout/index.ts b/src/GreenHome.Web/src/components/layout/index.ts new file mode 100644 index 0000000..4061357 --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/index.ts @@ -0,0 +1,5 @@ +export { AppShell } from './AppShell' +export { BottomNav } from './BottomNav' +export { TopHeader } from './TopHeader' +export { DeviceProvider, useDevice } from './DeviceProvider' +export { AuthGuard } from './AuthGuard' diff --git a/src/GreenHome.Web/src/components/navigation/DateNavigation.tsx b/src/GreenHome.Web/src/components/navigation/DateNavigation.tsx new file mode 100644 index 0000000..59711f6 --- /dev/null +++ b/src/GreenHome.Web/src/components/navigation/DateNavigation.tsx @@ -0,0 +1,249 @@ +import { useMemo, useRef, useState } from 'react' +import { ChevronRight, ChevronLeft, ChevronDown, Calendar as CalendarIcon } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { + parsePersianDate, + persianToGregorian, + getPersianMonthDays, + getPersianMonthStartWeekday, + formatPersianDateString, + getCurrentPersianYear, + getCurrentPersianMonth, + getCurrentPersianDay, +} from '@/lib/date/persian-date' + +const PERSIAN_WEEKDAYS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'] +const PERSIAN_MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] +const WEEKDAY_SHORT = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + +type DateNavigationProps = { + selectedDate: string + onPrevious: () => void + onNext: () => void + onCalendar: () => void + onSelectDate: (dateStr: string) => void + isToday: boolean + className?: string +} + +function formatHumanDate(dateStr: string) { + const parsed = parsePersianDate(dateStr) + if (!parsed) return null + const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day) + const weekday = PERSIAN_WEEKDAYS[(gregorian.getDay() + 1) % 7] + const monthName = PERSIAN_MONTHS[parsed.month - 1] + return { + weekday, + label: `${weekday} ${toPersianDigits(String(parsed.day))} ${monthName}`, + year: parsed.year, + month: parsed.month, + day: parsed.day, + } +} + +export function DateNavigation({ + selectedDate, + onPrevious, + onNext, + onCalendar, + onSelectDate, + isToday, + className, +}: DateNavigationProps) { + const [open, setOpen] = useState(false) + const [viewYear, setViewYear] = useState(() => parsePersianDate(selectedDate)?.year ?? getCurrentPersianYear()) + const [viewMonth, setViewMonth] = useState(() => parsePersianDate(selectedDate)?.month ?? getCurrentPersianMonth()) + const rootRef = useRef(null) + + const display = useMemo(() => formatHumanDate(selectedDate), [selectedDate]) + const selectedParsed = useMemo(() => parsePersianDate(selectedDate), [selectedDate]) + + const today = useMemo( + () => ({ + year: getCurrentPersianYear(), + month: getCurrentPersianMonth(), + day: getCurrentPersianDay(), + }), + [], + ) + + const isFutureDay = (year: number, month: number, day: number) => + year > today.year || + (year === today.year && (month > today.month || (month === today.month && day > today.day))) + + const openCalendar = () => { + const parsed = parsePersianDate(selectedDate) ?? { year: getCurrentPersianYear(), month: getCurrentPersianMonth() } + setViewYear(parsed.year) + setViewMonth(parsed.month) + setOpen(true) + } + + const goToToday = () => { + const todayStr = formatPersianDateString({ year: today.year, month: today.month, day: today.day }) + onSelectDate(todayStr) + setOpen(false) + } + + const changeMonth = (delta: number) => { + let month = viewMonth + delta + let year = viewYear + if (month < 1) { + month = 12 + year -= 1 + } else if (month > 12) { + month = 1 + year += 1 + } + setViewYear(year) + setViewMonth(month) + } + + const cells = useMemo(() => { + const leading = getPersianMonthStartWeekday(viewYear, viewMonth) + const daysInMonth = getPersianMonthDays(viewYear, viewMonth) + const items: (number | null)[] = [] + for (let i = 0; i < leading; i++) items.push(null) + for (let d = 1; d <= daysInMonth; d++) items.push(d) + return items + }, [viewYear, viewMonth]) + + return ( +
+
+ {/* Unified pill navigation */} +
+ + + + + +
+ + {/* Mini calendar popover */} + {open && ( + <> +
setOpen(false)} /> +
+
+ + + {PERSIAN_MONTHS[viewMonth - 1]} {toPersianDigits(String(viewYear))} + + +
+ +
+ {WEEKDAY_SHORT.map(day => ( + + {day} + + ))} +
+ +
+ {cells.map((day, index) => + day === null ? ( + + ) : ( + + ) + )} +
+ +
+ + +
+
+ + )} +
+ + {/* Quick jump to today */} + {!isToday && ( + + )} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/navigation/Pagination.tsx b/src/GreenHome.Web/src/components/navigation/Pagination.tsx new file mode 100644 index 0000000..fe30b60 --- /dev/null +++ b/src/GreenHome.Web/src/components/navigation/Pagination.tsx @@ -0,0 +1,88 @@ +import { ChevronRight, ChevronLeft } from 'lucide-react' +import { cn } from '@/lib/utils' + +type PaginationProps = { + currentPage: number + totalPages: number + onPageChange: (page: number) => void + className?: string + maxVisiblePages?: number +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + className, + maxVisiblePages = 5 +}: PaginationProps) { + if (totalPages <= 1) return null + + const getVisiblePages = () => { + if (totalPages <= maxVisiblePages) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + if (currentPage <= 3) { + return Array.from({ length: maxVisiblePages }, (_, i) => i + 1) + } + + if (currentPage >= totalPages - 2) { + return Array.from({ length: maxVisiblePages }, (_, i) => totalPages - maxVisiblePages + i + 1) + } + + return Array.from({ length: maxVisiblePages }, (_, i) => currentPage - 2 + i) + } + + const visiblePages = getVisiblePages() + + return ( +
+ + +
+ {visiblePages.map((pageNum) => ( + + ))} +
+ + +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/navigation/index.ts b/src/GreenHome.Web/src/components/navigation/index.ts new file mode 100644 index 0000000..05a9dd6 --- /dev/null +++ b/src/GreenHome.Web/src/components/navigation/index.ts @@ -0,0 +1,3 @@ +export { DateNavigation } from './DateNavigation' +export { Pagination } from './Pagination' + diff --git a/src/GreenHome.Web/src/components/settings/SettingsInputGroup.tsx b/src/GreenHome.Web/src/components/settings/SettingsInputGroup.tsx new file mode 100644 index 0000000..a3e2f87 --- /dev/null +++ b/src/GreenHome.Web/src/components/settings/SettingsInputGroup.tsx @@ -0,0 +1,56 @@ +import { FormInput } from '@/components/forms/FormInput' + +type SettingsInputGroupProps = { + label: string + minLabel: string + maxLabel: string + minValue: number + maxValue: number + onMinChange: (value: number) => void + onMaxChange: (value: number) => void + minUnit?: string + maxUnit?: string + minStep?: number + maxStep?: number + className?: string +} + +export function SettingsInputGroup({ + label, + minLabel, + maxLabel, + minValue, + maxValue, + onMinChange, + onMaxChange, + minUnit, + maxUnit, + minStep = 0.1, + maxStep = 0.1, + className +}: SettingsInputGroupProps) { + return ( +
+ +
+ onMinChange(parseFloat(e.target.value) || 0)} + rightAddon={minUnit && {minUnit}} + /> + onMaxChange(parseFloat(e.target.value) || 0)} + rightAddon={maxUnit && {maxUnit}} + /> +
+
+ ) +} + diff --git a/src/GreenHome.Web/src/components/settings/SettingsSection.tsx b/src/GreenHome.Web/src/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..9cf269d --- /dev/null +++ b/src/GreenHome.Web/src/components/settings/SettingsSection.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type SettingsSectionProps = { + icon: LucideIcon + title: string + children: ReactNode + iconGradient?: string + className?: string +} + +export function SettingsSection({ + icon: Icon, + title, + children, + iconGradient = 'from-gray-500 to-gray-600', + className +}: SettingsSectionProps) { + return ( +
+
+
+ +
+

+ {title} +

+
+ {children} +
+ ) +} + diff --git a/src/GreenHome.Web/src/components/settings/index.ts b/src/GreenHome.Web/src/components/settings/index.ts new file mode 100644 index 0000000..b8d85c6 --- /dev/null +++ b/src/GreenHome.Web/src/components/settings/index.ts @@ -0,0 +1,3 @@ +export { SettingsInputGroup } from './SettingsInputGroup' +export { SettingsSection } from './SettingsSection' + diff --git a/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx b/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx new file mode 100644 index 0000000..2f2aad2 --- /dev/null +++ b/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Modal } from '@/components/common/Modal' +import { Button } from '@/components/common/Button' + +type ConfirmDialogOptions = { + title?: string + message: string + confirmText?: string + cancelText?: string + variant?: 'default' | 'danger' +} + +type ConfirmDialogHook = { + confirm: (options: ConfirmDialogOptions) => Promise +} + +export function useConfirmDialog(): ConfirmDialogHook { + const [isOpen, setIsOpen] = useState(false) + const [options, setOptions] = useState({ message: '' }) + const [resolve, setResolve] = useState<((value: boolean) => void) | null>(null) + + const confirm = useCallback((opts: ConfirmDialogOptions): Promise => { + return new Promise((res) => { + setOptions(opts) + setIsOpen(true) + setResolve(() => res) + }) + }, []) + + const handleConfirm = () => { + setIsOpen(false) + resolve?.(true) + setResolve(null) + } + + const handleCancel = () => { + setIsOpen(false) + resolve?.(false) + setResolve(null) + } + + const ConfirmDialogComponent = () => ( + +
+

{options.message}

+
+ + +
+
+
+ ) + + return { + confirm, + ConfirmDialog: ConfirmDialogComponent + } as ConfirmDialogHook & { ConfirmDialog: React.ComponentType } +} + +// Simple confirm function for direct use (fallback to window.confirm) +export async function confirmDialog(options: ConfirmDialogOptions): Promise { + return new Promise((resolve) => { + const result = window.confirm(options.message) + resolve(result) + }) +} + diff --git a/src/GreenHome.Web/src/components/utils/ResendButton.tsx b/src/GreenHome.Web/src/components/utils/ResendButton.tsx new file mode 100644 index 0000000..5ed323e --- /dev/null +++ b/src/GreenHome.Web/src/components/utils/ResendButton.tsx @@ -0,0 +1,49 @@ +'use client' + +import { RotateCcw } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ResendButtonProps = { + canResend: boolean + cooldown: number + onResend: () => void + loading?: boolean + className?: string +} + +export function ResendButton({ + canResend, + cooldown, + onResend, + loading = false, + className +}: ResendButtonProps) { + const formatCooldown = (seconds: number) => { + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${minutes}:${String(secs).padStart(2, '0')}` + } + + return ( + + ) +} + diff --git a/src/GreenHome.Web/src/components/utils/index.ts b/src/GreenHome.Web/src/components/utils/index.ts new file mode 100644 index 0000000..5510558 --- /dev/null +++ b/src/GreenHome.Web/src/components/utils/index.ts @@ -0,0 +1,3 @@ +export { ResendButton } from './ResendButton' +export { useConfirmDialog, confirmDialog } from './ConfirmDialog' + diff --git a/src/GreenHome.Web/src/features/daily-report/chart-config.ts b/src/GreenHome.Web/src/features/daily-report/chart-config.ts new file mode 100644 index 0000000..9c08aff --- /dev/null +++ b/src/GreenHome.Web/src/features/daily-report/chart-config.ts @@ -0,0 +1,54 @@ +import { ChartConfig } from './types' +import { NormalizedTelemetry } from './types' + +export const BASE_CHART_CONFIGS: ChartConfig[] = [ + { + key: 'temp', + title: 'دما', + seriesLabel: 'دما (°C)', + color: '#ef4444', + bgColor: '#fee2e2', + getValue: (t: NormalizedTelemetry) => t.temp, + yAxisMin: 0, + }, + { + key: 'hum', + title: 'رطوبت', + seriesLabel: 'رطوبت (%)', + color: '#3b82f6', + bgColor: '#dbeafe', + getValue: (t: NormalizedTelemetry) => t.hum, + yAxisMin: 0, + yAxisMax: 100, + }, + { + key: 'gas', + title: 'گاز CO', + seriesLabel: 'CO (ppm)', + color: '#f59e0b', + bgColor: '#fef3c7', + getValue: (t: NormalizedTelemetry) => t.gas, + yAxisMin: 0, + yAxisMax: 100, + }, + { + key: 'soil', + title: 'رطوبت خاک', + seriesLabel: 'رطوبت خاک (%)', + color: '#16a34a', + bgColor: '#dcfce7', + getValue: (t: NormalizedTelemetry) => t.soil, + yAxisMin: 0, + yAxisMax: 100, + }, + { + key: 'lux', + title: 'نور', + seriesLabel: 'Lux', + color: '#a855f7', + bgColor: '#f3e8ff', + getValue: (t: NormalizedTelemetry) => t.lux, + yAxisMin: 0, + }, +] + diff --git a/src/GreenHome.Web/src/features/daily-report/hooks/useTelemetryCharts.ts b/src/GreenHome.Web/src/features/daily-report/hooks/useTelemetryCharts.ts new file mode 100644 index 0000000..b922856 --- /dev/null +++ b/src/GreenHome.Web/src/features/daily-report/hooks/useTelemetryCharts.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react' +import { NormalizedTelemetry, ChartConfig, ChartData } from '../types' +import { DataGap } from '../utils' +import { insertGapsInData, calcMinMax } from '../utils' +import { BASE_CHART_CONFIGS } from '../chart-config' + +type UseTelemetryChartsParams = { + filteredTelemetry: NormalizedTelemetry[] + filteredDataGaps: DataGap[] +} + +export function useTelemetryCharts({ + filteredTelemetry, + filteredDataGaps, +}: UseTelemetryChartsParams) { + const timestamps = useMemo( + () => filteredTelemetry.map(t => t.timestamp), + [filteredTelemetry] + ) + + const chartLabels = useMemo( + () => { + const labels = filteredTelemetry + .map(t => t.label) + + return labels + }, + [filteredTelemetry] + ) + + const charts = useMemo(() => { + return BASE_CHART_CONFIGS.map((cfg): ChartData => { + const raw = filteredTelemetry.map(cfg.getValue) + const data = insertGapsInData(raw, timestamps, filteredDataGaps) + + let yAxisMin = cfg.yAxisMin + let yAxisMax = cfg.yAxisMax + + if (cfg.key === 'temp') { + const mm = calcMinMax(data, 0, 40, 10) + yAxisMin = mm.min + yAxisMax = mm.max + } else if (cfg.key === 'lux') { + const mm = calcMinMax(data, 0, 2000, 1000) + yAxisMin = mm.min + yAxisMax = mm.max + } + + return { + ...cfg, + data, + yAxisMin, + yAxisMax: yAxisMax ?? yAxisMin, + } + }) + }, [filteredTelemetry, timestamps, filteredDataGaps]) + + return { + charts, + chartLabels, + } +} + diff --git a/src/GreenHome.Web/src/features/daily-report/index.ts b/src/GreenHome.Web/src/features/daily-report/index.ts new file mode 100644 index 0000000..4525a11 --- /dev/null +++ b/src/GreenHome.Web/src/features/daily-report/index.ts @@ -0,0 +1,5 @@ +// Daily report feature exports +export * from './types' +export * from './utils' +export * from './chart-config' + diff --git a/src/GreenHome.Web/src/features/daily-report/types.ts b/src/GreenHome.Web/src/features/daily-report/types.ts new file mode 100644 index 0000000..ae4bc50 --- /dev/null +++ b/src/GreenHome.Web/src/features/daily-report/types.ts @@ -0,0 +1,37 @@ +export type TabType = 'summary' | 'charts' | 'weather' | 'analysis' + +export const TABS: { value: TabType; label: string }[] = [ + { value: 'summary', label: 'خلاصه' }, + { value: 'charts', label: 'نمودار' }, + { value: 'weather', label: 'آب و هوا' }, + { value: 'analysis', label: 'تحلیل' }, +] + +export type NormalizedTelemetry = { + minute: number + timestamp: string + label: string + soil: number + temp: number + hum: number + gas: number + lux: number +} + +export type ChartConfig = { + key: string + title: string + seriesLabel: string + color: string + bgColor: string + getValue: (t: NormalizedTelemetry) => number + yAxisMin: number + yAxisMax?: number +} + +export type ChartData = ChartConfig & { + data: (number | null)[] + yAxisMin: number + yAxisMax: number +} + diff --git a/src/GreenHome.Web/src/features/daily-report/utils.ts b/src/GreenHome.Web/src/features/daily-report/utils.ts new file mode 100644 index 0000000..c21f38c --- /dev/null +++ b/src/GreenHome.Web/src/features/daily-report/utils.ts @@ -0,0 +1,272 @@ +import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react' +import { TelemetryDto } from '@/lib/api' +import { NormalizedTelemetry } from './types' +import { toPersianDigits } from '@/lib/format'; + +// Weather code to description and icon mapping +export const weatherCodeMap: Record }> = { + 0: { description: 'آسمان صاف', icon: Sun }, + 1: { description: 'عمدتاً صاف', icon: Sun }, + 2: { description: 'نیمه ابری', icon: Cloud }, + 3: { description: 'ابری', icon: Cloud }, + 45: { description: 'مه', icon: CloudFog }, + 48: { description: 'مه یخ‌زده', icon: CloudFog }, + 51: { description: 'نم‌نم باران', icon: CloudRain }, + 53: { description: 'نم‌نم باران', icon: CloudRain }, + 55: { description: 'نم‌نم باران شدید', icon: CloudRain }, + 61: { description: 'باران خفیف', icon: CloudRain }, + 63: { description: 'باران متوسط', icon: CloudRain }, + 65: { description: 'باران شدید', icon: CloudRain }, + 71: { description: 'برف خفیف', icon: CloudSnow }, + 73: { description: 'برف متوسط', icon: CloudSnow }, + 75: { description: 'برف شدید', icon: CloudSnow }, + 77: { description: 'دانه برف', icon: CloudSnow }, + 80: { description: 'رگبار خفیف', icon: CloudRain }, + 81: { description: 'رگبار متوسط', icon: CloudRain }, + 82: { description: 'رگبار شدید', icon: CloudRain }, + 85: { description: 'بارش برف خفیف', icon: CloudSnow }, + 86: { description: 'بارش برف شدید', icon: CloudSnow }, + 95: { description: 'رعد و برق', icon: CloudLightning }, + 96: { description: 'رعد و برق با تگرگ', icon: CloudLightning }, + 99: { description: 'رعد و برق شدید', icon: CloudLightning }, +} + +export function getWeatherInfo(code: number) { + return weatherCodeMap[code] || { description: 'نامشخص', icon: Cloud } +} + +// Persian day names +export const persianDayNames = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'] + +export function getPersianDayName(dateStr: string): string { + const date = new Date(dateStr) + return persianDayNames[date.getDay()] +} + +// Card colors configuration +export const paramConfig: Record = { + temperature: { + title: 'دما', + unit: '°C', + chartColor: '#ef4444', + iconBg: 'bg-red-50 dark:bg-red-500/10', + iconColor: 'text-red-600 dark:text-red-400', + }, + humidity: { + title: 'رطوبت هوا', + unit: '%', + chartColor: '#3b82f6', + iconBg: 'bg-blue-50 dark:bg-blue-500/10', + iconColor: 'text-blue-600 dark:text-blue-400', + }, + soil: { + title: 'رطوبت خاک', + unit: '%', + chartColor: '#16a34a', + iconBg: 'bg-green-50 dark:bg-green-500/10', + iconColor: 'text-green-600 dark:text-green-400', + }, + gas: { + title: 'گاز CO', + unit: 'ppm', + chartColor: '#f59e0b', + iconBg: 'bg-amber-50 dark:bg-amber-500/10', + iconColor: 'text-amber-600 dark:text-amber-400', + }, + lux: { + title: 'نور', + unit: 'Lux', + chartColor: '#a855f7', + iconBg: 'bg-purple-50 dark:bg-purple-500/10', + iconColor: 'text-purple-600 dark:text-purple-400', + }, +} + +// Data gap detection +export type DataGap = { + startMinute: number // دقیقه از نیمه شب + endMinute: number // دقیقه از نیمه شب + durationMinutes: number +} + +// تشخیص گپ‌های داده (شکاف‌های زمانی بدون داده) +export function detectDataGaps(timestamps: string[], gapThresholdMinutes: number = 30): DataGap[] { + if (timestamps.length < 2) return [] + + const gaps: DataGap[] = [] + + for (let i = 0; i < timestamps.length - 1; i++) { + const current = new Date(timestamps[i]) + const next = new Date(timestamps[i + 1]) + + const diffMs = next.getTime() - current.getTime() + const diffMinutes = diffMs / (1000 * 60) + + if (diffMinutes > gapThresholdMinutes) { + const startMinute = current.getHours() * 60 + current.getMinutes() + const endMinute = next.getHours() * 60 + next.getMinutes() + + gaps.push({ + startMinute, + endMinute, + durationMinutes: Math.round(diffMinutes) + }) + } + } + + return gaps +} + +// تخمین آستانه گپ بر اساس میانه فاصله بین نمونه‌ها +export function estimateGapThreshold(timestamps: string[], fallbackMinutes = 30, multiplier = 2.5): number { + if (timestamps.length < 3) return fallbackMinutes + + const intervals: number[] = [] + for (let i = 1; i < timestamps.length; i++) { + const diff = new Date(timestamps[i]).getTime() - new Date(timestamps[i - 1]).getTime() + if (diff > 0) intervals.push(diff / 60000) + } + + if (intervals.length === 0) return fallbackMinutes + + intervals.sort((a, b) => a - b) + const median = intervals[Math.floor(intervals.length / 2)] + const threshold = Math.round(median * multiplier) + + return Math.max(10, Math.min(threshold, fallbackMinutes * 4)) +} + +// تخمین فاصله ارسال عادی دستگاه (میانه فاصله بین نمونه‌ها) بر حسب دقیقه +export function estimateSendIntervalMinutes(timestamps: string[], fallbackMinutes = 15): number { + if (timestamps.length < 2) return fallbackMinutes + + const intervals: number[] = [] + for (let i = 1; i < timestamps.length; i++) { + const prev = new Date(timestamps[i - 1]).getTime() + const curr = new Date(timestamps[i]).getTime() + if (Number.isNaN(prev) || Number.isNaN(curr)) continue + const diff = curr - prev + if (diff > 0) intervals.push(diff / 60000) + } + + if (intervals.length === 0) return fallbackMinutes + + intervals.sort((a, b) => a - b) + const median = intervals[Math.floor(intervals.length / 2)] + if (median <= 0) return fallbackMinutes + return Math.max(1, Math.round(median)) +} + +// افزودن null برای گپ‌ها در داده‌های نمودار +export function fillGapsWithNull( + data: T[], + timestamps: string[], + gaps: DataGap[] +): (T | null)[] { + if (gaps.length === 0) return data + + const result: (T | null)[] = [] + let gapIndex = 0 + + for (let i = 0; i < data.length; i++) { + result.push(data[i]) + + // اگر داده بعدی وجود دارد و ما در میانه یک گپ هستیم + if (i < data.length - 1 && gapIndex < gaps.length) { + const currentDate = new Date(timestamps[i]) + const nextDate = new Date(timestamps[i + 1]) + const currentMinute = currentDate.getHours() * 60 + currentDate.getMinutes() + const nextMinute = nextDate.getHours() * 60 + nextDate.getMinutes() + + const gap = gaps[gapIndex] + + // اگر این گپ بین دو نقطه فعلی است، یک null اضافه کن + if (currentMinute <= gap.startMinute && nextMinute >= gap.endMinute) { + result.push(null) + gapIndex++ + } + } + } + + return result +} + +// Insert gaps in data (alternative implementation used in charts) +export function insertGapsInData( + data: number[], + timestamps: string[], + gaps: DataGap[] +): (number | null)[] { + if (gaps.length === 0 || data.length < 2) return data + + const result: (number | null)[] = [] + + for (let i = 0; i < data.length; i++) { + result.push(data[i]) + + if (i < data.length - 1) { + const cur = new Date(timestamps[i]) + const next = new Date(timestamps[i + 1]) + + const curMin = cur.getHours() * 60 + cur.getMinutes() + const nextMin = next.getHours() * 60 + next.getMinutes() + + const hasGap = gaps.some( + g => curMin <= g.startMinute && nextMin >= g.endMinute + ) + + if (hasGap) result.push(null) + } + } + + return result +} + +// Calculate min/max for chart axes with step rounding +export function calcMinMax( + data: (number | null)[], + defaultMin: number, + defaultMax: number, + step: number +) { + const valid = data.filter((v): v is number => v !== null) + if (!valid.length) return { min: defaultMin, max: defaultMax } + + const min = Math.min(...valid) + const max = Math.max(...valid) + + return { + min: min < defaultMin ? Math.floor(min / step) * step : defaultMin, + max: max > defaultMax ? Math.ceil(max / step) * step : defaultMax, + } +} + +// Normalize telemetry data +export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTelemetry[] { + return telemetry.map(t => { + const ts = t.serverTimestampUtc || t.timestampUtc + const d = new Date(ts) + + const h = d.getHours() + const m = d.getMinutes() + const s = d.getSeconds() + + return { + timestamp: ts, + minute: h * 60 + m, + label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`, + soil: Number(t.soilPercent ?? 0), + temp: Number(t.temperatureC ?? 0), + hum: Number(t.humidityPercent ?? 0), + gas: Number(t.gasPPM ?? 0), + lux: Number(t.lux ?? 0), + } + }) +} + diff --git a/src/GreenHome.Web/src/features/weather/api.ts b/src/GreenHome.Web/src/features/weather/api.ts new file mode 100644 index 0000000..10582ea --- /dev/null +++ b/src/GreenHome.Web/src/features/weather/api.ts @@ -0,0 +1,261 @@ +"use client" + +import { WeatherData } from './types' +import { persianToGregorian } from '@/lib/date/persian-date' +import { QOM_LAT, QOM_LON } from './helpers' +import { readCache, writeCache } from './cache' + +const API_BASE = import.meta.env.VITE_API_URL ?? '' + +const MINUTE_MS = 60 * 1000 +const TTL = { + current: 30 * MINUTE_MS, + forecast: 6 * 60 * MINUTE_MS, + historical: 30 * 24 * 60 * MINUTE_MS, + location: 30 * 24 * 60 * MINUTE_MS, +} as const + +type OpenMeteoForecastPayload = { + hourly: { + time: string[] + temperature_2m: number[] + relative_humidity_2m: number[] + weather_code: number[] + precipitation: number[] + } + daily: { + time: string[] + temperature_2m_max: number[] + temperature_2m_min: number[] + weather_code: number[] + precipitation_sum: number[] + precipitation_probability_max: number[] + uv_index_max: number[] + sunshine_duration: number[] + wind_speed_10m_max: number[] + } +} + +type OpenMeteoCurrentPayload = { + current: { + temperature_2m: number + relative_humidity_2m: number + wind_speed_10m: number + weather_code: number + } +} + +type OpenMeteoArchivePayload = { + daily: { + time: string[] + temperature_2m_max: number[] + temperature_2m_min: number[] + weather_code: number[] + precipitation_sum: number[] + wind_speed_10m_max: number[] + sunshine_duration: number[] + } +} + +function composeForecast( + forecastData: OpenMeteoForecastPayload, + currentData: OpenMeteoCurrentPayload +): WeatherData { + // Get only today's hourly data (first 24 hours) + const todayHourly = forecastData.hourly.time.slice(0, 24).map((time, i) => ({ + time, + temperature: forecastData.hourly.temperature_2m[i], + humidity: forecastData.hourly.relative_humidity_2m[i], + weatherCode: forecastData.hourly.weather_code[i], + precipitation: forecastData.hourly.precipitation[i], + })) + + return { + current: { + temperature: currentData.current.temperature_2m, + humidity: currentData.current.relative_humidity_2m, + windSpeed: currentData.current.wind_speed_10m, + weatherCode: currentData.current.weather_code, + }, + hourly: todayHourly, + daily: forecastData.daily.time.map((date, i) => ({ + date, + tempMax: forecastData.daily.temperature_2m_max[i], + tempMin: forecastData.daily.temperature_2m_min[i], + weatherCode: forecastData.daily.weather_code[i], + precipitation: forecastData.daily.precipitation_sum[i], + precipitationProbability: forecastData.daily.precipitation_probability_max[i], + uvIndexMax: forecastData.daily.uv_index_max[i], + sunshineDuration: forecastData.daily.sunshine_duration[i], + windSpeedMax: forecastData.daily.wind_speed_10m_max[i], + })), + } +} + +/** + * Fetch location name from coordinates using reverse geocoding (cached 30 days). + */ +export async function fetchLocationName(latitude: number, longitude: number, force = false): Promise { + const cacheKey = `location:${latitude}:${longitude}` + + if (!force) { + const cached = readCache(cacheKey, TTL.location) + if (cached !== null) return cached + } + + try { + const response = await fetch( + `${API_BASE}/api/weather/location?latitude=${latitude}&longitude=${longitude}` + ) + + if (!response.ok) { + throw new Error('Failed to fetch location name') + } + + const data = await response.json() + + // Extract location name from address + const address = data.address || {} + const locationParts: string[] = [] + + if (address.city || address.town || address.village) { + locationParts.push(address.city || address.town || address.village) + } + + if (address.state || address.province) { + locationParts.push(address.state || address.province) + } + + if (address.country) { + locationParts.push(address.country) + } + + const name = locationParts.length > 0 + ? locationParts.join('، ') + : (data.display_name || 'موقعیت نامشخص') + + writeCache(cacheKey, name) + return name + } catch (error) { + console.error('Error fetching location name:', error) + // Fallback to default location name + return 'کهک قم، ایران' + } +} + +/** + * Fetch historical weather data for past dates (cached 30 days). + */ +export async function fetchHistoricalWeather(selectedDate: string, force = false): Promise { + // تبدیل تاریخ شمسی به میلادی + const [year, month, day] = selectedDate.split('/').map(Number) + const gregorianDate = persianToGregorian(year, month, day) + const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD + + const cacheKey = `historical:${dateStr}` + + if (!force) { + const cached = readCache(cacheKey, TTL.historical) + if (cached !== null) return cached + } + + const response = await fetch( + `${API_BASE}/api/weather/historical?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}` + ) + + if (!response.ok) { + throw new Error('Failed to fetch historical weather data') + } + + const data = (await response.json()) as OpenMeteoArchivePayload + + // ساختار داده برای روزهای گذشته (بدون current و hourly) + const result: WeatherData = { + current: { + temperature: data.daily.temperature_2m_max?.[0] || 0, + humidity: 0, // Historical API رطوبت ندارد + windSpeed: data.daily.wind_speed_10m_max?.[0] || 0, + weatherCode: data.daily.weather_code?.[0] || 0, + }, + hourly: [], // برای گذشته hourly نداریم + daily: [{ + date: data.daily.time?.[0] || dateStr, + tempMax: data.daily.temperature_2m_max?.[0] || 0, + tempMin: data.daily.temperature_2m_min?.[0] || 0, + weatherCode: data.daily.weather_code?.[0] || 0, + precipitation: data.daily.precipitation_sum?.[0] || 0, + precipitationProbability: 0, + uvIndexMax: 0, + sunshineDuration: data.daily.sunshine_duration?.[0] || 0, + windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0, + }] + } + + writeCache(cacheKey, result) + return result +} + +/** + * Fetch forecast weather data for today and future dates. + * Cached in two tiers: current (30 min) and forecast (6 hours). + * Pass force=true to bypass the cache (e.g. explicit refresh). + */ +export async function fetchForecastWeather(force = false): Promise { + const forecastKey = `forecast:${QOM_LAT}:${QOM_LON}` + const currentKey = `current:${QOM_LAT}:${QOM_LON}` + + let forecastData = force ? null : readCache(forecastKey, TTL.forecast) + let currentData = force ? null : readCache(currentKey, TTL.current) + + if (forecastData !== null && currentData !== null) { + return composeForecast(forecastData, currentData) + } + + // Fetch only the pieces that are missing from the cache + const forecastResPromise: Promise = forecastData === null + ? fetch(`${API_BASE}/api/weather/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}&forecast_days=7`) + : Promise.resolve(null) + + const currentResPromise: Promise = currentData === null + ? fetch(`${API_BASE}/api/weather/current?latitude=${QOM_LAT}&longitude=${QOM_LON}`) + : Promise.resolve(null) + + const [forecastRes, currentRes] = await Promise.all([forecastResPromise, currentResPromise]) + + if (forecastRes !== null && !forecastRes.ok) { + throw new Error('Failed to fetch weather data') + } + if (currentRes !== null && !currentRes.ok) { + throw new Error('Failed to fetch weather data') + } + + if (forecastRes !== null) { + forecastData = (await forecastRes.json()) as OpenMeteoForecastPayload + writeCache(forecastKey, forecastData) + } + if (currentRes !== null) { + currentData = (await currentRes.json()) as OpenMeteoCurrentPayload + writeCache(currentKey, currentData) + } + + return composeForecast(forecastData!, currentData!) +} + +/** + * Check if a Persian date is today + */ +export function isToday(selectedDate: string): boolean { + try { + const [year, month, day] = selectedDate.split('/').map(Number) + const gregorianDate = persianToGregorian(year, month, day) + + const today = new Date() + today.setHours(0, 0, 0, 0) + gregorianDate.setHours(0, 0, 0, 0) + + return gregorianDate.getTime() === today.getTime() + } catch (e) { + console.error('Error checking if today:', e) + return false + } +} diff --git a/src/GreenHome.Web/src/features/weather/cache.ts b/src/GreenHome.Web/src/features/weather/cache.ts new file mode 100644 index 0000000..c3372b0 --- /dev/null +++ b/src/GreenHome.Web/src/features/weather/cache.ts @@ -0,0 +1,53 @@ +"use client" + +type CacheEntry = { + data: T + cachedAt: number +} + +const PREFIX = 'gh:weather:' + +function storageAvailable(): boolean { + try { + return typeof window !== 'undefined' && !!window.localStorage + } catch { + return false + } +} + +/** + * Read a cached value if it exists and is still within its TTL. + * Returns null on miss, expiry, parse error or when storage is unavailable. + */ +export function readCache(key: string, ttlMs: number): T | null { + if (!storageAvailable()) return null + + try { + const raw = window.localStorage.getItem(PREFIX + key) + if (!raw) return null + + const entry = JSON.parse(raw) as CacheEntry + if (typeof entry?.cachedAt !== 'number' || Date.now() - entry.cachedAt > ttlMs) { + window.localStorage.removeItem(PREFIX + key) + return null + } + + return entry.data + } catch { + return null + } +} + +/** + * Persist a value with the current timestamp. Ignores quota/private-mode errors. + */ +export function writeCache(key: string, data: T): void { + if (!storageAvailable()) return + + try { + const entry: CacheEntry = { data, cachedAt: Date.now() } + window.localStorage.setItem(PREFIX + key, JSON.stringify(entry)) + } catch { + // Ignore storage quota / private mode errors + } +} diff --git a/src/GreenHome.Web/src/features/weather/helpers.ts b/src/GreenHome.Web/src/features/weather/helpers.ts new file mode 100644 index 0000000..aee9fe7 --- /dev/null +++ b/src/GreenHome.Web/src/features/weather/helpers.ts @@ -0,0 +1,111 @@ +import { Thermometer, Sun, Droplets, Wind, Leaf } from 'lucide-react' +import { WeatherData, GreenhouseAlert } from './types' +import { toPersianDigits } from '@/lib/format/persian-digits' + +// Kahak Qom coordinates +export const QOM_LAT = 34.39674800 +export const QOM_LON = 50.86594800 + +// Greenhouse-specific recommendations +export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] { + const alerts: GreenhouseAlert[] = [] + const today = weather.daily[0] + + // Frost warning + if (today.tempMin < 5) { + alerts.push({ + type: 'danger', + title: '⚠️ هشدار یخ‌زدگی', + description: `دمای حداقل امشب ${toPersianDigits(Math.round(today.tempMin))}°C پیش‌بینی شده. سیستم گرمایش را فعال کنید و پوشش محافظ روی گیاهان حساس قرار دهید.`, + icon: Thermometer + }) + } + + // Heat stress warning + if (today.tempMax > 35) { + alerts.push({ + type: 'danger', + title: '🌡️ هشدار گرمای شدید', + description: `دمای حداکثر ${toPersianDigits(Math.round(today.tempMax))}°C پیش‌بینی شده. سایه‌بان‌ها را فعال کنید، تهویه را افزایش دهید و آبیاری را در ساعات خنک انجام دهید.`, + icon: Sun + }) + } + + // High UV warning + if (today.uvIndexMax > 8) { + alerts.push({ + type: 'warning', + title: '☀️ شاخص UV بالا', + description: `شاخص UV ${toPersianDigits(Math.round(today.uvIndexMax))} است. برای گیاهان حساس به نور از سایه‌بان استفاده کنید.`, + icon: Sun + }) + } + + // Strong wind warning + if (today.windSpeedMax > 40) { + alerts.push({ + type: 'warning', + title: '💨 باد شدید', + description: `سرعت باد به ${toPersianDigits(Math.round(today.windSpeedMax))} کیلومتر بر ساعت می‌رسد. دریچه‌ها و پنجره‌ها را ببندید و سازه را بررسی کنید.`, + icon: Wind + }) + } + + // Rain/precipitation warning + if (today.precipitation > 10) { + alerts.push({ + type: 'info', + title: '🌧️ بارش قابل توجه', + description: `بارش ${toPersianDigits(Math.round(today.precipitation))} میلی‌متر پیش‌بینی شده. سیستم زهکشی را بررسی کنید و آبیاری را کاهش دهید.`, + icon: Droplets + }) + } + + // Optimal conditions + if (today.tempMin >= 10 && today.tempMax <= 28 && today.windSpeedMax < 30 && today.precipitation < 5) { + alerts.push({ + type: 'success', + title: '✅ شرایط مناسب', + description: 'شرایط آب و هوایی امروز برای رشد گیاهان عالی است. می‌توانید تهویه طبیعی را افزایش دهید.', + icon: Leaf + }) + } + + return alerts +} + +// Get irrigation recommendation +export function getIrrigationRecommendation(weather: WeatherData): { level: string; color: string; description: string } { + const today = weather.daily[0] + + if (today.precipitationProbability > 60) { + return { level: 'کم', color: 'text-blue-600', description: 'به دلیل احتمال بارش، آبیاری را کاهش دهید' } + } + if (today.tempMax > 35) { + return { level: 'زیاد', color: 'text-red-600', description: 'به دلیل گرمای شدید، آبیاری بیشتری لازم است' } + } + if (today.tempMax > 28) { + return { level: 'متوسط-زیاد', color: 'text-orange-600', description: 'آبیاری در ساعات صبح و عصر توصیه می‌شود' } + } + return { level: 'متوسط', color: 'text-green-600', description: 'آبیاری معمول کافی است' } +} + +// Get ventilation recommendation +export function getVentilationRecommendation(weather: WeatherData): { level: string; color: string; description: string } { + const today = weather.daily[0] + + if (today.windSpeedMax > 40) { + return { level: 'بسته', color: 'text-red-600', description: 'به دلیل باد شدید، دریچه‌ها را ببندید' } + } + if (today.tempMax > 30) { + return { level: 'حداکثر', color: 'text-orange-600', description: 'تهویه کامل برای کاهش دما ضروری است' } + } + if (today.tempMax > 25 && today.windSpeedMax < 25) { + return { level: 'متوسط', color: 'text-green-600', description: 'تهویه طبیعی مناسب است' } + } + if (today.tempMin < 10) { + return { level: 'محدود', color: 'text-blue-600', description: 'تهویه را محدود کنید تا گرما حفظ شود' } + } + return { level: 'معمولی', color: 'text-gray-600', description: 'تهویه استاندارد کافی است' } +} + diff --git a/src/GreenHome.Web/src/features/weather/index.ts b/src/GreenHome.Web/src/features/weather/index.ts new file mode 100644 index 0000000..0ed6072 --- /dev/null +++ b/src/GreenHome.Web/src/features/weather/index.ts @@ -0,0 +1,5 @@ +// Weather feature exports +export * from './types' +export * from './api' +export * from './helpers' + diff --git a/src/GreenHome.Web/src/features/weather/types.ts b/src/GreenHome.Web/src/features/weather/types.ts new file mode 100644 index 0000000..cce69f4 --- /dev/null +++ b/src/GreenHome.Web/src/features/weather/types.ts @@ -0,0 +1,34 @@ +export type WeatherData = { + current: { + temperature: number + humidity: number + windSpeed: number + weatherCode: number + } + hourly: { + time: string + temperature: number + humidity: number + weatherCode: number + precipitation: number + }[] + daily: { + date: string + tempMax: number + tempMin: number + weatherCode: number + precipitation: number + precipitationProbability: number + uvIndexMax: number + sunshineDuration: number // in seconds + windSpeedMax: number + }[] +} + +export type GreenhouseAlert = { + type: 'danger' | 'warning' | 'info' | 'success' + title: string + description: string + icon: React.ComponentType<{ className?: string }> +} + diff --git a/src/GreenHome.Web/src/hooks/useDebounce.ts b/src/GreenHome.Web/src/hooks/useDebounce.ts new file mode 100644 index 0000000..9089050 --- /dev/null +++ b/src/GreenHome.Web/src/hooks/useDebounce.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +/** + * Hook برای debounce کردن یک مقدار + * @param value - مقداری که باید debounce بشه + * @param delay - تاخیر به میلی‌ثانیه (پیش‌فرض: 300ms) + * @returns مقدار debounced شده + */ +export function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} + diff --git a/src/GreenHome.Web/src/hooks/usePolling.ts b/src/GreenHome.Web/src/hooks/usePolling.ts new file mode 100644 index 0000000..8d280af --- /dev/null +++ b/src/GreenHome.Web/src/hooks/usePolling.ts @@ -0,0 +1,74 @@ +'use client' + +import { useEffect, useRef } from 'react' + +type UsePollingOptions = { + enabled: boolean + intervalMs?: number + fetchNewData: () => Promise + onNewData: (items: T[]) => void + onFetch?: () => void +} + +/** + * Lightweight polling with battery-friendly behavior: + * - pauses when the tab is hidden + * - performs an immediate sync when the tab becomes visible again + */ +export function usePolling({ enabled, intervalMs = 30000, fetchNewData, onNewData, onFetch }: UsePollingOptions) { + const fetchRef = useRef(fetchNewData) + const onDataRef = useRef(onNewData) + const onFetchRef = useRef(onFetch) + const intervalRef = useRef | null>(null) + + useEffect(() => { + fetchRef.current = fetchNewData + onDataRef.current = onNewData + onFetchRef.current = onFetch + }) + + useEffect(() => { + if (!enabled) return + + const poll = async () => { + try { + const items = await fetchRef.current() + onFetchRef.current?.() + if (items.length > 0) { + onDataRef.current(items) + } + } catch { + // Ignore polling errors; the next tick will retry + } + } + + const stop = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + + const start = () => { + stop() + intervalRef.current = setInterval(poll, intervalMs) + } + + const handleVisibilityChange = () => { + if (document.hidden) { + stop() + } else { + poll() + start() + } + } + + start() + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + stop() + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [enabled, intervalMs]) +} diff --git a/src/GreenHome.Web/src/hooks/usePullToRefresh.ts b/src/GreenHome.Web/src/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..2000004 --- /dev/null +++ b/src/GreenHome.Web/src/hooks/usePullToRefresh.ts @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +export function usePullToRefresh(onRefresh: () => Promise) { + const touchStartY = useRef(0) + const isRefreshing = useRef(false) + const [pullDistance, setPullDistance] = useState(0) + + useEffect(() => { + const handleTouchStart = (e: TouchEvent) => { + if (window.scrollY === 0) { + touchStartY.current = e.touches[0].clientY + } + } + + const handleTouchMove = (e: TouchEvent) => { + if (window.scrollY === 0 && touchStartY.current > 0) { + const touchY = e.touches[0].clientY + const distance = touchY - touchStartY.current + + if (distance > 0 && !isRefreshing.current) { + // Limit pull distance to 150px + const limitedDistance = Math.min(distance, 150) + setPullDistance(limitedDistance) + + // Prevent default scrolling when pulling down + if (distance > 10) { + e.preventDefault() + } + } + } + } + + const handleTouchEnd = async (e: TouchEvent) => { + if (window.scrollY === 0 && touchStartY.current > 0) { + const touchY = e.changedTouches[0].clientY + const distance = touchY - touchStartY.current + + if (distance > 100 && !isRefreshing.current) { + isRefreshing.current = true + setPullDistance(0) + try { + await onRefresh() + } finally { + isRefreshing.current = false + } + } else { + setPullDistance(0) + } + } + touchStartY.current = 0 + } + + // Only enable on touch devices + if ('ontouchstart' in window) { + window.addEventListener('touchstart', handleTouchStart, { passive: false }) + window.addEventListener('touchmove', handleTouchMove, { passive: false }) + window.addEventListener('touchend', handleTouchEnd) + } + + return () => { + if ('ontouchstart' in window) { + window.removeEventListener('touchstart', handleTouchStart) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) + } + } + }, [onRefresh]) + + return { pullDistance, isRefreshing: isRefreshing.current } +} + diff --git a/src/GreenHome.Web/src/lib/api/client.ts b/src/GreenHome.Web/src/lib/api/client.ts new file mode 100644 index 0000000..3d9fc92 --- /dev/null +++ b/src/GreenHome.Web/src/lib/api/client.ts @@ -0,0 +1,156 @@ +"use client" + +import type { + DeviceDto, + TelemetryDto, + DeviceSettingsDto, + SendCodeRequest, + SendCodeResponse, + VerifyCodeRequest, + VerifyCodeResponse, + PagedResult, + DailyReportDto, + AlertConditionDto, + CreateAlertConditionDto, + UpdateAlertConditionDto, + PowerOutageSummaryDto, +} from './types' + +const API_BASE = import.meta.env.VITE_API_URL ?? '' + +const DEFAULT_TIMEOUT_MS = 15000 +const MAX_RETRIES = 2 + +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null + return localStorage.getItem('authToken') +} + +async function http(url: string, init?: RequestInit, retries = MAX_RETRIES): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + + const token = getAuthToken() + const headers: Record = { + 'Content-Type': 'application/json', + ...(init?.headers as Record | undefined), + } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + try { + const res = await fetch(url, { ...init, headers, signal: controller.signal }) + + if (!res.ok) { + let errorMessage = `HTTP ${res.status}` + try { + const errorData = await res.json() + if (errorData.message) { + errorMessage = errorData.message + } + } catch { + // Ignore JSON parse errors + } + const error = new Error(errorMessage) as Error & { status?: number } + error.status = res.status + throw error + } + + // Check if response has content + const text = await res.text() + if (!text) { + return undefined as T + } + + return JSON.parse(text) as T + } catch (error) { + const method = (init?.method ?? 'GET').toUpperCase() + const isAbort = error instanceof DOMException && error.name === 'AbortError' + const status = (error as { status?: number }).status + const isClientError = status !== undefined && status >= 400 && status < 500 + + if (method === 'GET' && !isAbort && !isClientError && retries > 0) { + return http(url, init, retries - 1) + } + + throw error + } finally { + clearTimeout(timeout) + } +} + +export const api = { + CheckDevice: (deviceName: string, password: string) => http(`${API_BASE}/api/devices/CheckDevice?deviceName=${deviceName}&password=${password}`), + listDevices: () => http(`${API_BASE}/api/devices`), + createDevice: (dto: DeviceDto) => http(`${API_BASE}/api/devices`, { method: 'POST', body: JSON.stringify(dto) }), + listTelemetry: (q: { deviceId?: number; startUtc?: string; endUtc?: string; sinceUtc?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams() + if (q.deviceId) params.set('deviceId', String(q.deviceId)) + if (q.startUtc) params.set('startUtc', q.startUtc) + if (q.endUtc) params.set('endUtc', q.endUtc) + if (q.sinceUtc) params.set('sinceUtc', q.sinceUtc) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/telemetry?${params.toString()}`) + }, + createTelemetry: (dto: TelemetryDto) => http(`${API_BASE}/api/telemetry`, { method: 'POST', body: JSON.stringify(dto) }), + minmax: (deviceId: number, startUtc?: string, endUtc?: string) => { + const params = new URLSearchParams({ deviceId: String(deviceId) }) + if (startUtc) params.set('startUtc', startUtc) + if (endUtc) params.set('endUtc', endUtc) + return http<{ [k: string]: number }>(`${API_BASE}/api/telemetry/minmax?${params.toString()}`) + }, + monthDays: (deviceId: number, year: number, month: number) => http<{ persianDate: string; count: number }[]>(`${API_BASE}/api/telemetry/days?deviceId=${deviceId}&year=${year}&month=${month}`), + activeMonths: (deviceId: number, year: number) => http(`${API_BASE}/api/telemetry/months?deviceId=${deviceId}&year=${year}`), + + // Device Settings + getDeviceSettings: (deviceId: number) => http(`${API_BASE}/api/devicesettings/${deviceId}`), + createDeviceSettings: (dto: DeviceSettingsDto) => http(`${API_BASE}/api/devicesettings`, { method: 'POST', body: JSON.stringify(dto) }), + updateDeviceSettings: (dto: DeviceSettingsDto) => http(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) }), + + // Authentication + sendCode: (request: SendCodeRequest) => http(`${API_BASE}/api/auth/send-code`, { method: 'POST', body: JSON.stringify(request) }), + verifyCode: (request: VerifyCodeRequest) => http(`${API_BASE}/api/auth/verify-code`, { method: 'POST', body: JSON.stringify(request) }), + canResend: (mobile: string) => http<{ canResend: boolean }>(`${API_BASE}/api/auth/can-resend?mobile=${encodeURIComponent(mobile)}`), + me: () => http<{ id: number; mobile: string; name: string; family: string; role: number }>(`${API_BASE}/api/auth/me`), + + // User Devices + getUserDevices: (userId: number) => http(`${API_BASE}/api/devices/user/${userId}`), + getDevicesFiltered: (q: { userId: number; search?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams({ userId: String(q.userId) }) + if (q.search) params.set('search', q.search) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/devices/filtered?${params.toString()}`) + }, + + // Daily Report + getDailyReport: (deviceId: number, persianDate: string) => + http(`${API_BASE}/api/DailyReport?deviceId=${deviceId}&persianDate=${encodeURIComponent(persianDate)}`), + + // Display parameters from server + getDisplayParameters: (deviceId: number) => + http(`${API_BASE}/api/display-parameters?deviceId=${deviceId}`), + + // Alert Conditions + getAlertConditions: (deviceId: number) => { + return http(`${API_BASE}/api/alertconditions/device/${deviceId}`) + }, + getAlertCondition: (id: number) => + http(`${API_BASE}/api/alertconditions/${id}`), + createAlertCondition: (dto: CreateAlertConditionDto) => + http(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }), + updateAlertCondition: (dto: UpdateAlertConditionDto) => + http(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }), + deleteAlertCondition: (id: number) => + http(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' }), + + // Power Outage + getPowerOutageSummary: (deviceId: number, startUtc?: string, endUtc?: string) => { + const params = new URLSearchParams({ deviceId: String(deviceId) }) + if (startUtc) params.set('startUtc', startUtc) + if (endUtc) params.set('endUtc', endUtc) + return http(`${API_BASE}/api/PowerOutage/summary?${params.toString()}`) + }, +} diff --git a/src/GreenHome.Web/src/lib/api/index.ts b/src/GreenHome.Web/src/lib/api/index.ts new file mode 100644 index 0000000..9b10979 --- /dev/null +++ b/src/GreenHome.Web/src/lib/api/index.ts @@ -0,0 +1,4 @@ +// Re-export API client and types for backward compatibility +export { api } from './client' +export * from './types' + diff --git a/src/GreenHome.Web/src/lib/api/types.ts b/src/GreenHome.Web/src/lib/api/types.ts new file mode 100644 index 0000000..4e153e5 --- /dev/null +++ b/src/GreenHome.Web/src/lib/api/types.ts @@ -0,0 +1,211 @@ +// API DTOs and Types + +export type DeviceDto = { + id: number + deviceName: string + userId: number + userName: string + userFamily: string + userMobile: string + location: string + neshanLocation: string +} + +export type TelemetryDto = { + id: number + deviceId: number + timestampUtc: string + temperatureC: number + humidityPercent: number + soilPercent: number + gasPPM: number + lux: number + persianYear: number + persianMonth: number + persianDate: string + deviceName?: string + power?: number + oldPower?: number + batteryVoltage?: number + batteryPercent?: number + voltage?: number + serverTimestampUtc?: string +} + +export type DeviceSettingsDto = { + id: number + deviceId: number + deviceName: string + province: string + city: string + latitude?: number + longitude?: number + productType: string + minimumSmsIntervalMinutes: number + minimumCallIntervalMinutes: number + areaSquareMeters?: number + uploadIntervalMin: number + devicePhoneNumber: string + simCardType?: number + relayCount: number + smsRecievers: string + tokenCode?: string + verificationCode?: string + tokenExpiresAt?: string + relays: DeviceRelayDto[] + createdAt: string + updatedAt: string +} + +export type DeviceRelayDto = { + id: number + deviceSettingsId: number + relayIndex: number + name: string + isEnabled: boolean + relayKind: 0 | 1 | 2 | 3 | 4 | 5 | 6 + mode: 0 | 1 + minOnMinutes: number + minOffMinutes: number + rules: DeviceRelayRuleDto[] +} + +export type DeviceRelayRuleDto = { + id: number + sensorType: 0 | 1 | 2 | 3 | 4 + comparisonType: 0 | 1 | 2 | 3 + value1: number + value2?: number + action: 0 | 1 + timeType: 0 | 1 | 2 + order: number +} + +export type SendCodeRequest = { + mobile: string +} + +export type SendCodeResponse = { + success: boolean + message?: string + resendAfterSeconds: number +} + +export type VerifyCodeRequest = { + mobile: string + code: string +} + +export type VerifyCodeResponse = { + success: boolean + message?: string + token?: string + user?: { + id: number + mobile: string + name: string + family: string + } +} + +export type PagedResult = { + items: T[] + totalCount: number + page: number + pageSize: number +} + +export type DailyReportDto = { + id: number + deviceId: number + deviceName: string + persianDate: string + analysis: string + recordCount: number + sampledRecordCount: number + totalTokens: number + createdAt: string + fromCache: boolean +} + +export type AlertRuleDto = { + id: number + alertConditionId: number + sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4 + comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3 + value1: number + value2?: number // برای Between و OutOfRange + order: number +} + +export type CreateAlertRuleRequest = { + sensorType: 0 | 1 | 2 | 3 | 4 + comparisonType: 0 | 1 | 2 | 3 + value1: number + value2?: number + order: number +} + +export type AlertConditionDto = { + id: number + deviceId: number + deviceName: string + notificationType: number // Call=-1, SMS=0 + timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2 + callCooldownMinutes: number + smsCooldownMinutes: number + isEnabled: boolean + rules: AlertRuleDto[] + createdAt: string + updatedAt: string +} + +export type CreateAlertConditionDto = { + deviceId: number + notificationType: number + timeType: 0 | 1 | 2 + callCooldownMinutes?: number + smsCooldownMinutes?: number + isEnabled: boolean + rules: CreateAlertRuleRequest[] +} + +export type UpdateAlertConditionDto = { + id: number + notificationType: number + timeType: 0 | 1 | 2 + callCooldownMinutes?: number + smsCooldownMinutes?: number + isEnabled: boolean + rules: CreateAlertRuleRequest[] +} + +// Power Outage Types +export type PowerOutageLogDto = { + id: number + deviceId: number + deviceName: string + previousPower: number + currentPower: number + eventType: string // "Connected" or "Disconnected" + eventTypePersian: string // "وصل" or "قطع" + timestampUtc: string + serverTimestampUtc: string + persianDate: string +} + +export type PowerOutagePairDto = { + disconnectedAt: string + connectedAt: string | null + durationSeconds: number | null +} + +export type PowerOutageSummaryDto = { + deviceId: number + totalOutageCount: number + totalDowntimeSeconds: number + startDateUtc: string | null + endDateUtc: string | null + outages: PowerOutagePairDto[] +} + diff --git a/src/GreenHome.Web/src/lib/date/persian-date.ts b/src/GreenHome.Web/src/lib/date/persian-date.ts new file mode 100644 index 0000000..a366f3b --- /dev/null +++ b/src/GreenHome.Web/src/lib/date/persian-date.ts @@ -0,0 +1,127 @@ +/** + * Persian (Jalali) date utilities — corrected + */ +import { + toJalaali, + toGregorian, + isLeapJalaaliYear, + jalaaliMonthLength, +} from 'jalaali-js' + +export interface PersianDate { + year: number + month: number + day: number +} + +export function gregorianToPersian(gDate: Date): PersianDate { + const { jy, jm, jd } = toJalaali(gDate.getFullYear(), gDate.getMonth() + 1, gDate.getDate()) + return { year: jy, month: jm, day: jd } +} + +export function persianToGregorian(year: number, month: number, day: number): Date { + const { gy, gm, gd } = toGregorian(year, month, day) + return new Date(gy, gm - 1, gd) +} + +export function getCurrentPersianDate(): PersianDate { + return gregorianToPersian(new Date()) +} + +export function getCurrentPersianYear(): number { + return getCurrentPersianDate().year +} +export function getCurrentPersianMonth(): number { + return getCurrentPersianDate().month +} +export function getCurrentPersianDay(): number { + return getCurrentPersianDate().day +} + +export function isPersianLeapYear(year: number): boolean { + return isLeapJalaaliYear(year) +} + +export function getPersianMonthDays(year: number, month: number): number { + return jalaaliMonthLength(year, month) +} + +/** + * Get weekday (0–6) for the first day of a Persian month + * Returns: 0 = شنبه, 1 = یکشنبه, ..., 6 = جمعه + */ +export function getPersianMonthStartWeekday(year: number, month: number): number { + const { gy, gm, gd } = toGregorian(year, month, 1) + const gDate = new Date(gy, gm - 1, gd) + // JS getDay: 0 = Sunday ... 6 = Saturday + // We want: 0 = Saturday (شنبه), so map: persianIndex = (jsDay + 1) % 7 + return (gDate.getDay() + 1) % 7 +} + +/** + * Get today's Persian date as formatted string (e.g., "سه‌شنبه ۱۶ مهر ۱۴۰۴") + */ +export function getPersianTodayString(): string { + const daysOfWeek = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'] + const months = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] + + const now = new Date() + const persian = gregorianToPersian(now) + const persianWeekday = daysOfWeek[(now.getDay() + 1) % 7] // نگاشت درست روز هفته + return `${persianWeekday} ${persian.day} ${months[persian.month - 1]} ${persian.year}` +} + +/** + * Parse Persian date string in format "yyyy/MM/dd" and return PersianDate + */ +export function parsePersianDate(dateStr: string): PersianDate | null { + try { + const parts = dateStr.split('/') + if (parts.length !== 3) return null + const year = parseInt(parts[0]) + const month = parseInt(parts[1]) + const day = parseInt(parts[2]) + if (isNaN(year) || isNaN(month) || isNaN(day)) return null + return { year, month, day } + } catch { + return null + } +} + +/** + * Format Persian date as "yyyy/MM/dd" + */ +export function formatPersianDateString(date: PersianDate): string { + return `${date.year}/${String(date.month).padStart(2, '0')}/${String(date.day).padStart(2, '0')}` +} + +/** + * Get previous day in Persian calendar + */ +export function getPreviousPersianDay(dateStr: string): string | null { + const parsed = parsePersianDate(dateStr) + if (!parsed) return null + + // Convert to Gregorian, subtract one day, convert back to Persian + const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day) + gregorian.setDate(gregorian.getDate() - 1) + const prevPersian = gregorianToPersian(gregorian) + + return formatPersianDateString(prevPersian) +} + +/** + * Get next day in Persian calendar + */ +export function getNextPersianDay(dateStr: string): string | null { + const parsed = parsePersianDate(dateStr) + if (!parsed) return null + + // Convert to Gregorian, add one day, convert back to Persian + const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day) + gregorian.setDate(gregorian.getDate() + 1) + const nextPersian = gregorianToPersian(gregorian) + + return formatPersianDateString(nextPersian) +} + diff --git a/src/GreenHome.Web/src/lib/format/index.ts b/src/GreenHome.Web/src/lib/format/index.ts new file mode 100644 index 0000000..08d8e97 --- /dev/null +++ b/src/GreenHome.Web/src/lib/format/index.ts @@ -0,0 +1,4 @@ +// Re-export formatting utilities +export * from './persian-digits' +export * from './persian-date' + diff --git a/src/GreenHome.Web/src/lib/format/persian-date.ts b/src/GreenHome.Web/src/lib/format/persian-date.ts new file mode 100644 index 0000000..2f43ac5 --- /dev/null +++ b/src/GreenHome.Web/src/lib/format/persian-date.ts @@ -0,0 +1,39 @@ +/** + * Persian date formatting utilities + */ +import { toPersianDigits } from './persian-digits' + +/** + * Format date to yyyy/MM/dd + */ +export function formatPersianDate(year: number, month: number, day: number): string { + const mm = month.toString().padStart(2, '0') + const dd = day.toString().padStart(2, '0') + return `${year}/${mm}/${dd}` +} + +/** + * Ensure date string is in yyyy/MM/dd format + */ +export function ensureDateFormat(dateStr: string): string { + const parts = dateStr.split('/') + if (parts.length !== 3) return dateStr + const [year, month, day] = parts.map(Number) + return formatPersianDate(year, month, day) +} + +/** + * Format an ISO/UTC timestamp to local "HH:mm:ss" with Persian digits. + * Returns an empty string for invalid dates. + */ +export function formatPersianTime(isoString: string): string { + const date = new Date(isoString) + if (isNaN(date.getTime())) return '' + + const hh = date.getHours().toString().padStart(2, '0') + const mm = date.getMinutes().toString().padStart(2, '0') + const ss = date.getSeconds().toString().padStart(2, '0') + + return toPersianDigits(`${hh}:${mm}:${ss}`) +} + diff --git a/src/GreenHome.Web/src/lib/format/persian-digits.ts b/src/GreenHome.Web/src/lib/format/persian-digits.ts new file mode 100644 index 0000000..7f86461 --- /dev/null +++ b/src/GreenHome.Web/src/lib/format/persian-digits.ts @@ -0,0 +1,12 @@ +/** + * Persian digit formatting utilities + */ + +/** + * تابع تبدیل ارقام انگلیسی به فارسی + */ +export function toPersianDigits(num: number | string): string { + const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) +} + diff --git a/src/GreenHome.Web/src/lib/router.tsx b/src/GreenHome.Web/src/lib/router.tsx new file mode 100644 index 0000000..ded1861 --- /dev/null +++ b/src/GreenHome.Web/src/lib/router.tsx @@ -0,0 +1,36 @@ +import { useMemo } from 'react' +import { + useNavigate, + useSearchParams as useRouterSearchParams, + useLocation, + Link as RouterLink, + type LinkProps, +} from 'react-router-dom' + +// Thin compatibility layer so existing components keep the same API as `next/navigation`. +// The returned router object is memoized (stable identity) so callbacks/effects that +// list `router` in their dependency arrays do not re-run on every render. +export function useRouter() { + const navigate = useNavigate() + return useMemo( + () => ({ + push: (path: string) => navigate(path), + replace: (path: string) => navigate(path, { replace: true }), + }), + [navigate] + ) +} + +export function useSearchParams() { + const [params] = useRouterSearchParams() + return params +} + +export function usePathname() { + return useLocation().pathname +} + +// Compatibility Link: accepts `href` (like next/link) and forwards it as `to`. +export function Link({ href, ...rest }: { href: string } & Omit) { + return +} diff --git a/src/GreenHome.Web/src/lib/utils.ts b/src/GreenHome.Web/src/lib/utils.ts new file mode 100644 index 0000000..c0c567e --- /dev/null +++ b/src/GreenHome.Web/src/lib/utils.ts @@ -0,0 +1,11 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** + * Utility function to merge class names. + * Uses clsx for conditional classes and tailwind-merge to deduplicate + * conflicting Tailwind utilities (e.g. "px-2 px-4" keeps only "px-4"). + */ +export function cn(...classes: ClassValue[]): string { + return twMerge(clsx(classes)) +} diff --git a/src/GreenHome.Web/src/lib/utils/sun-utils.ts b/src/GreenHome.Web/src/lib/utils/sun-utils.ts new file mode 100644 index 0000000..049e01b --- /dev/null +++ b/src/GreenHome.Web/src/lib/utils/sun-utils.ts @@ -0,0 +1,51 @@ +/** + * محاسبه زمان طلوع و غروب خورشید برای یک موقعیت جغرافیایی + * @param latitude عرض جغرافیایی (درجه) + * @param longitude طول جغرافیایی (درجه) + * @param timezoneOffset افست زمانی از UTC (ساعت) + * @returns اطلاعات طلوع و غروب خورشید + */ +export function calculateSunTimes( + latitude: number = 34.39674800, // کهک قم + longitude: number = 50.86594800, + timezoneOffset: number = 3.5 // ایران +) { + const now = new Date() + const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000) + + // محاسبه انحراف خورشید (Solar Declination) + const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10)) + + // محاسبه زاویه ساعتی طلوع (Hour Angle) + const latRad = latitude * Math.PI / 180 + const decRad = declination * Math.PI / 180 + const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad) + + // در صورتی که خورشید طلوع/غروب می‌کند + if (Math.abs(cosHourAngle) <= 1) { + const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI + + // زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق) + const sunriseDecimal = 12 - hourAngle / 15 + (longitude / 15 - timezoneOffset) + const sunsetDecimal = 12 + hourAngle / 15 + (longitude / 15 - timezoneOffset) + + // تبدیل به ساعت و دقیقه + const sunriseHour = Math.floor(sunriseDecimal) + const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60) + + const sunsetHour = Math.floor(sunsetDecimal) + const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60) + + return { + sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal }, + sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal } + } + } + + // مقادیر پیش‌فرض (برای روزهای قطبی) + return { + sunrise: { hour: 6, minute: 0, decimal: 6 }, + sunset: { hour: 18, minute: 0, decimal: 18 } + } +} + diff --git a/src/GreenHome.Web/src/lib/utils/time-utils.ts b/src/GreenHome.Web/src/lib/utils/time-utils.ts new file mode 100644 index 0000000..3649bef --- /dev/null +++ b/src/GreenHome.Web/src/lib/utils/time-utils.ts @@ -0,0 +1,57 @@ +import { toPersianDigits } from '@/lib/format/persian-digits' + +/** + * تبدیل دقیقه از نیمه شب به ساعت و دقیقه + * @param minute دقیقه از نیمه شب (0-1439) + * @returns object شامل hour و minute + */ +export function minuteToHours(minute: number): { hour: number; minute: number } { + return { + hour: Math.floor(minute / 60), + minute: minute % 60 + } +} + +/** + * تبدیل دقیقه به درصد موقعیت در timeline (0-100) + * برای RTL layout: 0 ساعت در راست (100%)، 24 ساعت در چپ (0%) + * @param minute دقیقه از نیمه شب (0-1439) + * @param maxMinutes حداکثر دقیقه (پیش‌فرض: 1439) + * @returns درصد موقعیت (0-100) + */ +export function minuteToPercent(minute: number, maxMinutes: number = 1439): number { + return ((maxMinutes - minute) / maxMinutes) * 100 +} + +/** + * فرمت کردن زمان برای نمایش به صورت HH:MM با ارقام فارسی + * @param hour ساعت + * @param minute دقیقه + * @returns رشته فرمت شده با ارقام فارسی + */ +export function formatTimeLabel(hour: number, minute: number): string { + return `${toPersianDigits(hour.toString().padStart(2, '0'))}:${toPersianDigits(minute.toString().padStart(2, '0'))}` +} + +/** + * محاسبه مدت زمان بازه انتخاب شده به ساعت و دقیقه + * @param startMinute دقیقه شروع + * @param endMinute دقیقه پایان + * @returns object شامل hour و minute + */ +export function calculateDuration(startMinute: number, endMinute: number): { hour: number; minute: number } { + const durationMinutes = endMinute - startMinute + 1 + return minuteToHours(durationMinutes) +} + +/** + * فرمت کردن زمان برای نمایش با prefix (مثل "طلوع" یا "غروب") + * @param prefix پیشوند (مثل "طلوع" یا "غروب") + * @param hour ساعت + * @param minute دقیقه + * @returns رشته فرمت شده با prefix و ارقام فارسی + */ +export function formatSunTimeLabel(prefix: string, hour: number, minute: number): string { + return `${prefix} ${formatTimeLabel(hour, minute)}` +} + diff --git a/src/GreenHome.Web/src/lib/version.ts b/src/GreenHome.Web/src/lib/version.ts new file mode 100644 index 0000000..ab0ed38 --- /dev/null +++ b/src/GreenHome.Web/src/lib/version.ts @@ -0,0 +1,12 @@ +export const APP_VERSION = import.meta.env.VITE_APP_VERSION || Date.now().toString(); + +export async function getAppVersion(): Promise { + try { + const response = await fetch('/version.json', { cache: 'no-store' }); + const data = await response.json(); + return data.version; + } catch { + return APP_VERSION; + } +} + diff --git a/src/GreenHome.Web/src/main.tsx b/src/GreenHome.Web/src/main.tsx new file mode 100644 index 0000000..8379898 --- /dev/null +++ b/src/GreenHome.Web/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './app/globals.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/src/GreenHome.Web/src/vite-env.d.ts b/src/GreenHome.Web/src/vite-env.d.ts new file mode 100644 index 0000000..084c3a5 --- /dev/null +++ b/src/GreenHome.Web/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare module 'jalaali-js'; diff --git a/src/GreenHome.Web/tsconfig.json b/src/GreenHome.Web/tsconfig.json new file mode 100644 index 0000000..f43d3ff --- /dev/null +++ b/src/GreenHome.Web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/src/GreenHome.Web/vite.config.ts b/src/GreenHome.Web/vite.config.ts new file mode 100644 index 0000000..001b392 --- /dev/null +++ b/src/GreenHome.Web/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 5173, + proxy: { + // In development, forward API calls to the running ASP.NET backend. + '/api': { + target: process.env.VITE_DEV_API_URL ?? 'http://127.0.0.1:5064', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +})