diff --git a/.app_version b/.app_version index d77011c2..e780e0e0 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.30.7 +0.30.12 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0a24e5a2..867f379e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,8 @@ assignees: '' --- +**BEFORE OPENING AN ISSUE, MAKE SURE YOU READ THIS: https://github.com/Freika/dawarich/issues/1382** + **OS & Hardware** Provide your software and hardware specs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecd06e5..5854fb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [UNRELEASED] + +## Fixed + +- Default value for `points_count` attribute is now set to 0 in the User model. + +# [0.30.12] - 2025-08-26 + +## Fixed + +- Number of user points is not being cached resulting in performance boost on certain pages and operations. +- Logout bug +- Api key is now shown even in trial period + + +# [0.30.11] - 2025-08-23 + +## Changed + +- If user already have import with the same name, it will be appended with timestamp during the import process. + +## Fixed + +- Some types of imports were not being detected correctly and were failing to import. #1678 + + +# [0.30.10] - 2025-08-22 + +## Added + +- `POST /api/v1/visits` endpoint. +- User now can create visits manually on the map. +- User can now delete a visit by clicking on the delete button in the visit popup. +- Import failure now throws an internal server error. + +## Changed + +- Source of imports is now being detected automatically. + + +# [0.30.9] - 2025-08-19 + +## Changed + +- Countries, visited during a trip, are now being calculated from points to improve performance. + +## Added + +- QR code for API key is implemented but hidden under feature flag until the iOS app supports it. +- X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses. +- Trial version for cloud users is now available. + + +# [0.30.8] - 2025-08-01 + +## Fixed + +- Fog of war is now working correctly on zoom and map movement. #1603 +- Possibly fixed a bug where visits were no suggested correctly. #984 +- Scratch map is now working correctly. + + # [0.30.7] - 2025-08-01 ## Fixed @@ -52,7 +114,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Prometheus metrics are now available at `/metrics`. Configure `METRICS_USERNAME` and `METRICS_PASSWORD` environment variables for basic authentication, default values are `prometheus` for both. All other prometheus-related environment variables are also necessary. - ## Fixed - The Warden error in jobs is now fixed. #1556 diff --git a/Gemfile b/Gemfile index 614a2e95..c7145245 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby File.read('.ruby-version').strip +gem 'activerecord-postgis-adapter' # https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40 gem 'aws-sdk-s3', '~> 1.177.0', require: false gem 'aws-sdk-core', '~> 3.215.1', require: false @@ -24,7 +25,7 @@ gem 'oj' gem 'parallel' gem 'pg' gem 'prometheus_exporter' -gem 'activerecord-postgis-adapter' +gem 'rqrcode', '~> 3.0' gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' diff --git a/Gemfile.lock b/Gemfile.lock index 4b955b5a..74af4a35 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,38 +40,38 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) timeout (>= 0.4.0) activerecord-postgis-adapter (11.0.0) activerecord (~> 8.0.0) rgeo-activerecord (~> 8.0.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -127,6 +127,7 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) chartkick (5.2.0) + chunky_png (1.4.0) coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.3) @@ -297,7 +298,7 @@ GEM date stringio public_suffix (6.0.1) - puma (6.6.0) + puma (6.6.1) nio4r (~> 2.0) pundit (2.5.0) activesupport (>= 3.0.0) @@ -311,20 +312,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.2.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -332,9 +333,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -365,6 +366,10 @@ GEM rgeo-geojson (2.2.0) multi_json (~> 1.15) rgeo (>= 1.0.0) + rqrcode (3.1.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.0.0) rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.4) @@ -553,6 +558,7 @@ DEPENDENCIES rgeo rgeo-activerecord rgeo-geojson + rqrcode (~> 3.0) rspec-rails rswag-api rswag-specs diff --git a/README.md b/README.md index 789bd889..8ee904bf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🌍 Dawarich: Your Self-Hosted Location History Tracker +# 🌍 Dawarich: Your Self-Hostable Location History Tracker [![Discord](https://dcbadge.limes.pink/api/server/pHsBjpt5J8)](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika) @@ -21,9 +21,14 @@ ## πŸ—ΊοΈ About Dawarich -**Dawarich** is a self-hosted web app designed to replace Google Timeline (aka Google Location History). It enables you to: +If you're looking for Dawarich Cloud, where everything is managed for you, check out [Dawarich Cloud](https://dawarich.app). + +**Dawarich** is a self-hostable web app designed to replace Google Timeline (aka Google Location History). +It enables you to: + +- Track your location history. - Visualize your data on an interactive map. -- Import your location history from Google Maps Timeline and Owntracks. +- Import your location history from Google Maps Timeline, OwnTracks, GPX, GeoJSON and some other sources - Explore statistics like the number of countries and cities visited, total distance traveled, and more! πŸ“„ **Changelog**: Find the latest updates [here](CHANGELOG.md). @@ -62,7 +67,7 @@ Simply install one of the supported apps on your device and configure it to send 1. Clone the repository. 2. Run the following command to start the app: ```bash - docker-compose up + docker-compose -f docker/docker-compose.yml up ``` 3. Access the app at `http://localhost:3000`. diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 83fc96ab..bee31ae9 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1,6 +1,6 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{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)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact -.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{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)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact +.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 257a1910..572eadbb 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -33,6 +33,40 @@ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } +/* Add Visit Marker Styles */ +.add-visit-marker { + display: flex !important; + align-items: center; + justify-content: center; + font-size: 20px; + background: white; + border: 2px solid #007bff; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + animation: pulse-visit 2s infinite; +} + +@keyframes pulse-visit { + 0% { + transform: scale(1); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + } + 50% { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5); + } + 100% { + transform: scale(1); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + } +} + +/* Visit Form Popup Styles */ +.visit-form-popup .leaflet-popup-content-wrapper { + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + .leaflet-right-panel.controls-shifted { right: 310px; } diff --git a/app/controllers/api/v1/countries/borders_controller.rb b/app/controllers/api/v1/countries/borders_controller.rb index 1c3d13a8..6be8195a 100644 --- a/app/controllers/api/v1/countries/borders_controller.rb +++ b/app/controllers/api/v1/countries/borders_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Countries::BordersController < ApplicationController +class Api::V1::Countries::BordersController < ApiController def index countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson'))) diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb index 85e53f7d..5af80348 100644 --- a/app/controllers/api/v1/countries/visited_cities_controller.rb +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController end_at = DateTime.parse(params[:end_at]).to_i points = current_api_user - .tracked_points + .points .where(timestamp: start_at..end_at) render json: { data: CountriesAndCities.new(points).call } diff --git a/app/controllers/api/v1/health_controller.rb b/app/controllers/api/v1/health_controller.rb index 8e13d165..1e5ab2f1 100644 --- a/app/controllers/api/v1/health_controller.rb +++ b/app/controllers/api/v1/health_controller.rb @@ -4,14 +4,6 @@ class Api::V1::HealthController < ApiController skip_before_action :authenticate_api_key def index - if current_api_user - response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!') - else - response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!') - end - - response.set_header('X-Dawarich-Version', APP_VERSION) - render json: { status: 'ok' } end end diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 505bb123..6dd2cf93 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -10,7 +10,7 @@ class Api::V1::PointsController < ApiController order = params[:order] || 'desc' points = current_api_user - .tracked_points + .points .where(timestamp: start_at..end_at) .order(timestamp: order) .page(params[:page]) @@ -31,7 +31,7 @@ class Api::V1::PointsController < ApiController end def update - point = current_api_user.tracked_points.find(params[:id]) + point = current_api_user.points.find(params[:id]) point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})") @@ -39,7 +39,7 @@ class Api::V1::PointsController < ApiController end def destroy - point = current_api_user.tracked_points.find(params[:id]) + point = current_api_user.points.find(params[:id]) point.destroy render json: { message: 'Point deleted successfully' } diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 9832d6b4..248e5ea7 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -10,6 +10,19 @@ class Api::V1::VisitsController < ApiController render json: serialized_visits end + def create + service = Visits::Create.new(current_api_user, visit_params) + + result = service.call + + if result + render json: Api::VisitSerializer.new(service.visit).call + else + error_message = service.errors || 'Failed to create visit' + render json: { error: error_message }, status: :unprocessable_entity + end + end + def update visit = current_api_user.visits.find(params[:id]) visit = update_visit(visit) @@ -62,10 +75,25 @@ class Api::V1::VisitsController < ApiController end end + def destroy + visit = current_api_user.visits.find(params[:id]) + + if visit.destroy + head :no_content + else + render json: { + error: 'Failed to delete visit', + errors: visit.errors.full_messages + }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + render json: { error: 'Visit not found' }, status: :not_found + end + private def visit_params - params.require(:visit).permit(:name, :place_id, :status) + params.require(:visit).permit(:name, :place_id, :status, :latitude, :longitude, :started_at, :ended_at) end def merge_params @@ -78,6 +106,8 @@ class Api::V1::VisitsController < ApiController def update_visit(visit) visit_params.each do |key, value| + next if %w[latitude longitude].include?(key.to_s) + visit[key] = value visit.name = visit.place.name if visit_params[:place_id].present? end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 4d13bdaf..d53f57ae 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -2,10 +2,18 @@ class ApiController < ApplicationController skip_before_action :verify_authenticity_token + before_action :set_version_header before_action :authenticate_api_key private + def set_version_header + message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!" + + response.set_header('X-Dawarich-Response', message) + response.set_header('X-Dawarich-Version', APP_VERSION) + end + def authenticate_api_key return head :unauthorized unless current_api_user diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index f77c7a54..27453c76 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -6,6 +6,6 @@ class HomeController < ApplicationController redirect_to map_url if current_user - @points = current_user.tracked_points.without_raw_data if current_user + @points = current_user.points.without_raw_data if current_user end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 2d7feef1..3ee75a95 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -43,8 +43,7 @@ class ImportsController < ApplicationController raw_files = Array(files_params).reject(&:blank?) if raw_files.empty? - redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity - return + redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity and return end created_imports = [] @@ -59,11 +58,11 @@ class ImportsController < ApplicationController if created_imports.any? redirect_to imports_url, notice: "#{created_imports.size} files are queued to be imported in background", - status: :see_other + status: :see_other and return else redirect_to new_import_path, alert: 'No valid file references were found. Please upload files using the file selector.', - status: :unprocessable_entity + status: :unprocessable_entity and return end rescue StandardError => e if created_imports.present? @@ -95,7 +94,7 @@ class ImportsController < ApplicationController end def import_params - params.require(:import).permit(:name, :source, files: []) + params.require(:import).permit(:name, files: []) end def create_import_from_signed_id(signed_id) @@ -103,11 +102,8 @@ class ImportsController < ApplicationController blob = ActiveStorage::Blob.find_signed(signed_id) - import = current_user.imports.build( - name: blob.filename.to_s, - source: params[:import][:source] - ) - + import_name = generate_unique_import_name(blob.filename.to_s) + import = current_user.imports.build(name: import_name) import.file.attach(blob) import.save! @@ -115,6 +111,18 @@ class ImportsController < ApplicationController import end + def generate_unique_import_name(original_name) + return original_name unless current_user.imports.exists?(name: original_name) + + # Extract filename and extension + basename = File.basename(original_name, File.extname(original_name)) + extension = File.extname(original_name) + + # Add current datetime + timestamp = Time.current.strftime('%Y%m%d_%H%M%S') + "#{basename}_#{timestamp}#{extension}" + end + def validate_points_limit limit_exceeded = PointsLimitExceeded.new(current_user).call diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index 82d9435f..bbb308bb 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -88,6 +88,6 @@ class MapController < ApplicationController end def points_from_user - current_user.tracked_points.without_raw_data.order(timestamp: :asc) + current_user.points.without_raw_data.order(timestamp: :asc) end end diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index a78c97c4..65d99698 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -24,7 +24,7 @@ class PointsController < ApplicationController alert: 'No points selected.', status: :see_other and return if point_ids.blank? - current_user.tracked_points.where(id: point_ids).destroy_all + current_user.points.where(id: point_ids).destroy_all redirect_to points_url(preserved_params), notice: 'Points were successfully destroyed.', @@ -58,7 +58,7 @@ class PointsController < ApplicationController end def user_points - current_user.tracked_points + current_user.points end def order_by diff --git a/app/controllers/settings/background_jobs_controller.rb b/app/controllers/settings/background_jobs_controller.rb index 31bda769..b9f3a597 100644 --- a/app/controllers/settings/background_jobs_controller.rb +++ b/app/controllers/settings/background_jobs_controller.rb @@ -6,7 +6,7 @@ class Settings::BackgroundJobsController < ApplicationController %w[start_immich_import start_photoprism_import].include?(params[:job_name]) } - def index;end + def index; end def create EnqueueBackgroundJob.perform_later(params[:job_name], current_user.id) diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 4bff870e..710f9b60 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -59,11 +59,11 @@ class StatsController < ApplicationController @stats.each do |year, stats| stats_by_month = stats.index_by(&:month) - + year_distances[year] = (1..12).map do |month| month_name = Date::MONTHNAMES[month] distance = stats_by_month[month]&.distance || 0 - + [month_name, distance] end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index dfd93042..2fb02162 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -48,11 +48,11 @@ module ApplicationHelper grouped_by_country[country] ||= [] - if toponym['cities'].present? - toponym['cities'].each do |city_data| - city = city_data['city'] - grouped_by_country[country] << city if city.present? - end + next unless toponym['cities'].present? + + toponym['cities'].each do |city_data| + city = city_data['city'] + grouped_by_country[country] << city if city.present? end end end @@ -86,7 +86,7 @@ module ApplicationHelper end def points_exist?(year, month, user) - user.tracked_points.where( + user.points.where( timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month ).exists? end @@ -172,4 +172,21 @@ module ApplicationHelper data: { tip: "Expires on #{active_until.iso8601}" } ) end + + def onboarding_modal_showable?(user) + user.trial_state? + end + + def trial_button_class(user) + case (user.active_until.to_date - Time.current.to_date).to_i + when 5..8 + 'btn-info' + when 2...5 + 'btn-warning' + when 0...2 + 'btn-error' + else + 'btn-success' + end + end end diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb new file mode 100644 index 00000000..b28f55b9 --- /dev/null +++ b/app/helpers/user_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module UserHelper + def api_key_qr_code(user) + qrcode = RQRCode::QRCode.new(user.api_key) + svg = qrcode.as_svg( + color: "000", + fill: "fff", + shape_rendering: "crispEdges", + module_size: 11, + standalone: true, + use_path: true, + offset: 5 + ) + svg.html_safe + end +end diff --git a/app/javascript/controllers/add_visit_controller.js b/app/javascript/controllers/add_visit_controller.js new file mode 100644 index 00000000..cf5b32da --- /dev/null +++ b/app/javascript/controllers/add_visit_controller.js @@ -0,0 +1,462 @@ +import { Controller } from "@hotwired/stimulus"; +import L from "leaflet"; +import { showFlashMessage } from "../maps/helpers"; + +export default class extends Controller { + static targets = [""]; + static values = { + apiKey: String + } + + connect() { + console.log("Add visit controller connected"); + this.map = null; + this.isAddingVisit = false; + this.addVisitMarker = null; + this.addVisitButton = null; + this.currentPopup = null; + this.mapsController = null; + + // Wait for the map to be initialized + this.waitForMap(); + } + + disconnect() { + this.cleanup(); + console.log("Add visit controller disconnected"); + } + + waitForMap() { + // Get the map from the maps controller instance + const mapElement = document.querySelector('[data-controller*="maps"]'); + + if (mapElement) { + // Try to get Stimulus controller instance + const stimulusController = this.application.getControllerForElementAndIdentifier(mapElement, 'maps'); + if (stimulusController && stimulusController.map) { + this.map = stimulusController.map; + this.mapsController = stimulusController; + this.apiKeyValue = stimulusController.apiKey; + this.setupAddVisitButton(); + return; + } + } + + // Fallback: check for map container and try to find map instance + const mapContainer = document.getElementById('map'); + if (mapContainer && mapContainer._leaflet_id) { + // Get map instance from Leaflet registry + this.map = window.L._getMap ? window.L._getMap(mapContainer._leaflet_id) : null; + + if (!this.map) { + // Try through Leaflet internal registry + const maps = window.L.Map._instances || {}; + this.map = maps[mapContainer._leaflet_id]; + } + + if (this.map) { + // Get API key from map element data + this.apiKeyValue = mapContainer.dataset.api_key || this.element.dataset.apiKey; + this.setupAddVisitButton(); + return; + } + } + + // Wait a bit more for the map to initialize + setTimeout(() => this.waitForMap(), 200); + } + + setupAddVisitButton() { + if (!this.map || this.addVisitButton) return; + + // Create the Add Visit control + const AddVisitControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button'); + button.innerHTML = 'βž•'; + button.title = 'Add a visit'; + + // Style the button to match other map controls + button.style.width = '48px'; + button.style.height = '48px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.backgroundColor = 'white'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '48px'; + button.style.fontSize = '18px'; + button.style.textAlign = 'center'; + button.style.transition = 'all 0.2s ease'; + + // Disable map interactions when clicking the button + L.DomEvent.disableClickPropagation(button); + + // Add hover effects + button.addEventListener('mouseenter', () => { + if (!this.isAddingVisit) { + button.style.backgroundColor = '#f0f0f0'; + } + }); + + button.addEventListener('mouseleave', () => { + if (!this.isAddingVisit) { + button.style.backgroundColor = 'white'; + } + }); + + // Toggle add visit mode on button click + L.DomEvent.on(button, 'click', () => { + this.toggleAddVisitMode(button); + }); + + this.addVisitButton = button; + return button; + } + }); + + // Add the control to the map (top right, below existing buttons) + this.map.addControl(new AddVisitControl({ position: 'topright' })); + } + + toggleAddVisitMode(button) { + if (this.isAddingVisit) { + // Exit add visit mode + this.exitAddVisitMode(button); + } else { + // Enter add visit mode + this.enterAddVisitMode(button); + } + } + + enterAddVisitMode(button) { + this.isAddingVisit = true; + + // Update button style to show active state + button.style.backgroundColor = '#dc3545'; + button.style.color = 'white'; + button.innerHTML = 'βœ•'; + + // Change cursor to crosshair + this.map.getContainer().style.cursor = 'crosshair'; + + // Add map click listener + this.map.on('click', this.onMapClick, this); + + showFlashMessage('notice', 'Click on the map to place a visit'); + } + + exitAddVisitMode(button) { + this.isAddingVisit = false; + + // Reset button style + button.style.backgroundColor = 'white'; + button.style.color = 'black'; + button.innerHTML = 'βž•'; + + // Reset cursor + this.map.getContainer().style.cursor = ''; + + // Remove map click listener + this.map.off('click', this.onMapClick, this); + + // Remove any existing marker + if (this.addVisitMarker) { + this.map.removeLayer(this.addVisitMarker); + this.addVisitMarker = null; + } + + // Close any open popup + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + } + + onMapClick(e) { + if (!this.isAddingVisit) return; + + const { lat, lng } = e.latlng; + + // Remove existing marker if any + if (this.addVisitMarker) { + this.map.removeLayer(this.addVisitMarker); + } + + // Create a new marker at the clicked location + this.addVisitMarker = L.marker([lat, lng], { + draggable: true, + icon: L.divIcon({ + className: 'add-visit-marker', + html: 'πŸ“', + iconSize: [30, 30], + iconAnchor: [15, 15] + }) + }).addTo(this.map); + + // Show the visit form popup + this.showVisitForm(lat, lng); + } + + showVisitForm(lat, lng) { + // Get current date/time for default values + const now = new Date(); + const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)); + + // Format dates for datetime-local input + const formatDateTime = (date) => { + return date.toISOString().slice(0, 16); + }; + + const startTime = formatDateTime(now); + const endTime = formatDateTime(oneHourLater); + + // Create form HTML + const formHTML = ` +
+

Add New Visit

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+
+ `; + + // Create popup at the marker location + this.currentPopup = L.popup({ + closeOnClick: false, + autoClose: false, + maxWidth: 300, + className: 'visit-form-popup' + }) + .setLatLng([lat, lng]) + .setContent(formHTML) + .openOn(this.map); + + // Add event listeners after the popup is added to DOM + setTimeout(() => { + const form = document.getElementById('add-visit-form'); + const cancelButton = document.getElementById('cancel-visit'); + const nameInput = document.getElementById('visit-name'); + + if (form) { + form.addEventListener('submit', (e) => this.handleFormSubmit(e)); + } + + if (cancelButton) { + cancelButton.addEventListener('click', () => { + this.exitAddVisitMode(this.addVisitButton); + }); + } + + // Focus the name input + if (nameInput) { + nameInput.focus(); + } + }, 100); + } + + async handleFormSubmit(event) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + // Get form values + const visitData = { + visit: { + name: formData.get('name'), + started_at: formData.get('started_at'), + ended_at: formData.get('ended_at'), + latitude: formData.get('latitude'), + longitude: formData.get('longitude') + } + }; + + // Validate that end time is after start time + const startTime = new Date(visitData.visit.started_at); + const endTime = new Date(visitData.visit.ended_at); + + if (endTime <= startTime) { + showFlashMessage('error', 'End time must be after start time'); + return; + } + + // Disable form while submitting + const submitButton = form.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + submitButton.disabled = true; + submitButton.textContent = 'Creating...'; + + try { + const response = await fetch(`/api/v1/visits`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiKeyValue}` + }, + body: JSON.stringify(visitData) + }); + + const data = await response.json(); + + if (response.ok) { + showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`); + this.exitAddVisitMode(this.addVisitButton); + + // Refresh visits layer - this will clear and refetch data + this.refreshVisitsLayer(); + + // Ensure confirmed visits layer is enabled (with a small delay for the API call to complete) + setTimeout(() => { + this.ensureVisitsLayersEnabled(); + }, 300); + } else { + const errorMessage = data.error || data.message || 'Failed to create visit'; + showFlashMessage('error', errorMessage); + } + } catch (error) { + console.error('Error creating visit:', error); + showFlashMessage('error', 'Network error: Failed to create visit'); + } finally { + // Re-enable form + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } + + refreshVisitsLayer() { + console.log('Attempting to refresh visits layer...'); + + // Try multiple approaches to refresh the visits layer + const mapsController = document.querySelector('[data-controller*="maps"]'); + if (mapsController) { + // Try to get the Stimulus controller instance + const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); + + if (stimulusController && stimulusController.visitsManager) { + console.log('Found maps controller with visits manager'); + + // Clear existing visits and fetch fresh data + if (stimulusController.visitsManager.visitCircles) { + stimulusController.visitsManager.visitCircles.clearLayers(); + } + if (stimulusController.visitsManager.confirmedVisitCircles) { + stimulusController.visitsManager.confirmedVisitCircles.clearLayers(); + } + + // Refresh the visits data + if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') { + console.log('Refreshing visits data...'); + stimulusController.visitsManager.fetchAndDisplayVisits(); + } + } else { + console.log('Could not find maps controller or visits manager'); + + // Fallback: Try to dispatch a custom event + const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true }); + mapsController.dispatchEvent(refreshEvent); + } + } else { + console.log('Could not find maps controller element'); + } + } + + ensureVisitsLayersEnabled() { + console.log('Ensuring visits layers are enabled...'); + + const mapsController = document.querySelector('[data-controller*="maps"]'); + if (mapsController) { + const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); + + if (stimulusController && stimulusController.map && stimulusController.visitsManager) { + const map = stimulusController.map; + const visitsManager = stimulusController.visitsManager; + + // Get the confirmed visits layer (newly created visits are always confirmed) + const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer(); + + // Ensure confirmed visits layer is added to map since we create confirmed visits + if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) { + console.log('Adding confirmed visits layer to map'); + map.addLayer(confirmedVisitsLayer); + + // Update the layer control checkbox to reflect the layer is now active + this.updateLayerControlCheckbox('Confirmed Visits', true); + } + + // Refresh visits data to include the new visit + if (typeof visitsManager.fetchAndDisplayVisits === 'function') { + console.log('Final refresh of visits to show new visit...'); + visitsManager.fetchAndDisplayVisits(); + } + } + } + } + + updateLayerControlCheckbox(layerName, isEnabled) { + // Find the layer control input for the specified layer + const layerControlContainer = document.querySelector('.leaflet-control-layers'); + if (!layerControlContainer) { + console.log('Layer control container not found'); + return; + } + + const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]'); + inputs.forEach(input => { + const label = input.nextElementSibling; + if (label && label.textContent.trim() === layerName) { + console.log(`Updating ${layerName} checkbox to ${isEnabled}`); + input.checked = isEnabled; + + // Trigger change event to ensure proper state management + input.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + } + + cleanup() { + if (this.map) { + this.map.off('click', this.onMapClick, this); + + if (this.addVisitMarker) { + this.map.removeLayer(this.addVisitMarker); + } + + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + } + } + } +} diff --git a/app/javascript/controllers/direct_upload_controller.js b/app/javascript/controllers/direct_upload_controller.js index 5be5b921..cc58436e 100644 --- a/app/javascript/controllers/direct_upload_controller.js +++ b/app/javascript/controllers/direct_upload_controller.js @@ -5,7 +5,8 @@ import { showFlashMessage } from "../maps/helpers" export default class extends Controller { static targets = ["input", "progress", "progressBar", "submit", "form"] static values = { - url: String + url: String, + userTrial: Boolean } connect() { @@ -50,6 +51,22 @@ export default class extends Controller { const files = this.inputTarget.files if (files.length === 0) return + // Check file size limits for trial users + if (this.userTrialValue) { + const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes + const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE) + + if (oversizedFiles.length > 0) { + const fileNames = oversizedFiles.map(f => f.name).join(', ') + const message = `File size limit exceeded. Trial users can only upload files up to 10MB. Oversized files: ${fileNames}` + showFlashMessage('error', message) + + // Clear the file input + this.inputTarget.value = '' + return + } + } + console.log(`Uploading ${files.length} files`) this.isUploading = true diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 8e3349b6..0957d27c 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -35,6 +35,7 @@ import { showFlashMessage } from "../maps/helpers"; import { fetchAndDisplayPhotos } from "../maps/photos"; import { countryCodesMap } from "../maps/country_codes"; import { VisitsManager } from "../maps/visits"; +import { ScratchLayer } from "../maps/scratch_layer"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; @@ -49,7 +50,6 @@ export default class extends BaseController { layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; - currentPopup = null; tracksLayer = null; tracksVisible = false; tracksSubscription = null; @@ -181,7 +181,7 @@ export default class extends BaseController { this.areasLayer = new L.FeatureGroup(); this.photoMarkers = L.layerGroup(); - this.setupScratchLayer(this.countryCodesMap); + this.initializeScratchLayer(); if (!this.settingsButtonAdded) { this.addSettingsButton(); @@ -197,7 +197,7 @@ export default class extends BaseController { Tracks: this.tracksLayer, Heatmap: this.heatmapLayer, "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer, + "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer, Photos: this.photoMarkers, "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), @@ -348,127 +348,23 @@ export default class extends BaseController { appendPoint(data) { if (this.liveMapHandler && this.liveMapEnabled) { this.liveMapHandler.appendPoint(data); + // Update scratch layer manager with new markers + if (this.scratchLayerManager) { + this.scratchLayerManager.updateMarkers(this.markers); + } } else { console.warn('LiveMapHandler not initialized or live mode not enabled'); } } - async setupScratchLayer(countryCodesMap) { - this.scratchLayer = L.geoJSON(null, { - style: { - fillColor: '#FFD700', - fillOpacity: 0.3, - color: '#FFA500', - weight: 1 - } - }) - - try { - // Up-to-date version can be found on Github: - // https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson - const response = await fetch('/api/v1/countries/borders.json', { - headers: { - 'Accept': 'application/geo+json,application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const worldData = await response.json(); - // Cache the world borders data for future use - this.worldBordersData = worldData; - - const visitedCountries = this.getVisitedCountries(countryCodesMap) - const filteredFeatures = worldData.features.filter(feature => - visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) - ) - - this.scratchLayer.addData({ - type: 'FeatureCollection', - features: filteredFeatures - }) - } catch (error) { - console.error('Error loading GeoJSON:', error); - } + async initializeScratchLayer() { + this.scratchLayerManager = new ScratchLayer(this.map, this.markers, this.countryCodesMap, this.apiKey); + this.scratchLayer = await this.scratchLayerManager.setup(); } - getVisitedCountries(countryCodesMap) { - if (!this.markers) return []; - - return [...new Set( - this.markers - .filter(marker => marker[7]) // Ensure country exists - .map(marker => { - // Convert country name to ISO code, or return the original if not found - return countryCodesMap[marker[7]] || marker[7]; - }) - )]; - } - - // Optional: Add methods to handle user interactions toggleScratchLayer() { - if (this.map.hasLayer(this.scratchLayer)) { - this.map.removeLayer(this.scratchLayer) - } else { - this.scratchLayer.addTo(this.map) - } - } - - async refreshScratchLayer() { - console.log('Refreshing scratch layer with current data'); - - if (!this.scratchLayer) { - console.log('Scratch layer not initialized, setting up'); - await this.setupScratchLayer(this.countryCodesMap); - return; - } - - try { - // Clear existing data - this.scratchLayer.clearLayers(); - - // Get current visited countries based on current markers - const visitedCountries = this.getVisitedCountries(this.countryCodesMap); - console.log('Current visited countries:', visitedCountries); - - if (visitedCountries.length === 0) { - console.log('No visited countries found'); - return; - } - - // Fetch country borders data (reuse if already loaded) - if (!this.worldBordersData) { - console.log('Loading world borders data'); - const response = await fetch('/api/v1/countries/borders.json', { - headers: { - 'Accept': 'application/geo+json,application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - this.worldBordersData = await response.json(); - } - - // Filter for visited countries - const filteredFeatures = this.worldBordersData.features.filter(feature => - visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) - ); - - console.log('Filtered features for visited countries:', filteredFeatures.length); - - // Add the filtered country data to the scratch layer - this.scratchLayer.addData({ - type: 'FeatureCollection', - features: filteredFeatures - }); - - } catch (error) { - console.error('Error refreshing scratch layer:', error); + if (this.scratchLayerManager) { + this.scratchLayerManager.toggle(); } } @@ -591,9 +487,11 @@ export default class extends BaseController { this.visitsManager.fetchAndDisplayVisits(); } } else if (event.name === 'Scratch map') { - // Refresh scratch map with current visited countries + // Add scratch map layer console.log('Scratch map layer enabled via layer control'); - this.refreshScratchLayer(); + if (this.scratchLayerManager) { + this.scratchLayerManager.addToMap(); + } } else if (event.name === 'Fog of War') { // Enable fog of war when layer is added this.fogOverlay = event.layer; @@ -626,6 +524,12 @@ export default class extends BaseController { // Clear the visit circles when layer is disabled this.visitsManager.visitCircles.clearLayers(); } + } else if (event.name === 'Scratch map') { + // Handle scratch map layer removal + console.log('Scratch map layer disabled via layer control'); + if (this.scratchLayerManager) { + this.scratchLayerManager.remove(); + } } else if (event.name === 'Fog of War') { // Fog canvas will be automatically removed by the layer's onRemove method this.fogOverlay = null; @@ -703,7 +607,7 @@ export default class extends BaseController { Routes: this.polylinesLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.layerGroup(), "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer || L.layerGroup(), + "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup() }; @@ -741,24 +645,26 @@ export default class extends BaseController { const markerId = parseInt(marker[6]); return markerId !== numericId; }); - } - } - addLastMarker(map, markers) { - if (markers.length > 0) { - const lastMarker = markers[markers.length - 1].slice(0, 2); - const marker = L.marker(lastMarker).addTo(map); - return marker; // Return marker reference for tracking + // Update scratch layer manager with updated markers + if (this.scratchLayerManager) { + this.scratchLayerManager.updateMarkers(this.markers); + } } - return null; } updateFog(markers, clearFogRadius, fogLineThreshold) { - const fog = document.getElementById('fog'); - if (!fog) { - initializeFogCanvas(this.map); + // Call the fog overlay's updateFog method if it exists + if (this.fogOverlay && typeof this.fogOverlay.updateFog === 'function') { + this.fogOverlay.updateFog(markers, clearFogRadius, fogLineThreshold); + } else { + // Fallback for when fog overlay isn't available + const fog = document.getElementById('fog'); + if (!fog) { + initializeFogCanvas(this.map); + } + requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold)); } - requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold)); } initializeDrawControl() { @@ -1098,7 +1004,7 @@ export default class extends BaseController { Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false, Heatmap: this.map.hasLayer(this.heatmapLayer), "Fog of War": this.map.hasLayer(this.fogOverlay), - "Scratch map": this.map.hasLayer(this.scratchLayer), + "Scratch map": this.scratchLayerManager?.isVisible() || false, Areas: this.map.hasLayer(this.areasLayer), Photos: this.map.hasLayer(this.photoMarkers) }; @@ -1640,14 +1546,6 @@ export default class extends BaseController { } } - chunk(array, size) { - const chunked = []; - for (let i = 0; i < array.length; i += size) { - chunked.push(array.slice(i, i + size)); - } - return chunked; - } - getWholeYearLink() { // First try to get year from URL parameters const urlParams = new URLSearchParams(window.location.search); @@ -1912,30 +1810,6 @@ export default class extends BaseController { }); } - updateLayerControl() { - if (!this.layerControl) return; - - // Remove existing layer control - this.map.removeControl(this.layerControl); - - // Create new controls layer object - const controlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Tracks: this.tracksLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup(), - "Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(), - "Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() - }; - - // Re-add the layer control - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - } - toggleTracksVisibility(event) { this.tracksVisible = event.target.checked; @@ -1943,8 +1817,4 @@ export default class extends BaseController { toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); } } - - - - } diff --git a/app/javascript/controllers/onboarding_modal_controller.js b/app/javascript/controllers/onboarding_modal_controller.js new file mode 100644 index 00000000..5a20e1c2 --- /dev/null +++ b/app/javascript/controllers/onboarding_modal_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal"] + static values = { showable: Boolean } + + connect() { + if (this.showableValue) { + // Listen for Turbo page load events to show modal after navigation completes + document.addEventListener('turbo:load', this.handleTurboLoad.bind(this)) + } + } + + disconnect() { + // Clean up event listener when controller is removed + document.removeEventListener('turbo:load', this.handleTurboLoad.bind(this)) + } + + handleTurboLoad() { + if (this.showableValue) { + this.checkAndShowModal() + } + } + + checkAndShowModal() { + const MODAL_STORAGE_KEY = 'dawarich_onboarding_shown' + const hasShownModal = localStorage.getItem(MODAL_STORAGE_KEY) + + if (!hasShownModal && this.hasModalTarget) { + // Show the modal + this.modalTarget.showModal() + + // Mark as shown in local storage + localStorage.setItem(MODAL_STORAGE_KEY, 'true') + + // Add event listener to handle when modal is closed + this.modalTarget.addEventListener('close', () => { + // Modal closed - state already saved + }) + } + } +} diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index 1b13dc54..927d85e9 100644 --- a/app/javascript/maps/fog_of_war.js +++ b/app/javascript/maps/fog_of_war.js @@ -33,7 +33,12 @@ export function drawFogCanvas(map, markers, clearFogRadius, fogLineThreshold) { const size = map.getSize(); - // 1) Paint base fog + // Update canvas size if needed + if (fog.width !== size.x || fog.height !== size.y) { + fog.width = size.x; + fog.height = size.y; + } +// 1) Paint base fog ctx.clearRect(0, 0, size.x, size.y); ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.fillRect(0, 0, size.x, size.y); @@ -106,23 +111,17 @@ export function createFogOverlay() { return L.Layer.extend({ onAdd: function(map) { this._map = map; + + // Initialize storage for fog parameters + this._markers = []; + this._clearFogRadius = 50; + this._fogLineThreshold = 90; // Initialize the fog canvas initializeFogCanvas(map); - // Get the map controller to access markers and settings - const mapElement = document.getElementById('map'); - if (mapElement && mapElement._stimulus_controllers) { - const controller = mapElement._stimulus_controllers.find(c => c.identifier === 'maps'); - if (controller) { - this._controller = controller; - - // Draw initial fog if we have markers - if (controller.markers && controller.markers.length > 0) { - drawFogCanvas(map, controller.markers, controller.clearFogRadius, controller.fogLineThreshold); - } - } - } + // Fog overlay will be initialized via updateFog() call from maps controller + // No need to try to access controller data here // Add resize event handlers to update fog size this._onResize = () => { @@ -139,7 +138,31 @@ export function createFogOverlay() { } }; + // Add event handlers for zoom and pan to update fog position + this._onMoveEnd = () => { + console.log('Fog: moveend event fired'); + if (this._markers && this._markers.length > 0) { + console.log('Fog: redrawing after move with stored data'); + drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold); + } else { + console.log('Fog: no stored markers available'); + } + }; + + this._onZoomEnd = () => { + console.log('Fog: zoomend event fired'); + if (this._markers && this._markers.length > 0) { + console.log('Fog: redrawing after zoom with stored data'); + drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold); + } else { + console.log('Fog: no stored markers available'); + } + }; + + // Bind event listeners map.on('resize', this._onResize); + map.on('moveend', this._onMoveEnd); + map.on('zoomend', this._onZoomEnd); }, onRemove: function(map) { @@ -148,16 +171,28 @@ export function createFogOverlay() { fog.remove(); } - // Clean up event listener + // Clean up event listeners if (this._onResize) { map.off('resize', this._onResize); } + if (this._onMoveEnd) { + map.off('moveend', this._onMoveEnd); + } + if (this._onZoomEnd) { + map.off('zoomend', this._onZoomEnd); + } }, // Method to update fog when markers change updateFog: function(markers, clearFogRadius, fogLineThreshold) { if (this._map) { - drawFogCanvas(this._map, markers, clearFogRadius, fogLineThreshold); + // Store the updated parameters + this._markers = markers || []; + this._clearFogRadius = clearFogRadius || 50; + this._fogLineThreshold = fogLineThreshold || 90; + + console.log('Fog: updateFog called with', markers?.length || 0, 'markers'); + drawFogCanvas(this._map, this._markers, this._clearFogRadius, this._fogLineThreshold); } } }); diff --git a/app/javascript/maps/scratch_layer.js b/app/javascript/maps/scratch_layer.js new file mode 100644 index 00000000..f83844ae --- /dev/null +++ b/app/javascript/maps/scratch_layer.js @@ -0,0 +1,171 @@ +import L from "leaflet"; + +export class ScratchLayer { + constructor(map, markers, countryCodesMap, apiKey) { + this.map = map; + this.markers = markers; + this.countryCodesMap = countryCodesMap; + this.apiKey = apiKey; + this.scratchLayer = null; + this.worldBordersData = null; + } + + async setup() { + this.scratchLayer = L.geoJSON(null, { + style: { + fillColor: '#FFD700', + fillOpacity: 0.3, + color: '#FFA500', + weight: 1 + } + }); + + try { + // Up-to-date version can be found on Github: + // https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson + const worldData = await this._fetchWorldBordersData(); + + const visitedCountries = this.getVisitedCountries(); + console.log('Current visited countries:', visitedCountries); + + if (visitedCountries.length === 0) { + console.log('No visited countries found'); + return this.scratchLayer; + } + + const filteredFeatures = worldData.features.filter(feature => + visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) + ); + + console.log('Filtered features for visited countries:', filteredFeatures.length); + + this.scratchLayer.addData({ + type: 'FeatureCollection', + features: filteredFeatures + }); + } catch (error) { + console.error('Error loading GeoJSON:', error); + } + + return this.scratchLayer; + } + + async _fetchWorldBordersData() { + if (this.worldBordersData) { + return this.worldBordersData; + } + + console.log('Loading world borders data'); + const response = await fetch('/api/v1/countries/borders.json', { + headers: { + 'Accept': 'application/geo+json,application/json', + 'Authorization': `Bearer ${this.apiKey}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + this.worldBordersData = await response.json(); + return this.worldBordersData; + } + + getVisitedCountries() { + if (!this.markers) return []; + + return [...new Set( + this.markers + .filter(marker => marker[7]) // Ensure country exists + .map(marker => { + // Convert country name to ISO code, or return the original if not found + return this.countryCodesMap[marker[7]] || marker[7]; + }) + )]; + } + + toggle() { + if (!this.scratchLayer) { + console.warn('Scratch layer not initialized'); + return; + } + + if (this.map.hasLayer(this.scratchLayer)) { + this.map.removeLayer(this.scratchLayer); + } else { + this.scratchLayer.addTo(this.map); + } + } + + async refresh() { + console.log('Refreshing scratch layer with current data'); + + if (!this.scratchLayer) { + console.log('Scratch layer not initialized, setting up'); + await this.setup(); + return; + } + + try { + // Clear existing data + this.scratchLayer.clearLayers(); + + // Get current visited countries based on current markers + const visitedCountries = this.getVisitedCountries(); + console.log('Current visited countries:', visitedCountries); + + if (visitedCountries.length === 0) { + console.log('No visited countries found'); + return; + } + + // Fetch country borders data (reuse if already loaded) + const worldData = await this._fetchWorldBordersData(); + + // Filter for visited countries + const filteredFeatures = worldData.features.filter(feature => + visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) + ); + + console.log('Filtered features for visited countries:', filteredFeatures.length); + + // Add the filtered country data to the scratch layer + this.scratchLayer.addData({ + type: 'FeatureCollection', + features: filteredFeatures + }); + + } catch (error) { + console.error('Error refreshing scratch layer:', error); + } + } + + // Update markers reference when they change + updateMarkers(markers) { + this.markers = markers; + } + + // Get the Leaflet layer for use in layer controls + getLayer() { + return this.scratchLayer; + } + + // Check if layer is currently visible on map + isVisible() { + return this.scratchLayer && this.map.hasLayer(this.scratchLayer); + } + + // Remove layer from map + remove() { + if (this.scratchLayer && this.map.hasLayer(this.scratchLayer)) { + this.map.removeLayer(this.scratchLayer); + } + } + + // Add layer to map + addToMap() { + if (this.scratchLayer) { + this.scratchLayer.addTo(this.map); + } + } +} diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js index 0ceeb415..f70cf765 100644 --- a/app/javascript/maps/visits.js +++ b/app/javascript/maps/visits.js @@ -1326,44 +1326,79 @@ export class VisitsManager { // Create popup content with form and dropdown const defaultName = visit.name; const popupContent = ` -
-
-
+
+
+
${dateTimeDisplay.trim()}
-
- - Duration: ${durationText}, - - - status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)} - - ${visit.place.latitude}, ${visit.place.longitude} +
+
+ Duration: ${durationText} +
+
+ Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)} +
+
+ ${visit.place.latitude}, ${visit.place.longitude} +
-
+
+
-
- + ${possiblePlaces.length > 0 ? possiblePlaces.map(place => ` - `).join('')} + `).join('') : ` + + `}
-
- +
+ ${visit.status !== 'confirmed' ? ` - - + + ` : ''}
+
+ +
`; @@ -1374,8 +1409,9 @@ export class VisitsManager { closeOnClick: true, autoClose: true, closeOnEscapeKey: true, - maxWidth: 450, // Set maximum width - minWidth: 300 // Set minimum width + maxWidth: 420, // Set maximum width + minWidth: 320, // Set minimum width + className: 'visit-popup' // Add custom class for additional styling }) .setLatLng([visit.place.latitude, visit.place.longitude]) .setContent(popupContent); @@ -1407,6 +1443,12 @@ export class VisitsManager { const newName = event.target.querySelector('input').value; const selectedPlaceId = event.target.querySelector('select[name="place"]').value; + // Validate that we have a valid place_id + if (!selectedPlaceId || selectedPlaceId === '') { + showFlashMessage('error', 'Please select a valid location'); + return; + } + // Get the selected place name from the dropdown const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`); const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : ''; @@ -1473,9 +1515,11 @@ export class VisitsManager { // Add event listeners for confirm and decline buttons const confirmBtn = form.querySelector('.confirm-visit'); const declineBtn = form.querySelector('.decline-visit'); + const deleteBtn = form.querySelector('.delete-visit'); confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed')); declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined')); + deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id)); } } @@ -1517,6 +1561,51 @@ export class VisitsManager { } } + /** + * Handles deletion of a visit with confirmation + * @param {Event} event - The click event + * @param {string} visitId - The visit ID to delete + */ + async handleDeleteVisit(event, visitId) { + event.preventDefault(); + event.stopPropagation(); + + // Show confirmation dialog + const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.'); + + if (!confirmDelete) { + return; + } + + try { + const response = await fetch(`/api/v1/visits/${visitId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + } + }); + + if (response.ok) { + // Close the popup + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + + // Refresh the visits list + this.fetchAndDisplayVisits(); + showFlashMessage('notice', 'Visit deleted successfully'); + } else { + const errorData = await response.json(); + const errorMessage = errorData.error || 'Failed to delete visit'; + showFlashMessage('error', errorMessage); + } + } catch (error) { + console.error('Error deleting visit:', error); + showFlashMessage('error', 'Failed to delete visit'); + } + } + /** * Truncates text to a specified length and adds ellipsis if needed * @param {string} text - The text to truncate diff --git a/app/javascript/styles/visits.css b/app/javascript/styles/visits.css index c43cb036..5852d002 100644 --- a/app/javascript/styles/visits.css +++ b/app/javascript/styles/visits.css @@ -15,3 +15,42 @@ .merge-visits-button { margin: 8px 0; } + +/* Visit popup styling */ +.visit-popup .leaflet-popup-content-wrapper { + border-radius: 0.5rem; + border: none; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + padding: 0; + overflow: hidden; +} + +.visit-popup .leaflet-popup-content { + margin: 0; + line-height: 1.5; +} + +.visit-popup .leaflet-popup-tip { + border-top-color: hsl(var(--b1)); +} + +.visit-popup .leaflet-popup-close-button { + color: hsl(var(--bc)) !important; + font-size: 18px !important; + font-weight: bold !important; + top: 8px !important; + right: 8px !important; + width: 24px !important; + height: 24px !important; + text-align: center !important; + line-height: 24px !important; + background: hsl(var(--b2)) !important; + border-radius: 50% !important; + border: none !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; +} + +.visit-popup .leaflet-popup-close-button:hover { + background: hsl(var(--b3)) !important; + color: hsl(var(--bc)) !important; +} diff --git a/app/jobs/bulk_visits_suggesting_job.rb b/app/jobs/bulk_visits_suggesting_job.rb index 4384be6a..e52b06da 100644 --- a/app/jobs/bulk_visits_suggesting_job.rb +++ b/app/jobs/bulk_visits_suggesting_job.rb @@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob users.active.find_each do |user| next unless user.safe_settings.visits_suggestions_enabled? - next if user.tracked_points.empty? + next unless user.points_count.positive? schedule_chunked_jobs(user, time_chunks) end diff --git a/app/jobs/data_migrations/migrate_points_latlon_job.rb b/app/jobs/data_migrations/migrate_points_latlon_job.rb index 90dfa096..a74be609 100644 --- a/app/jobs/data_migrations/migrate_points_latlon_job.rb +++ b/app/jobs/data_migrations/migrate_points_latlon_job.rb @@ -7,7 +7,7 @@ class DataMigrations::MigratePointsLatlonJob < ApplicationJob user = User.find(user_id) # rubocop:disable Rails/SkipsModelValidations - user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)') + user.points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)') # rubocop:enable Rails/SkipsModelValidations end end diff --git a/app/jobs/data_migrations/prefill_points_counter_cache_job.rb b/app/jobs/data_migrations/prefill_points_counter_cache_job.rb new file mode 100644 index 00000000..fe3715ab --- /dev/null +++ b/app/jobs/data_migrations/prefill_points_counter_cache_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class DataMigrations::PrefillPointsCounterCacheJob < ApplicationJob + queue_as :data_migrations + + def perform(user_id = nil) + if user_id + prefill_counter_for_user(user_id) + else + User.find_each(batch_size: 100) do |user| + prefill_counter_for_user(user.id) + end + end + end + + private + + def prefill_counter_for_user(user_id) + User.reset_counters(user_id, :points) + rescue ActiveRecord::RecordNotFound + Rails.logger.warn "User #{user_id} not found, skipping counter cache update" + end +end diff --git a/app/jobs/tracks/cleanup_job.rb b/app/jobs/tracks/cleanup_job.rb index 82eae62d..ad1a8e29 100644 --- a/app/jobs/tracks/cleanup_job.rb +++ b/app/jobs/tracks/cleanup_job.rb @@ -23,9 +23,9 @@ class Tracks::CleanupJob < ApplicationJob private def users_with_old_untracked_points(older_than) - User.active.joins(:tracked_points) - .where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i }) - .having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks + User.active.joins(:points) + .where(points: { track_id: nil, timestamp: ..older_than.to_i }) + .having('COUNT(points.id) >= 2') # Only users with enough points for tracks .group(:id) end end diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb new file mode 100644 index 00000000..bbce993f --- /dev/null +++ b/app/jobs/users/mailer_sending_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Users::MailerSendingJob < ApplicationJob + queue_as :mailers + + def perform(user_id, email_type, **options) + user = User.find(user_id) + + if trial_related_email?(email_type) && user.active? + Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed" + return + end + + params = { user: user }.merge(options) + + UsersMailer.with(params).public_send(email_type).deliver_later + end + + private + + def trial_related_email?(email_type) + %w[trial_expires_soon trial_expired].include?(email_type.to_s) + end +end diff --git a/app/jobs/users/trial_webhook_job.rb b/app/jobs/users/trial_webhook_job.rb new file mode 100644 index 00000000..512dd075 --- /dev/null +++ b/app/jobs/users/trial_webhook_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Users::TrialWebhookJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find(user_id) + + payload = { + user_id: user.id, + email: user.email, + active_until: user.active_until, + status: user.status, + action: 'create_user' + } + + token = Subscription::EncodeJwtToken.new(payload, ENV['JWT_SECRET_KEY']).call + + request_url = "#{ENV['MANAGER_URL']}/api/v1/users" + headers = { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + } + + HTTParty.post(request_url, headers: headers, body: { token: token }.to_json) + end +end diff --git a/app/mailers/users_mailer.rb b/app/mailers/users_mailer.rb new file mode 100644 index 00000000..c7293a75 --- /dev/null +++ b/app/mailers/users_mailer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class UsersMailer < ApplicationMailer + def welcome + @user = params[:user] + + mail(to: @user.email, subject: 'Welcome to Dawarich!') + end + + def explore_features + @user = params[:user] + + mail(to: @user.email, subject: 'Explore Dawarich features!') + end + + def trial_expires_soon + @user = params[:user] + + mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days') + end + + def trial_expired + @user = params[:user] + + mail(to: @user.email, subject: 'πŸ’” Your Dawarich trial expired') + end +end diff --git a/app/models/concerns/calculateable.rb b/app/models/concerns/calculateable.rb index 31e4ff53..12caeac2 100644 --- a/app/models/concerns/calculateable.rb +++ b/app/models/concerns/calculateable.rb @@ -10,6 +10,7 @@ module Calculateable def calculate_distance calculated_distance_meters = calculate_distance_from_coordinates + self.distance = convert_distance_for_storage(calculated_distance_meters) end diff --git a/app/models/import.rb b/app/models/import.rb index d22d5174..8635f2a9 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -13,6 +13,7 @@ class Import < ApplicationRecord after_commit :remove_attached_file, on: :destroy validates :name, presence: true, uniqueness: { scope: :user_id } + validate :file_size_within_limit, if: -> { user.trial? } enum :status, { created: 0, processing: 1, completed: 2, failed: 3 } @@ -20,7 +21,7 @@ class Import < ApplicationRecord google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7, user_data_archive: 8 - } + }, allow_nil: true def process! if user_data_archive? @@ -58,4 +59,12 @@ class Import < ApplicationRecord def remove_attached_file file.purge_later end + + def file_size_within_limit + return unless file.attached? + + if file.blob.byte_size > 11.megabytes + errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.') + end + end end diff --git a/app/models/point.rb b/app/models/point.rb index ef00e99b..69e87681 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -6,7 +6,7 @@ class Point < ApplicationRecord belongs_to :import, optional: true, counter_cache: true belongs_to :visit, optional: true - belongs_to :user + belongs_to :user, counter_cache: true belongs_to :country, optional: true belongs_to :track, optional: true diff --git a/app/models/stat.rb b/app/models/stat.rb index 2cf26d04..c69be6d0 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -24,7 +24,7 @@ class Stat < ApplicationRecord end def points - user.tracked_points + user.points .without_raw_data .where(timestamp: timespan) .order(timestamp: :asc) diff --git a/app/models/trip.rb b/app/models/trip.rb index 7ba14ad5..fca5e1e2 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -18,13 +18,7 @@ class Trip < ApplicationRecord end def points - user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) - end - - def countries - return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata? - - visited_countries + user.points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) end def photo_previews @@ -35,13 +29,8 @@ class Trip < ApplicationRecord @photo_sources ||= photos.map { _1[:source] }.uniq end - - def calculate_countries - countries = - Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name) - - self.visited_countries = countries + self.visited_countries = points.pluck(:country_name).uniq.compact end private diff --git a/app/models/user.rb b/app/models/user.rb index 4c61d98e..96d3e3a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,23 +1,24 @@ # frozen_string_literal: true -class User < ApplicationRecord +class User < ApplicationRecord # rubocop:disable Metrics/ClassLength devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable - has_many :tracked_points, class_name: 'Point', dependent: :destroy + has_many :points, dependent: :destroy, counter_cache: true has_many :imports, dependent: :destroy has_many :stats, dependent: :destroy has_many :exports, dependent: :destroy has_many :notifications, dependent: :destroy has_many :areas, dependent: :destroy has_many :visits, dependent: :destroy - has_many :points, through: :imports has_many :places, through: :visits has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } + after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? } + before_save :sanitize_input validates :email, presence: true @@ -25,15 +26,16 @@ class User < ApplicationRecord validates :reset_password_token, uniqueness: true, allow_nil: true attribute :admin, :boolean, default: false + attribute :points_count, :integer, default: 0 - enum :status, { inactive: 0, active: 1 } + enum :status, { inactive: 0, active: 1, trial: 2 } def safe_settings Users::SafeSettings.new(settings) end def countries_visited - tracked_points + points .where.not(country_name: [nil, '']) .distinct .pluck(:country_name) @@ -41,7 +43,7 @@ class User < ApplicationRecord end def cities_visited - tracked_points.where.not(city: [nil, '']).distinct.pluck(:city).compact + points.where.not(city: [nil, '']).distinct.pluck(:city).compact end def total_distance @@ -58,11 +60,11 @@ class User < ApplicationRecord end def total_reverse_geocoded_points - tracked_points.where.not(reverse_geocoded_at: nil).count + points.where.not(reverse_geocoded_at: nil).count end def total_reverse_geocoded_points_without_data - tracked_points.where(geodata: {}).count + points.where(geodata: {}).count end def immich_integration_configured? @@ -96,7 +98,7 @@ class User < ApplicationRecord end def can_subscribe? - (active_until.nil? || active_until&.past?) && !DawarichSettings.self_hosted? + (trial? || !active_until&.future?) && !DawarichSettings.self_hosted? end def generate_subscription_token @@ -115,6 +117,10 @@ class User < ApplicationRecord Users::ExportDataJob.perform_later(id) end + def trial_state? + (points_count || 0).zero? && trial? + end + private def create_api_key @@ -124,7 +130,6 @@ class User < ApplicationRecord end def activate - # TODO: Remove the `status` column in the future. update(status: :active, active_until: 1000.years.from_now) end @@ -133,4 +138,18 @@ class User < ApplicationRecord settings['photoprism_url']&.gsub!(%r{/+\z}, '') settings.try(:[], 'maps')&.try(:[], 'url')&.strip! end + + def start_trial + update(status: :trial, active_until: 7.days.from_now) + schedule_welcome_emails + + Users::TrialWebhookJob.perform_later(id) + end + + def schedule_welcome_emails + Users::MailerSendingJob.perform_later(id, 'welcome') + Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features') + Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon') + Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired') + end end diff --git a/app/models/visit.rb b/app/models/visit.rb index 794cfe06..936fdc7d 100644 --- a/app/models/visit.rb +++ b/app/models/visit.rb @@ -10,6 +10,8 @@ class Visit < ApplicationRecord validates :started_at, :ended_at, :duration, :name, :status, presence: true + validates :ended_at, comparison: { greater_than: :started_at } + enum :status, { suggested: 0, confirmed: 1, declined: 2 } def coordinates diff --git a/app/policies/import_policy.rb b/app/policies/import_policy.rb index 0d1ceddf..fcaa2347 100644 --- a/app/policies/import_policy.rb +++ b/app/policies/import_policy.rb @@ -11,13 +11,13 @@ class ImportPolicy < ApplicationPolicy user.present? && record.user == user end - # Users can create new imports if they are active + # Users can create new imports if they are active or trial def new? create? end def create? - user.present? && user.active? + user.present? && (user.active? || user.trial?) end # Users can only edit their own imports diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb index c0a1119a..c21e9c6f 100644 --- a/app/serializers/api/photo_serializer.rb +++ b/app/serializers/api/photo_serializer.rb @@ -68,6 +68,8 @@ class Api::PhotoSerializer photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape' when 'photoprism' photo['Portrait'] ? 'portrait' : 'landscape' + else + 'landscape' # default orientation for nil or unknown source end end end diff --git a/app/serializers/stats_serializer.rb b/app/serializers/stats_serializer.rb index 3a35f157..ae66afbf 100644 --- a/app/serializers/stats_serializer.rb +++ b/app/serializers/stats_serializer.rb @@ -10,7 +10,7 @@ class StatsSerializer def call { totalDistanceKm: total_distance_km, - totalPointsTracked: user.tracked_points.count, + totalPointsTracked: user.points_count, totalReverseGeocodedPoints: reverse_geocoded_points, totalCountriesVisited: user.countries_visited.count, totalCitiesVisited: user.cities_visited.count, @@ -27,7 +27,7 @@ class StatsSerializer end def reverse_geocoded_points - user.tracked_points.reverse_geocoded.count + user.points.reverse_geocoded.count end def yearly_stats diff --git a/app/services/exports/create.rb b/app/services/exports/create.rb index d885afb8..0c2feb76 100644 --- a/app/services/exports/create.rb +++ b/app/services/exports/create.rb @@ -35,7 +35,7 @@ class Exports::Create def time_framed_points user - .tracked_points + .points .where(timestamp: start_at.to_i..end_at.to_i) .order(timestamp: :asc) end diff --git a/app/services/geojson/importer.rb b/app/services/geojson/importer.rb index 9967cd49..94230047 100644 --- a/app/services/geojson/importer.rb +++ b/app/services/geojson/importer.rb @@ -2,19 +2,19 @@ class Geojson::Importer include Imports::Broadcaster + include Imports::FileLoader include PointValidation - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - json = Oj.load(file_content) - + json = load_json_data data = Geojson::Params.new(json).call data.each.with_index(1) do |point, index| diff --git a/app/services/google_maps/phone_takeout_importer.rb b/app/services/google_maps/phone_takeout_importer.rb index 90f75f72..51cfda5c 100644 --- a/app/services/google_maps/phone_takeout_importer.rb +++ b/app/services/google_maps/phone_takeout_importer.rb @@ -2,12 +2,14 @@ class GoogleMaps::PhoneTakeoutImporter include Imports::Broadcaster + include Imports::FileLoader - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call @@ -46,9 +48,7 @@ class GoogleMaps::PhoneTakeoutImporter raw_signals = [] raw_array = [] - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - - json = Oj.load(file_content) + json = load_json_data if json.is_a?(Array) raw_array = parse_raw_array(json) diff --git a/app/services/google_maps/records_storage_importer.rb b/app/services/google_maps/records_storage_importer.rb index 28c80bc8..f47c15fd 100644 --- a/app/services/google_maps/records_storage_importer.rb +++ b/app/services/google_maps/records_storage_importer.rb @@ -4,11 +4,14 @@ # via the UI, vs the CLI, which uses the `GoogleMaps::RecordsImporter` class. class GoogleMaps::RecordsStorageImporter + include Imports::FileLoader + BATCH_SIZE = 1000 - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user = User.find_by(id: user_id) + @file_path = file_path end def call @@ -20,21 +23,16 @@ class GoogleMaps::RecordsStorageImporter private - attr_reader :import, :user + attr_reader :import, :user, :file_path def process_file_in_batches - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - locations = parse_file(file_content) + parsed_file = load_json_data + return unless parsed_file.is_a?(Hash) && parsed_file['locations'] + + locations = parsed_file['locations'] process_locations_in_batches(locations) if locations.present? end - def parse_file(file_content) - parsed_file = Oj.load(file_content, mode: :compat) - return nil unless parsed_file.is_a?(Hash) && parsed_file['locations'] - - parsed_file['locations'] - end - def process_locations_in_batches(locations) batch = [] index = 0 diff --git a/app/services/google_maps/semantic_history_importer.rb b/app/services/google_maps/semantic_history_importer.rb index ae6209b4..e5eeb0b9 100644 --- a/app/services/google_maps/semantic_history_importer.rb +++ b/app/services/google_maps/semantic_history_importer.rb @@ -2,13 +2,15 @@ class GoogleMaps::SemanticHistoryImporter include Imports::Broadcaster + include Imports::FileLoader BATCH_SIZE = 1000 - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path @current_index = 0 end @@ -61,8 +63,7 @@ class GoogleMaps::SemanticHistoryImporter end def points_data - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - json = Oj.load(file_content) + json = load_json_data json['timelineObjects'].flat_map do |timeline_object| parse_timeline_object(timeline_object) diff --git a/app/services/gpx/track_importer.rb b/app/services/gpx/track_importer.rb index e0207292..2a25cc99 100644 --- a/app/services/gpx/track_importer.rb +++ b/app/services/gpx/track_importer.rb @@ -4,16 +4,18 @@ require 'rexml/document' class Gpx::TrackImporter include Imports::Broadcaster + include Imports::FileLoader - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification + file_content = load_file_content json = Hash.from_xml(file_content) tracks = json['gpx']['trk'] diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index d86fe337..58079188 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -14,17 +14,33 @@ class Imports::Create import.update!(status: :processing) broadcast_status_update - importer(import.source).new(import, user.id).call + temp_file_path = Imports::SecureFileDownloader.new(import.file).download_to_temp_file + + source = if import.source.nil? || should_detect_source? + detect_source_from_file(temp_file_path) + else + import.source + end + + import.update!(source: source) + importer(source).new(import, user.id, temp_file_path).call schedule_stats_creating(user.id) schedule_visit_suggesting(user.id, import) update_import_points_count(import) + User.reset_counters(user.id, :points) rescue StandardError => e import.update!(status: :failed) broadcast_status_update + ExceptionReporter.call(e, 'Import failed') + create_import_failed_notification(import, user, e) ensure + if temp_file_path && File.exist?(temp_file_path) + File.unlink(temp_file_path) + end + if import.processing? import.update!(status: :completed) broadcast_status_update @@ -34,7 +50,9 @@ class Imports::Create private def importer(source) - case source + raise ArgumentError, 'Import source cannot be nil' if source.nil? + + case source.to_s when 'google_semantic_history' then GoogleMaps::SemanticHistoryImporter when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutImporter when 'google_records' then GoogleMaps::RecordsStorageImporter @@ -42,6 +60,8 @@ class Imports::Create when 'gpx' then Gpx::TrackImporter when 'geojson' then Geojson::Importer when 'immich_api', 'photoprism_api' then Photos::Importer + else + raise ArgumentError, "Unsupported source: #{source}" end end @@ -56,7 +76,12 @@ class Imports::Create end def schedule_visit_suggesting(user_id, import) + return unless user.safe_settings.visits_suggestions_enabled? + points = import.points.order(:timestamp) + + return if points.none? + start_at = Time.zone.at(points.first.timestamp) end_at = Time.zone.at(points.last.timestamp) @@ -74,6 +99,17 @@ class Imports::Create ).call end + def should_detect_source? + # Don't override API-based sources that can't be reliably detected + !%w[immich_api photoprism_api].include?(import.source) + end + + def detect_source_from_file(temp_file_path) + detector = Imports::SourceDetector.new_from_file_header(temp_file_path) + + detector.detect_source! + end + def import_failed_message(import, error) if DawarichSettings.self_hosted? "Import \"#{import.name}\" failed: #{error.message}, stacktrace: #{error.backtrace.join("\n")}" diff --git a/app/services/imports/file_loader.rb b/app/services/imports/file_loader.rb new file mode 100644 index 00000000..b26d4188 --- /dev/null +++ b/app/services/imports/file_loader.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Imports + module FileLoader + extend ActiveSupport::Concern + + private + + def load_json_data + if file_path && File.exist?(file_path) + Oj.load_file(file_path, mode: :compat) + else + file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification + Oj.load(file_content, mode: :compat) + end + end + + def load_file_content + if file_path && File.exist?(file_path) + File.read(file_path) + else + Imports::SecureFileDownloader.new(import.file).download_with_verification + end + end + end +end diff --git a/app/services/imports/secure_file_downloader.rb b/app/services/imports/secure_file_downloader.rb index f4bd2091..d92e64be 100644 --- a/app/services/imports/secure_file_downloader.rb +++ b/app/services/imports/secure_file_downloader.rb @@ -9,6 +9,63 @@ class Imports::SecureFileDownloader end def download_with_verification + file_content = download_to_string + verify_file_integrity(file_content) + file_content + end + + def download_to_temp_file + retries = 0 + temp_file = nil + + begin + Timeout.timeout(DOWNLOAD_TIMEOUT) do + temp_file = create_temp_file + + # Download directly to temp file + storage_attachment.download do |chunk| + temp_file.write(chunk) + end + temp_file.rewind + + # If file is empty, try alternative download method + if temp_file.size == 0 + Rails.logger.warn('No content received from block download, trying alternative method') + temp_file.write(storage_attachment.blob.download) + temp_file.rewind + end + end + rescue Timeout::Error => e + retries += 1 + if retries <= MAX_RETRIES + Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}") + cleanup_temp_file(temp_file) + retry + else + Rails.logger.error("Download failed after #{MAX_RETRIES} attempts") + cleanup_temp_file(temp_file) + raise + end + rescue StandardError => e + Rails.logger.error("Download error: #{e.message}") + cleanup_temp_file(temp_file) + raise + end + + raise 'Download completed but no content was received' if temp_file.size == 0 + + verify_temp_file_integrity(temp_file) + temp_file.path + ensure + # Keep temp file open so it can be read by other processes + # Caller is responsible for cleanup + end + + private + + attr_reader :storage_attachment + + def download_to_string retries = 0 file_content = nil @@ -51,13 +108,23 @@ class Imports::SecureFileDownloader raise 'Download completed but no content was received' if file_content.nil? || file_content.empty? - verify_file_integrity(file_content) file_content end - private + def create_temp_file + extension = File.extname(storage_attachment.filename.to_s) + basename = File.basename(storage_attachment.filename.to_s, extension) + Tempfile.new(["#{basename}_#{Time.now.to_i}", extension], binmode: true) + end - attr_reader :storage_attachment + def cleanup_temp_file(temp_file) + return unless temp_file + + temp_file.close unless temp_file.closed? + temp_file.unlink if File.exist?(temp_file.path) + rescue StandardError => e + Rails.logger.warn("Failed to cleanup temp file: #{e.message}") + end def verify_file_integrity(file_content) return if file_content.nil? || file_content.empty? @@ -78,4 +145,26 @@ class Imports::SecureFileDownloader raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}" end + + def verify_temp_file_integrity(temp_file) + return if temp_file.nil? || temp_file.size == 0 + + # Verify file size + expected_size = storage_attachment.blob.byte_size + actual_size = temp_file.size + + if expected_size != actual_size + raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes" + end + + # Verify checksum + expected_checksum = storage_attachment.blob.checksum + temp_file.rewind + actual_checksum = Base64.strict_encode64(Digest::MD5.digest(temp_file.read)) + temp_file.rewind + + return unless expected_checksum != actual_checksum + + raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}" + end end diff --git a/app/services/imports/source_detector.rb b/app/services/imports/source_detector.rb new file mode 100644 index 00000000..7acbb081 --- /dev/null +++ b/app/services/imports/source_detector.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +class Imports::SourceDetector + class UnknownSourceError < StandardError; end + + DETECTION_RULES = { + google_semantic_history: { + required_keys: ['timelineObjects'], + nested_patterns: [ + ['timelineObjects', 0, 'activitySegment'], + ['timelineObjects', 0, 'placeVisit'] + ] + }, + google_records: { + required_keys: ['locations'], + nested_patterns: [ + ['locations', 0, 'latitudeE7'], + ['locations', 0, 'longitudeE7'] + ] + }, + google_phone_takeout: { + alternative_patterns: [ + # Pattern 1: Object with semanticSegments + { + required_keys: ['semanticSegments'], + nested_patterns: [['semanticSegments', 0, 'startTime']] + }, + # Pattern 2: Object with rawSignals + { + required_keys: ['rawSignals'] + }, + # Pattern 3: Array format with visit/activity objects + { + structure: :array, + nested_patterns: [ + [0, 'visit', 'topCandidate', 'placeLocation'], + [0, 'activity'] + ] + } + ] + }, + geojson: { + required_keys: ['type', 'features'], + required_values: { 'type' => 'FeatureCollection' }, + nested_patterns: [ + ['features', 0, 'type'], + ['features', 0, 'geometry'], + ['features', 0, 'properties'] + ] + }, + owntracks: { + structure: :rec_file_lines, + line_pattern: /"_type":"location"/ + } + }.freeze + + def initialize(file_content, filename = nil, file_path = nil) + @file_content = file_content + @filename = filename + @file_path = file_path + end + + def self.new_from_file_header(file_path) + filename = File.basename(file_path) + + # For detection, read only first 2KB to optimize performance + header_content = File.open(file_path, 'rb') { |f| f.read(2048) } + + new(header_content, filename, file_path) + end + + def detect_source + return :gpx if gpx_file? + return :owntracks if owntracks_file? + + json_data = parse_json + return nil unless json_data + + DETECTION_RULES.each do |format, rules| + next if format == :owntracks # Already handled above + + if matches_format?(json_data, rules) + return format + end + end + + nil + end + + def detect_source! + format = detect_source + raise UnknownSourceError, 'Unable to detect file format' unless format + + format + end + + private + + attr_reader :file_content, :filename, :file_path + + def gpx_file? + return false unless filename + + # Must have .gpx extension AND contain GPX XML structure + return false unless filename.downcase.end_with?('.gpx') + + # Check content for GPX structure + content_to_check = if file_path && File.exist?(file_path) + # Read first 1KB for GPX detection + File.open(file_path, 'rb') { |f| f.read(1024) } + else + file_content + end + + content_to_check.strip.start_with?('= current.length + current = current[key] + elsif current.is_a?(Hash) + return false unless current.key?(key) + current = current[key] + else + return false + end + end + + !current.nil? + end +end diff --git a/app/services/imports/watcher.rb b/app/services/imports/watcher.rb index 79e0a59c..6467ec06 100644 --- a/app/services/imports/watcher.rb +++ b/app/services/imports/watcher.rb @@ -70,12 +70,14 @@ class Imports::Watcher end def mime_type(source) - case source.to_sym + case source&.to_sym when :gpx then 'application/xml' when :json, :geojson, :google_phone_takeout, :google_records, :google_semantic_history 'application/json' when :owntracks 'application/octet-stream' + when nil + 'application/octet-stream' # fallback MIME type for nil source else raise UnsupportedSourceError, "Unsupported source: #{source}" end diff --git a/app/services/jobs/create.rb b/app/services/jobs/create.rb index d4176652..114a6cf4 100644 --- a/app/services/jobs/create.rb +++ b/app/services/jobs/create.rb @@ -14,9 +14,9 @@ class Jobs::Create points = case job_name when 'start_reverse_geocoding' - user.tracked_points + user.points when 'continue_reverse_geocoding' - user.tracked_points.not_reverse_geocoded + user.points.not_reverse_geocoded else raise InvalidJobName, 'Invalid job name' end diff --git a/app/services/own_tracks/importer.rb b/app/services/own_tracks/importer.rb index bc63f5f6..70fcf2e4 100644 --- a/app/services/own_tracks/importer.rb +++ b/app/services/own_tracks/importer.rb @@ -2,16 +2,18 @@ class OwnTracks::Importer include Imports::Broadcaster + include Imports::FileLoader - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification + file_content = load_file_content parsed_data = OwnTracks::RecParser.new(file_content).call points_data = parsed_data.map do |point| diff --git a/app/services/photos/importer.rb b/app/services/photos/importer.rb index f3ce8fc4..e307b6b1 100644 --- a/app/services/photos/importer.rb +++ b/app/services/photos/importer.rb @@ -2,17 +2,18 @@ class Photos::Importer include Imports::Broadcaster + include Imports::FileLoader include PointValidation - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - json = Oj.load(file_content) + json = load_json_data json.each.with_index(1) { |point, index| create_point(point, index) } end diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb index 4f927aad..c7046a2d 100644 --- a/app/services/photos/thumbnail.rb +++ b/app/services/photos/thumbnail.rb @@ -10,7 +10,8 @@ class Photos::Thumbnail end def call - raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source) + raise ArgumentError, 'Photo source cannot be nil' if source.nil? + unsupported_source_error unless SUPPORTED_SOURCES.include?(source) HTTParty.get(request_url, headers: headers) end diff --git a/app/services/points_limit_exceeded.rb b/app/services/points_limit_exceeded.rb index 2bf8de8a..21cb802a 100644 --- a/app/services/points_limit_exceeded.rb +++ b/app/services/points_limit_exceeded.rb @@ -9,7 +9,7 @@ class PointsLimitExceeded return false if DawarichSettings.self_hosted? Rails.cache.fetch(cache_key, expires_in: 1.day) do - @user.tracked_points.count >= points_limit + @user.points_count >= points_limit end end diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index 91c2a1d7..d05bafb3 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -47,7 +47,7 @@ class Stats::CalculateMonth return @points if defined?(@points) @points = user - .tracked_points + .points .without_raw_data .where(timestamp: start_timestamp..end_timestamp) .select(:lonlat, :timestamp) @@ -60,7 +60,7 @@ class Stats::CalculateMonth def toponyms toponym_points = user - .tracked_points + .points .without_raw_data .where(timestamp: start_timestamp..end_timestamp) .select(:city, :country_name) diff --git a/app/services/subscription/encode_jwt_token.rb b/app/services/subscription/encode_jwt_token.rb new file mode 100644 index 00000000..77c9e898 --- /dev/null +++ b/app/services/subscription/encode_jwt_token.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Subscription::EncodeJwtToken + def initialize(payload, secret_key) + @payload = payload + @secret_key = secret_key + end + + def call + JWT.encode( + @payload, + @secret_key, + 'HS256' + ) + end +end diff --git a/app/services/tracks/generator.rb b/app/services/tracks/generator.rb index be22d021..1993477f 100644 --- a/app/services/tracks/generator.rb +++ b/app/services/tracks/generator.rb @@ -86,7 +86,7 @@ class Tracks::Generator end def load_bulk_points - scope = user.tracked_points.order(:timestamp) + scope = user.points.order(:timestamp) scope = scope.where(timestamp: timestamp_range) if time_range_defined? scope @@ -95,7 +95,7 @@ class Tracks::Generator def load_incremental_points # For incremental mode, we process untracked points # If end_at is specified, only process points up to that time - scope = user.tracked_points.where(track_id: nil).order(:timestamp) + scope = user.points.where(track_id: nil).order(:timestamp) scope = scope.where(timestamp: ..end_at.to_i) if end_at.present? scope @@ -104,7 +104,7 @@ class Tracks::Generator def load_daily_points day_range = daily_time_range - user.tracked_points.where(timestamp: day_range).order(:timestamp) + user.points.where(timestamp: day_range).order(:timestamp) end def create_track_from_segment(segment_data) @@ -195,8 +195,8 @@ class Tracks::Generator def bulk_timestamp_range return [start_at.to_i, end_at.to_i] if start_at && end_at - first_point = user.tracked_points.order(:timestamp).first - last_point = user.tracked_points.order(:timestamp).last + first_point = user.points.order(:timestamp).first + last_point = user.points.order(:timestamp).last [first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i] end @@ -207,7 +207,7 @@ class Tracks::Generator end def incremental_timestamp_range - first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first + first_point = user.points.where(track_id: nil).order(:timestamp).first end_timestamp = end_at ? end_at.to_i : Time.current.to_i [first_point&.timestamp || 0, end_timestamp] diff --git a/app/services/tracks/incremental_processor.rb b/app/services/tracks/incremental_processor.rb index 62c1faed..f02305a8 100644 --- a/app/services/tracks/incremental_processor.rb +++ b/app/services/tracks/incremental_processor.rb @@ -50,7 +50,7 @@ class Tracks::IncrementalProcessor def find_previous_point @previous_point ||= - user.tracked_points + user.points .where('timestamp < ?', new_point.timestamp) .order(:timestamp) .last diff --git a/app/services/tracks/track_builder.rb b/app/services/tracks/track_builder.rb index 0ccd82b0..a988f3bf 100644 --- a/app/services/tracks/track_builder.rb +++ b/app/services/tracks/track_builder.rb @@ -59,7 +59,8 @@ module Tracks::TrackBuilder original_path: build_path(points) ) - track.distance = pre_calculated_distance.round + # TODO: Move trips attrs to columns with more precision and range + track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max track.duration = calculate_duration(points) track.avg_speed = calculate_average_speed(track.distance, track.duration) @@ -99,8 +100,10 @@ module Tracks::TrackBuilder # Speed in meters per second, then convert to km/h for storage speed_mps = distance_in_meters.to_f / duration_seconds + speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h - (speed_mps * 3.6).round(2) # m/s to km/h + # Cap the speed to prevent database precision overflow (max 999999.99) + [speed_kmh, 999999.99].min end def calculate_elevation_stats(points) diff --git a/app/services/users/export_data.rb b/app/services/users/export_data.rb index dbe4f33b..7ebbf0a1 100644 --- a/app/services/users/export_data.rb +++ b/app/services/users/export_data.rb @@ -331,7 +331,7 @@ class Users::ExportData trips: user.trips.count, stats: user.stats.count, notifications: user.notifications.count, - points: user.tracked_points.count, + points: user.points_count, visits: user.visits.count, places: user.places.count } diff --git a/app/services/visits/create.rb b/app/services/visits/create.rb new file mode 100644 index 00000000..c2f47310 --- /dev/null +++ b/app/services/visits/create.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Visits + class Create + attr_reader :user, :params, :errors, :visit + + def initialize(user, params) + @user = user + @params = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params + @visit = nil + @errors = nil + end + + def call + ActiveRecord::Base.transaction do + place = find_or_create_place + return false unless place + + visit = create_visit(place) + visit + end + rescue ActiveRecord::RecordInvalid => e + ExceptionReporter.call(e, "Failed to create visit: #{e.message}") + + @errors = "Failed to create visit: #{e.message}" + + false + rescue StandardError => e + ExceptionReporter.call(e, "Failed to create visit: #{e.message}") + + @errors = "Failed to create visit: #{e.message}" + false + end + + private + + def find_or_create_place + existing_place = find_existing_place + + return existing_place if existing_place + + create_new_place + end + + def find_existing_place + Place.joins("JOIN visits ON places.id = visits.place_id") + .where(visits: { user: user }) + .where( + "ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)", + params[:longitude].to_f, params[:latitude].to_f, 0.001 # approximately 100 meters + ).first + end + + def create_new_place + place_name = params[:name] + lat_f = params[:latitude].to_f + lon_f = params[:longitude].to_f + + place = Place.create!( + name: place_name, + latitude: lat_f, + longitude: lon_f, + lonlat: "POINT(#{lon_f} #{lat_f})", + source: :manual + ) + + place + rescue StandardError => e + ExceptionReporter.call(e, "Failed to create place: #{e.message}") + nil + end + + def create_visit(place) + started_at = DateTime.parse(params[:started_at]) + ended_at = DateTime.parse(params[:ended_at]) + duration_minutes = (ended_at - started_at) * 24 * 60 + + @visit = user.visits.create!( + name: params[:name], + place: place, + started_at: started_at, + ended_at: ended_at, + duration: duration_minutes.to_i, + status: :confirmed + ) + + @visit + end + end +end diff --git a/app/services/visits/place_finder.rb b/app/services/visits/place_finder.rb index 86f0a547..e2f3a3ab 100644 --- a/app/services/visits/place_finder.rb +++ b/app/services/visits/place_finder.rb @@ -114,7 +114,7 @@ module Visits # Look for existing place with this name existing = Place.where(name: name) - .near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m) + .near([point.lat, point.lon], SIMILARITY_RADIUS, :m) .first return existing if existing @@ -122,9 +122,9 @@ module Visits # Create new place place = Place.new( name: name, - lonlat: "POINT(#{point.longitude} #{point.latitude})", - latitude: point.latitude, - longitude: point.longitude, + lonlat: "POINT(#{point.lon} #{point.lat})", + latitude: point.lat, + longitude: point.lon, city: properties['city'], country: properties['country'], geodata: point.geodata, diff --git a/app/services/visits/smart_detect.rb b/app/services/visits/smart_detect.rb index 64d66440..828c364d 100644 --- a/app/services/visits/smart_detect.rb +++ b/app/services/visits/smart_detect.rb @@ -13,7 +13,7 @@ module Visits @user = user @start_at = start_at.to_i @end_at = end_at.to_i - @points = user.tracked_points.not_visited + @points = user.points.not_visited .order(timestamp: :asc) .where(timestamp: start_at..end_at) end diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb index 7aab6b93..b40853fb 100644 --- a/app/services/visits/suggest.rb +++ b/app/services/visits/suggest.rb @@ -6,7 +6,7 @@ class Visits::Suggest def initialize(user, start_at:, end_at:) @start_at = start_at.to_i @end_at = end_at.to_i - @points = user.tracked_points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at) + @points = user.points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at) @user = user end diff --git a/app/views/devise/registrations/_api_key.html.erb b/app/views/devise/registrations/_api_key.html.erb index c04b7b85..aeba5bfd 100644 --- a/app/views/devise/registrations/_api_key.html.erb +++ b/app/views/devise/registrations/_api_key.html.erb @@ -1,6 +1,14 @@

Use this API key to authenticate your requests.

<%= current_user.api_key %> + + <% if ENV['QR_CODE_ENABLED'] == 'true' %> +

+ Or you can scan it in your Dawarich iOS app: + <%= api_key_qr_code(current_user) %> +

+ <% end %> +

Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %>

@@ -20,7 +28,6 @@
OR

Overland

<%= api_v1_overland_batches_url(api_key: current_user.api_key) %>

-

<%= link_to "Generate new API key", generate_api_key_path, data: { confirm: "Are you sure? This will invalidate the current API key.", turbo_confirm: "Are you sure?", turbo_method: :post }, class: 'btn btn-primary' %> diff --git a/app/views/devise/registrations/_points_usage.html.erb b/app/views/devise/registrations/_points_usage.html.erb index c079b93a..68880d0d 100644 --- a/app/views/devise/registrations/_points_usage.html.erb +++ b/app/views/devise/registrations/_points_usage.html.erb @@ -1,6 +1,6 @@

- You have used <%= number_with_delimiter(current_user.tracked_points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available. + You have used <%= number_with_delimiter(current_user.points_count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.

- +

diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 5fb84f95..e48f01bd 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -5,6 +5,12 @@

Edit your account!

<%= render 'devise/registrations/api_key' %> + <% if current_user.trial? %> +

Your trial period ends at <%= human_datetime current_user.active_until %>.

+

+ <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success glass' %> to continue using Dawarich after your trial ends. +

+ <% end %> <% if !DawarichSettings.self_hosted? %> <%= render 'devise/registrations/points_usage' %> <% end %> diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb index 35d2ec34..95d16411 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -1,70 +1,25 @@ + +
+
+

Supported Import Formats

+
    +
  • βœ… Google Maps: Records.json, Semantic History, Phone Takeout (.json)
  • +
  • βœ… GPX: Track files (.gpx)
  • +
  • βœ… GeoJSON: Feature collections (.json)
  • +
  • βœ… OwnTracks: Recorder files (.rec)
  • +
+
+ File format is automatically detected during upload. +
+
+
+ <%= form_with model: import, class: "contents", data: { controller: "direct-upload", direct_upload_url_value: rails_direct_uploads_url, + direct_upload_user_trial_value: current_user.trial?, direct_upload_target: "form" } do |form| %> -
- -
-
-
- -

JSON files from your Takeout/Location History/Semantic Location History/YEAR

-
-
-
-
- -

The Records.json file from your Google Takeout

-
-
-
-
- -

A JSON file you received after your request for Takeout from your mobile device

-
-
-
-
- -

A .REC file you could find in your volumes/owntracks-recorder/store/rec/USER/TOPIC directory

-
-
-
-
- -

A valid GeoJSON file. For example, a file, exported from a Dawarich instance

-
-
-
-
- -

GPX track file

-
-
-
-
-
- +
@@ -55,7 +55,8 @@ <% @imports.each do |import| %> + data-points-total="<%= import.processed %>" + class="hover"> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e7b97017..1036f84d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -31,5 +31,7 @@ <%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %> + + <%= render 'map/onboarding_modal' %> diff --git a/app/views/map/_onboarding_modal.html.erb b/app/views/map/_onboarding_modal.html.erb new file mode 100644 index 00000000..c1d69b36 --- /dev/null +++ b/app/views/map/_onboarding_modal.html.erb @@ -0,0 +1,21 @@ +<% if user_signed_in? %> +
+ + + +
+<% end %> diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 354b028b..e720ca0e 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -63,7 +63,7 @@
Name
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>) @@ -72,9 +73,9 @@ <%= human_datetime(import.created_at) %> <% if import.file.present? %> - <%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: import.name %> + <%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %> <% end %> - <%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> + <%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
- <%= number_with_delimiter user.tracked_points.count %> + <%= number_with_delimiter user.points_count %> <%= human_datetime(user.created_at) %> diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 5140faf5..cf7ac463 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -20,7 +20,9 @@ <% if user_signed_in? && current_user.can_subscribe? %> -
  • <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %>
  • +
  • + <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %> +
  • <% end %> @@ -71,7 +73,15 @@ @@ -137,4 +158,3 @@ - diff --git a/app/views/trips/_countries.html.erb b/app/views/trips/_countries.html.erb index 0ae8f7e5..ce6f3c7c 100644 --- a/app/views/trips/_countries.html.erb +++ b/app/views/trips/_countries.html.erb @@ -15,12 +15,9 @@
    Countries
    - <% if trip.countries.any? %> - <%= trip.countries.join(', ') %> - <% elsif trip.visited_countries.present? %> + <% if trip.visited_countries.any? %> <%= trip.visited_countries.join(', ') %> <% else %> - Countries are being calculated... <% end %>
    diff --git a/app/views/users_mailer/explore_features.html.erb b/app/views/users_mailer/explore_features.html.erb new file mode 100644 index 00000000..9d8c64c0 --- /dev/null +++ b/app/views/users_mailer/explore_features.html.erb @@ -0,0 +1,55 @@ + + + + + + + +
    +
    +

    Explore Dawarich Features

    +
    +
    +

    Hi <%= @user.email %>,

    + +

    You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.

    + +

    Here are some powerful features you might want to explore:

    + +
    +

    πŸ“Š Statistics & Analytics

    +

    View detailed insights about distances traveled and time spent in different locations.

    +
    + +
    +

    πŸ—ΊοΈ Interactive Maps

    +

    Visualize your tracks on beautiful maps with different layers and styling options.

    +
    + +
    +

    πŸ“ Places & Visits

    +

    Discover the places you've visited and get automatic visit detection for frequently visited locations.

    +
    + +
    +

    πŸ“€ Data Export

    +

    Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.

    +
    + + Continue Exploring + +

    You have 5 days left in your trial. Make the most of it!

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/explore_features.text.erb b/app/views/users_mailer/explore_features.text.erb new file mode 100644 index 00000000..0ffa8e99 --- /dev/null +++ b/app/views/users_mailer/explore_features.text.erb @@ -0,0 +1,26 @@ +Explore Dawarich Features + +Hi <%= @user.email %>, + +You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data. + +Here are some powerful features you might want to explore: + +πŸ“Š Statistics & Analytics +View detailed insights about distances traveled and time spent in different locations. + +πŸ—ΊοΈ Interactive Maps +Visualize your tracks on beautiful maps with different layers and styling options. + +πŸ“ Places & Visits +Discover the places you've visited and get automatic visit detection for frequently visited locations. + +πŸ“€ Data Export +Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications. + +Continue exploring: https://my.dawarich.app + +You have 5 days left in your trial. Make the most of it! + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/trial_expired.html.erb b/app/views/users_mailer/trial_expired.html.erb new file mode 100644 index 00000000..3294b88b --- /dev/null +++ b/app/views/users_mailer/trial_expired.html.erb @@ -0,0 +1,50 @@ + + + + + + + +
    +
    +

    πŸ”’ Your Trial Has Expired

    +
    +
    +

    Hi <%= @user.email %>,

    + +
    +

    Your 7-day Dawarich trial has ended.

    +
    + +

    Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.

    + +

    Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.

    + +

    πŸ”“ Restore full access with a subscription:

    +
      +
    • Resume location tracking
    • +
    • Access all your historical data
    • +
    • Use travel analytics and insights
    • +
    • Export data in multiple formats
    • +
    • Enjoy beautiful interactive maps
    • +
    + + Subscribe to Continue + +

    Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!

    + +

    We'd love to have you back as a subscriber.

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/trial_expired.text.erb b/app/views/users_mailer/trial_expired.text.erb new file mode 100644 index 00000000..d43178f3 --- /dev/null +++ b/app/views/users_mailer/trial_expired.text.erb @@ -0,0 +1,25 @@ +πŸ”’ Your Trial Has Expired + +Hi <%= @user.email %>, + +Your 7-day Dawarich trial has ended. + +Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week. + +Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich. + +πŸ”“ Restore full access with a subscription: +- Resume location tracking +- Access all your historical data +- Use travel analytics and insights +- Export data in multiple formats +- Enjoy beautiful interactive maps + +Subscribe to continue: https://my.dawarich.app + +Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off! + +We'd love to have you back as a subscriber. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/trial_expires_soon.html.erb b/app/views/users_mailer/trial_expires_soon.html.erb new file mode 100644 index 00000000..c1e5ff6e --- /dev/null +++ b/app/views/users_mailer/trial_expires_soon.html.erb @@ -0,0 +1,50 @@ + + + + + + + +
    +
    +

    ⏰ Your Trial Expires Soon

    +
    +
    +

    Hi <%= @user.email %>,

    + +
    +

    ⚠️ Important: Your Dawarich trial expires in just 2 days!

    +
    + +

    We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.

    + +

    To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.

    + +

    ✨ What you'll keep with a subscription:

    +
      +
    • Location tracking and data storage
    • +
    • Travel analytics and insights
    • +
    • Data export in multiple formats
    • +
    • Beautiful interactive maps
    • +
    • Visit detection and places management
    • +
    + + Subscribe Now + +

    Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!

    + +

    Questions? Drop us a message at hi@dawarich.app or just reply to this email.

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/trial_expires_soon.text.erb b/app/views/users_mailer/trial_expires_soon.text.erb new file mode 100644 index 00000000..c5f7352e --- /dev/null +++ b/app/views/users_mailer/trial_expires_soon.text.erb @@ -0,0 +1,25 @@ +⏰ Your Trial Expires Soon + +Hi <%= @user.email %>, + +⚠️ Important: Your Dawarich trial expires in just 2 days! + +We hope you've enjoyed exploring your location data with Dawarich over the past 5 days. + +To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan. + +✨ What you'll keep with a subscription: +- Location tracking and data storage +- Travel analytics and insights +- Data export in multiple formats +- Beautiful interactive maps +- Visit detection and places management + +Subscribe now: https://my.dawarich.app + +Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich! + +Questions? Drop us a message at hi@dawarich.app + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/welcome.html.erb b/app/views/users_mailer/welcome.html.erb new file mode 100644 index 00000000..07f80721 --- /dev/null +++ b/app/views/users_mailer/welcome.html.erb @@ -0,0 +1,40 @@ + + + + + + + +
    +
    +

    Welcome to Dawarich!

    +
    +
    +

    Hi <%= @user.email %>,

    + +

    Welcome to Dawarich! We're excited to have you on board.

    + +

    Your 7-day free trial has started. During this time, you can:

    +
      +
    • Track your location data
    • +
    • View your movement patterns on beautiful maps
    • +
    • Analyze your travel statistics
    • +
    • Export your data in various formats
    • +
    + + Start Exploring Dawarich + +

    If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.

    + +

    Happy tracking!
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/welcome.text.erb b/app/views/users_mailer/welcome.text.erb new file mode 100644 index 00000000..8cbf42d2 --- /dev/null +++ b/app/views/users_mailer/welcome.text.erb @@ -0,0 +1,18 @@ +Welcome to Dawarich! + +Hi <%= @user.email %>, + +Welcome to Dawarich! We're excited to have you on board. + +Your 7-day free trial has started. During this time, you can: +- Track your location data +- View your movement patterns on beautiful maps +- Analyze your travel statistics +- Export your data in various formats + +Start exploring Dawarich: https://my.dawarich.app + +If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email. + +Happy tracking! +Evgenii from Dawarich diff --git a/config/application.rb b/config/application.rb index 3d2dd0be..58530149 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,5 +36,7 @@ module Dawarich end config.active_job.queue_adapter = :sidekiq + + config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews" end end diff --git a/config/routes.rb b/config/routes.rb index 0c8026fb..638e273f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -101,7 +101,7 @@ Rails.application.routes.draw do resources :areas, only: %i[index create update destroy] resources :points, only: %i[index create update destroy] - resources :visits, only: %i[index update] do + resources :visits, only: %i[index create update destroy] do get 'possible_places', to: 'visits/possible_places#index', on: :member collection do post 'merge', to: 'visits#merge' diff --git a/config/sidekiq.yml b/config/sidekiq.yml index ef963573..780bbc1c 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -4,6 +4,7 @@ - data_migrations - points - default + - mailers - imports - exports - stats diff --git a/db/data/20250704185707_create_tracks_from_points.rb b/db/data/20250704185707_create_tracks_from_points.rb index 7d5cffb5..2972eac4 100644 --- a/db/data/20250704185707_create_tracks_from_points.rb +++ b/db/data/20250704185707_create_tracks_from_points.rb @@ -8,7 +8,7 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0] processed_users = 0 User.find_each do |user| - points_count = user.tracked_points.count + points_count = user.points.count if points_count > 0 puts "Enqueuing track creation for user #{user.id} (#{points_count} points)" diff --git a/db/migrate/20250821192219_add_points_count_to_users.rb b/db/migrate/20250821192219_add_points_count_to_users.rb new file mode 100644 index 00000000..86f056a6 --- /dev/null +++ b/db/migrate/20250821192219_add_points_count_to_users.rb @@ -0,0 +1,12 @@ +class AddPointsCountToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :points_count, :integer, default: 0, null: false + + # Initialize counter cache for existing users using background job + reversible do |dir| + dir.up do + DataMigrations::PrefillPointsCounterCacheJob.perform_later + end + end + end +end diff --git a/db/migrate/20250823125940_remove_default_from_imports_source.rb b/db/migrate/20250823125940_remove_default_from_imports_source.rb new file mode 100644 index 00000000..4b99017d --- /dev/null +++ b/db/migrate/20250823125940_remove_default_from_imports_source.rb @@ -0,0 +1,5 @@ +class RemoveDefaultFromImportsSource < ActiveRecord::Migration[8.0] + def change + change_column_default :imports, :source, from: 0, to: nil + end +end diff --git a/db/schema.rb b/db/schema.rb index feac06e4..6cb87072 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do +ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -99,7 +99,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do create_table "imports", force: :cascade do |t| t.string "name", null: false t.bigint "user_id", null: false - t.integer "source", default: 0 + t.integer "source" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "raw_points", default: 0 @@ -230,7 +230,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.datetime "end_at", null: false t.bigint "user_id", null: false t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false - t.integer "distance" + t.decimal "distance", precision: 8, scale: 2 t.float "avg_speed" t.integer "duration" t.integer "elevation_gain" @@ -274,6 +274,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.string "last_sign_in_ip" t.integer "status", default: 0 t.datetime "active_until" + t.integer "points_count", default: 0, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/spec/factories/imports.rb b/spec/factories/imports.rb index 51670b6b..9ae0acea 100644 --- a/spec/factories/imports.rb +++ b/spec/factories/imports.rb @@ -4,7 +4,7 @@ FactoryBot.define do factory :import do user sequence(:name) { |n| "owntracks_export_#{n}.json" } - source { Import.sources[:owntracks] } + # source { Import.sources[:owntracks] } trait :with_points do after(:create) do |import| diff --git a/spec/factories/users.rb b/spec/factories/users.rb index c9eb856e..3e27ad70 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -34,6 +34,11 @@ FactoryBot.define do active_until { 1.day.ago } end + trait :trial do + status { :trial } + active_until { 7.days.from_now } + end + trait :with_immich_integration do settings do { diff --git a/spec/fixtures/files/google/phone-takeout.json b/spec/fixtures/files/google/phone-takeout_w_3_duplicates.json similarity index 99% rename from spec/fixtures/files/google/phone-takeout.json rename to spec/fixtures/files/google/phone-takeout_w_3_duplicates.json index f8442949..59b45587 100644 --- a/spec/fixtures/files/google/phone-takeout.json +++ b/spec/fixtures/files/google/phone-takeout_w_3_duplicates.json @@ -1,5 +1,3 @@ -// This file contains 3 doubles - { "semanticSegments": [ { diff --git a/spec/fixtures/users/welcome b/spec/fixtures/users/welcome new file mode 100644 index 00000000..f6f72ecf --- /dev/null +++ b/spec/fixtures/users/welcome @@ -0,0 +1,3 @@ +Users#welcome + +Hi, find me in app/views/users/welcome diff --git a/spec/jobs/trips/calculate_countries_job_spec.rb b/spec/jobs/trips/calculate_countries_job_spec.rb new file mode 100644 index 00000000..d6d8abaa --- /dev/null +++ b/spec/jobs/trips/calculate_countries_job_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Trips::CalculateCountriesJob, type: :job do + describe '#perform' do + let(:user) { create(:user) } + let(:trip) { create(:trip, user: user) } + let(:distance_unit) { 'km' } + let(:points) do + [ + create(:point, user: user, country_name: 'Germany', timestamp: trip.started_at.to_i + 1.hour), + create(:point, user: user, country_name: 'France', timestamp: trip.started_at.to_i + 2.hours), + create(:point, user: user, country_name: 'Germany', timestamp: trip.started_at.to_i + 3.hours), + create(:point, user: user, country_name: 'Italy', timestamp: trip.started_at.to_i + 4.hours) + ] + end + + before do + points # Create the points + end + + it 'finds the trip and calculates countries' do + expect(Trip).to receive(:find).with(trip.id).and_return(trip) + expect(trip).to receive(:calculate_countries) + expect(trip).to receive(:save!) + + described_class.perform_now(trip.id, distance_unit) + end + + it 'calculates unique countries from trip points' do + described_class.perform_now(trip.id, distance_unit) + + trip.reload + expect(trip.visited_countries).to contain_exactly('Germany', 'France', 'Italy') + end + + it 'broadcasts the update with correct parameters' do + expect(Turbo::StreamsChannel).to receive(:broadcast_update_to).with( + "trip_#{trip.id}", + target: "trip_countries", + partial: "trips/countries", + locals: { trip: trip, distance_unit: distance_unit } + ) + + described_class.perform_now(trip.id, distance_unit) + end + + context 'when trip has no points' do + let(:trip_without_points) { create(:trip, user: user) } + + it 'sets visited_countries to empty array' do + trip_without_points.points.destroy_all + described_class.perform_now(trip_without_points.id, distance_unit) + + trip_without_points.reload + + expect(trip_without_points.visited_countries).to eq([]) + end + end + + context 'when points have nil country names' do + let(:points_with_nil_countries) do + [ + create(:point, user: user, country_name: 'Germany', timestamp: trip.started_at.to_i + 1.hour), + create(:point, user: user, country_name: nil, timestamp: trip.started_at.to_i + 2.hours), + create(:point, user: user, country_name: 'France', timestamp: trip.started_at.to_i + 3.hours) + ] + end + + before do + # Remove existing points and create new ones with nil countries + Point.where(user: user).destroy_all + points_with_nil_countries + end + + it 'filters out nil country names' do + described_class.perform_now(trip.id, distance_unit) + + trip.reload + expect(trip.visited_countries).to contain_exactly('Germany', 'France') + end + end + + context 'when trip is not found' do + it 'raises ActiveRecord::RecordNotFound' do + expect { + described_class.perform_now(999999, distance_unit) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when distance_unit is different' do + let(:distance_unit) { 'mi' } + + it 'passes the correct distance_unit to broadcast' do + expect(Turbo::StreamsChannel).to receive(:broadcast_update_to).with( + "trip_#{trip.id}", + target: "trip_countries", + partial: "trips/countries", + locals: { trip: trip, distance_unit: 'mi' } + ) + + described_class.perform_now(trip.id, distance_unit) + end + end + + describe 'queue configuration' do + it 'uses the trips queue' do + expect(described_class.queue_name).to eq('trips') + end + end + end +end diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb new file mode 100644 index 00000000..ba4b1de9 --- /dev/null +++ b/spec/jobs/users/mailer_sending_job_spec.rb @@ -0,0 +1,144 @@ +require 'rails_helper' + +RSpec.describe Users::MailerSendingJob, type: :job do + let(:user) { create(:user, :trial) } + let(:mailer_double) { double('mailer', deliver_later: true) } + + before do + allow(UsersMailer).to receive(:with).and_return(UsersMailer) + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + describe '#perform' do + context 'when email_type is welcome' do + it 'sends welcome email to trial user' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:welcome).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'welcome') + end + + it 'sends welcome email to active user' do + active_user = create(:user) + expect(UsersMailer).to receive(:with).with({ user: active_user }) + expect(UsersMailer).to receive(:welcome).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(active_user.id, 'welcome') + end + end + + context 'when email_type is explore_features' do + it 'sends explore_features email to trial user' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:explore_features).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'explore_features') + end + + it 'sends explore_features email to active user' do + active_user = create(:user) + expect(UsersMailer).to receive(:with).with({ user: active_user }) + expect(UsersMailer).to receive(:explore_features).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(active_user.id, 'explore_features') + end + end + + context 'when email_type is trial_expires_soon' do + context 'with trial user' do + it 'sends trial_expires_soon email' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:trial_expires_soon).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'trial_expires_soon') + end + end + + context 'with active user' do + let(:active_user) { create(:user).tap { |u| u.update!(status: :active) } } + + it 'skips sending trial_expires_soon email' do + expect(UsersMailer).not_to receive(:with) + expect(UsersMailer).not_to receive(:trial_expires_soon) + + described_class.perform_now(active_user.id, 'trial_expires_soon') + end + end + end + + context 'when email_type is trial_expired' do + context 'with trial user' do + it 'sends trial_expired email' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:trial_expired).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'trial_expired') + end + end + + context 'with active user' do + let(:active_user) { create(:user).tap { |u| u.update!(status: :active) } } + + it 'skips sending trial_expired email' do + expect(UsersMailer).not_to receive(:with) + expect(UsersMailer).not_to receive(:trial_expired) + + described_class.perform_now(active_user.id, 'trial_expired') + end + end + end + + context 'with additional options' do + it 'merges options with user params' do + custom_options = { custom_data: 'test', priority: :high } + expected_params = { user: user, custom_data: 'test', priority: :high } + + expect(UsersMailer).to receive(:with).with(expected_params) + expect(UsersMailer).to receive(:welcome).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'welcome', **custom_options) + end + end + + context 'when user is deleted' do + it 'raises ActiveRecord::RecordNotFound' do + user.destroy + + expect { + described_class.perform_now(user.id, 'welcome') + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe '#trial_related_email?' do + subject { described_class.new } + + it 'returns true for trial_expires_soon' do + expect(subject.send(:trial_related_email?, 'trial_expires_soon')).to be true + end + + it 'returns true for trial_expired' do + expect(subject.send(:trial_related_email?, 'trial_expired')).to be true + end + + it 'returns false for welcome' do + expect(subject.send(:trial_related_email?, 'welcome')).to be false + end + + it 'returns false for explore_features' do + expect(subject.send(:trial_related_email?, 'explore_features')).to be false + end + + it 'returns false for unknown email types' do + expect(subject.send(:trial_related_email?, 'unknown_email')).to be false + end + end +end diff --git a/spec/jobs/users/trial_webhook_job_spec.rb b/spec/jobs/users/trial_webhook_job_spec.rb new file mode 100644 index 00000000..94a9e581 --- /dev/null +++ b/spec/jobs/users/trial_webhook_job_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe Users::TrialWebhookJob, type: :job do + let(:user) { create(:user, :trial) } + let(:jwt_token) { 'encoded.jwt.token' } + let(:manager_url) { 'https://manager.example.com' } + let(:request_url) { "#{manager_url}/api/v1/users" } + let(:jwt_service) { instance_double(Subscription::EncodeJwtToken, call: jwt_token) } + + before do + stub_const('ENV', ENV.to_hash.merge('MANAGER_URL' => manager_url, 'JWT_SECRET_KEY' => 'secret')) + allow(Subscription::EncodeJwtToken).to receive(:new).and_return(jwt_service) + allow(HTTParty).to receive(:post) + end + + describe '#perform' do + it 'encodes JWT with correct payload' do + expected_payload = { + user_id: user.id, + email: user.email, + active_until: user.active_until, + status: user.status, + action: 'create_user' + } + + expect(Subscription::EncodeJwtToken).to receive(:new) + .with(expected_payload, 'secret') + .and_return(jwt_service) + + described_class.perform_now(user.id) + end + + it 'makes HTTP POST request to Manager API' do + expected_headers = { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + } + expected_body = { token: jwt_token }.to_json + + expect(HTTParty).to receive(:post) + .with(request_url, headers: expected_headers, body: expected_body) + + described_class.perform_now(user.id) + end + + context 'when user is deleted' do + it 'raises ActiveRecord::RecordNotFound' do + user.destroy + + expect { + described_class.perform_now(user.id) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/mailers/previews/users_mailer_preview.rb b/spec/mailers/previews/users_mailer_preview.rb new file mode 100644 index 00000000..464549dc --- /dev/null +++ b/spec/mailers/previews/users_mailer_preview.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class UsersMailerPreview < ActionMailer::Preview + def welcome + UsersMailer.with(user: User.last).welcome + end + + def explore_features + UsersMailer.with(user: User.last).explore_features + end + + def trial_expires_soon + UsersMailer.with(user: User.last).trial_expires_soon + end + + def trial_expired + UsersMailer.with(user: User.last).trial_expired + end +end diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb new file mode 100644 index 00000000..11789e2b --- /dev/null +++ b/spec/mailers/users_mailer_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsersMailer, type: :mailer do + let(:user) { create(:user, email: 'test@example.com') } + + before do + stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) + end + + describe "welcome" do + let(:mail) { UsersMailer.with(user: user).welcome } + + it "renders the headers" do + expect(mail.subject).to eq("Welcome to Dawarich!") + expect(mail.to).to eq(["test@example.com"]) + end + + it "renders the body" do + expect(mail.body.encoded).to match("test@example.com") + end + end + + describe "explore_features" do + let(:mail) { UsersMailer.with(user: user).explore_features } + + it "renders the headers" do + expect(mail.subject).to eq("Explore Dawarich features!") + expect(mail.to).to eq(["test@example.com"]) + end + end + + describe "trial_expires_soon" do + let(:mail) { UsersMailer.with(user: user).trial_expires_soon } + + it "renders the headers" do + expect(mail.subject).to eq("⚠️ Your Dawarich trial expires in 2 days") + expect(mail.to).to eq(["test@example.com"]) + end + end + + describe "trial_expired" do + let(:mail) { UsersMailer.with(user: user).trial_expired } + + it "renders the headers" do + expect(mail.subject).to eq("πŸ’” Your Dawarich trial expired") + expect(mail.to).to eq(["test@example.com"]) + end + end +end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 88f06f02..50034082 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -3,16 +3,69 @@ require 'rails_helper' RSpec.describe Import, type: :model do + let(:user) { create(:user) } + subject(:import) { create(:import, user:) } + describe 'associations' do it { is_expected.to have_many(:points).dependent(:destroy) } - it { is_expected.to belong_to(:user) } + it 'belongs to a user' do + expect(user).to be_present + expect(import.user).to eq(user) + end end describe 'validations' do - subject { build(:import, name: 'test import') } - it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) } + + it 'validates uniqueness of name scoped to user_id' do + create(:import, name: 'test_name', user: user) + + duplicate_import = build(:import, name: 'test_name', user: user) + expect(duplicate_import).not_to be_valid + expect(duplicate_import.errors[:name]).to include('has already been taken') + + other_user = create(:user) + different_user_import = build(:import, name: 'test_name', user: other_user) + expect(different_user_import).to be_valid + end + + describe 'file size validation' do + context 'when user is a trial user' do + let(:user) do + user = create(:user) + user.update!(status: :trial) + user + end + + it 'validates file size limit for large files' do + import = build(:import, user: user) + mock_file = double(attached?: true, blob: double(byte_size: 12.megabytes)) + allow(import).to receive(:file).and_return(mock_file) + + expect(import).not_to be_valid + expect(import.errors[:file]).to include('is too large. Trial users can only upload files up to 10MB.') + end + + it 'allows files under the size limit' do + import = build(:import, user: user) + mock_file = double(attached?: true, blob: double(byte_size: 5.megabytes)) + allow(import).to receive(:file).and_return(mock_file) + + expect(import).to be_valid + end + end + + context 'when user is a paid user' do + let(:user) { create(:user, status: :active) } + let(:import) { build(:import, user: user) } + + it 'does not validate file size limit' do + allow(import).to receive(:file).and_return(double(attached?: true, blob: double(byte_size: 12.megabytes))) + + expect(import).to be_valid + end + end + end end describe 'enums' do diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index 20bb5ba3..8c46a65a 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -26,34 +26,6 @@ RSpec.describe Trip, type: :model do trip.save end end - - context 'when DawarichSettings.store_geodata? is enabled' do - before do - allow(DawarichSettings).to receive(:store_geodata?).and_return(true) - end - - it 'sets the countries' do - expect(trip.countries).to eq(trip.points.pluck(:country).uniq.compact) - end - end - end - - describe '#countries' do - let(:user) { create(:user) } - let(:trip) { create(:trip, user:) } - let(:points) do - create_list( - :point, - 25, - :reverse_geocoded, - user:, - timestamp: (trip.started_at.to_i..trip.ended_at.to_i).to_a.sample - ) - end - - it 'returns the unique countries of the points' do - expect(trip.countries).to eq(trip.points.pluck(:country).uniq.compact) - end end describe '#photo_previews' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3caba416..94c225c5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,9 +5,8 @@ require 'rails_helper' RSpec.describe User, type: :model do describe 'associations' do it { is_expected.to have_many(:imports).dependent(:destroy) } - it { is_expected.to have_many(:points).through(:imports) } it { is_expected.to have_many(:stats) } - it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) } + it { is_expected.to have_many(:points).class_name('Point').dependent(:destroy) } it { is_expected.to have_many(:exports).dependent(:destroy) } it { is_expected.to have_many(:notifications).dependent(:destroy) } it { is_expected.to have_many(:areas).dependent(:destroy) } @@ -18,7 +17,7 @@ RSpec.describe User, type: :model do end describe 'enums' do - it { is_expected.to define_enum_for(:status).with_values(inactive: 0, active: 1) } + it { is_expected.to define_enum_for(:status).with_values(inactive: 0, active: 1, trial: 2) } end describe 'callbacks' do @@ -49,19 +48,116 @@ RSpec.describe User, type: :model do allow(DawarichSettings).to receive(:self_hosted?).and_return(false) end - it 'does not activate user' do + it 'sets user to trial instead of active' do user = create(:user, :inactive) - expect(user.active?).to be_falsey - expect(user.active_until).to be_within(1.minute).of(1.day.ago) + expect(user.trial?).to be_truthy + expect(user.active_until).to be_within(1.minute).of(7.days.from_now) end end end + + describe '#start_trial' do + let(:user) { create(:user, :inactive) } + + before do + allow(Users::TrialWebhookJob).to receive(:perform_later) + end + + it 'sets trial status and active_until to 7 days from now' do + user.send(:start_trial) + + expect(user.reload.trial?).to be_truthy + expect(user.active_until).to be_within(1.minute).of(7.days.from_now) + end + + it 'enqueues trial webhook job' do + expect(Users::TrialWebhookJob).to receive(:perform_later).with(user.id) + user.send(:start_trial) + end + + it 'schedules welcome emails' do + allow(user).to receive(:schedule_welcome_emails) + + user.send(:start_trial) + + expect(user).to have_received(:schedule_welcome_emails) + end + end + + describe '#schedule_welcome_emails' do + let(:user) { create(:user, :inactive) } + + before do + allow(Users::MailerSendingJob).to receive(:perform_later) + allow(Users::MailerSendingJob).to receive(:set).and_return(Users::MailerSendingJob) + end + + it 'schedules welcome email immediately' do + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'welcome') + user.send(:schedule_welcome_emails) + end + + it 'schedules explore_features email for day 2' do + expect(Users::MailerSendingJob).to receive(:set).with(wait: 2.days).and_return(Users::MailerSendingJob) + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'explore_features') + user.send(:schedule_welcome_emails) + end + + it 'schedules trial_expires_soon email for day 5' do + expect(Users::MailerSendingJob).to receive(:set).with(wait: 5.days).and_return(Users::MailerSendingJob) + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'trial_expires_soon') + user.send(:schedule_welcome_emails) + end + + it 'schedules trial_expired email for day 7' do + expect(Users::MailerSendingJob).to receive(:set).with(wait: 7.days).and_return(Users::MailerSendingJob) + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'trial_expired') + user.send(:schedule_welcome_emails) + end + end end describe 'methods' do let(:user) { create(:user) } + describe '#trial_state?' do + context 'when user has trial status and no tracked points' do + let(:user) do + user = build(:user, :trial) + user.save!(validate: false) + user.update_column(:status, 'trial') + user + end + + it 'returns true' do + user.points.destroy_all + + expect(user.trial_state?).to be_truthy + end + end + + context 'when user has trial status but has tracked points' do + let(:user) { create(:user, :trial) } + + before do + create(:point, user: user) + end + + it 'returns false' do + expect(user.trial_state?).to be_falsey + end + end + + context 'when user is not on trial' do + let(:user) { create(:user, :active) } + + it 'returns false' do + expect(user.trial_state?).to be_falsey + end + end + end + describe '#countries_visited' do subject { user.countries_visited } @@ -199,12 +295,27 @@ RSpec.describe User, type: :model do let(:user) { create(:user, status: :active, active_until: 1000.years.from_now) } it 'returns false' do + user.update(status: :active) + expect(user.can_subscribe?).to be_falsey end end context 'when user is inactive' do - let(:user) { create(:user, :inactive) } + let(:user) do + user = build(:user, :inactive) + user.save!(validate: false) + user.update_columns(status: 'inactive', active_until: 1.day.ago) + user + end + + it 'returns true' do + expect(user.can_subscribe?).to be_truthy + end + end + + context 'when user is on trial' do + let(:user) { create(:user, :trial, active_until: 1.week.from_now) } it 'returns true' do expect(user.can_subscribe?).to be_truthy diff --git a/spec/models/visit_spec.rb b/spec/models/visit_spec.rb index 563d4370..edff03a3 100644 --- a/spec/models/visit_spec.rb +++ b/spec/models/visit_spec.rb @@ -10,6 +10,21 @@ RSpec.describe Visit, type: :model do it { is_expected.to have_many(:points).dependent(:nullify) } end + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:started_at) } + it { is_expected.to validate_presence_of(:ended_at) } + it { is_expected.to validate_presence_of(:duration) } + it { is_expected.to validate_presence_of(:status) } + + it 'validates ended_at is greater than started_at' do + visit = build(:visit, started_at: Time.zone.now, ended_at: Time.zone.now - 1.hour) + + expect(visit).not_to be_valid + expect(visit.errors[:ended_at]).to include("must be greater than #{visit.started_at}") + end + end + describe 'factory' do it { expect(build(:visit)).to be_valid } end diff --git a/spec/requests/api/v1/countries/borders_spec.rb b/spec/requests/api/v1/countries/borders_spec.rb index 1162e198..b5922b73 100644 --- a/spec/requests/api/v1/countries/borders_spec.rb +++ b/spec/requests/api/v1/countries/borders_spec.rb @@ -4,12 +4,38 @@ require 'rails_helper' RSpec.describe 'Api::V1::Countries::Borders', type: :request do describe 'GET /index' do - it 'returns a list of countries with borders' do - get '/api/v1/countries/borders' + let(:user) { create(:user) } - expect(response).to have_http_status(:success) - expect(response.body).to include('AF') - expect(response.body).to include('ZW') + context 'when user is not authenticated' do + it 'returns http unauthorized' do + get '/api/v1/countries/borders' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns X-Dawarich-Response header' do + get '/api/v1/countries/borders' + + expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive!') + expect(response.headers['X-Dawarich-Version']).to eq(APP_VERSION) + end + end + + context 'when user is authenticated' do + it 'returns a list of countries with borders' do + get '/api/v1/countries/borders', headers: { 'Authorization' => "Bearer #{user.api_key}" } + + expect(response).to have_http_status(:success) + expect(response.body).to include('AF') + expect(response.body).to include('ZW') + end + + it 'returns X-Dawarich-Response header' do + get '/api/v1/countries/borders', headers: { 'Authorization' => "Bearer #{user.api_key}" } + + expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive and authenticated!') + expect(response.headers['X-Dawarich-Version']).to eq(APP_VERSION) + end end end end diff --git a/spec/requests/api/v1/visits_spec.rb b/spec/requests/api/v1/visits_spec.rb index a4b3a877..a250635c 100644 --- a/spec/requests/api/v1/visits_spec.rb +++ b/spec/requests/api/v1/visits_spec.rb @@ -64,6 +64,104 @@ RSpec.describe 'Api::V1::Visits', type: :request do end end + describe 'POST /api/v1/visits' do + let(:valid_create_params) do + { + visit: { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + context 'with valid parameters' do + let(:existing_place) { create(:place, latitude: 52.52, longitude: 13.405) } + + it 'creates a new visit' do + expect { + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + }.to change { user.visits.count }.by(1) + + expect(response).to have_http_status(:ok) + end + + it 'creates a visit with correct attributes' do + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + + json_response = JSON.parse(response.body) + expect(json_response['name']).to eq('Test Visit') + expect(json_response['status']).to eq('confirmed') + expect(json_response['duration']).to eq(120) # 2 hours in minutes + expect(json_response['place']['latitude']).to eq(52.52) + expect(json_response['place']['longitude']).to eq(13.405) + end + + it 'creates a place for the visit' do + expect { + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + }.to change { Place.count }.by(1) + + created_place = Place.last + expect(created_place.name).to eq('Test Visit') + expect(created_place.latitude).to eq(52.52) + expect(created_place.longitude).to eq(13.405) + expect(created_place.source).to eq('manual') + end + + it 'reuses existing place when coordinates are exactly the same' do + create(:visit, user: user, place: existing_place) + + expect { + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + }.not_to change { Place.count } + + json_response = JSON.parse(response.body) + expect(json_response['place']['id']).to eq(existing_place.id) + end + end + + context 'with invalid parameters' do + context 'when required fields are missing' do + let(:missing_name_params) do + valid_create_params.deep_merge(visit: { name: '' }) + end + + it 'returns unprocessable entity status' do + post '/api/v1/visits', params: missing_name_params, headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns error message' do + post '/api/v1/visits', params: missing_name_params, headers: auth_headers + + json_response = JSON.parse(response.body) + + expect(json_response['error']).to eq('Failed to create visit') + end + + it 'does not create a visit' do + expect { + post '/api/v1/visits', params: missing_name_params, headers: auth_headers + }.not_to change { Visit.count } + end + end + end + + context 'with invalid API key' do + let(:invalid_auth_headers) { { 'Authorization' => 'Bearer invalid-key' } } + + it 'returns unauthorized status' do + post '/api/v1/visits', params: valid_create_params, headers: invalid_auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + describe 'PUT /api/v1/visits/:id' do let(:visit) { create(:visit, user:) } @@ -224,4 +322,61 @@ RSpec.describe 'Api::V1::Visits', type: :request do end end end + + describe 'DELETE /api/v1/visits/:id' do + let!(:visit) { create(:visit, user: user, place: place) } + let!(:other_user_visit) { create(:visit, user: other_user, place: place) } + + context 'when visit exists and belongs to current user' do + it 'deletes the visit' do + expect { + delete "/api/v1/visits/#{visit.id}", headers: auth_headers + }.to change { user.visits.count }.by(-1) + + expect(response).to have_http_status(:no_content) + end + + it 'removes the visit from the database' do + delete "/api/v1/visits/#{visit.id}", headers: auth_headers + + expect { visit.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when visit does not exist' do + it 'returns not found status' do + delete '/api/v1/visits/999999', headers: auth_headers + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Visit not found') + end + end + + context 'when visit belongs to another user' do + it 'returns not found status' do + delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Visit not found') + end + + it 'does not delete the visit' do + expect { + delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers + }.not_to change { Visit.count } + end + end + + context 'with invalid API key' do + let(:invalid_auth_headers) { { 'Authorization' => 'Bearer invalid-key' } } + + it 'returns unauthorized status' do + delete "/api/v1/visits/#{visit.id}", headers: invalid_auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb index 0d1852de..56eb3333 100644 --- a/spec/requests/imports_spec.rb +++ b/spec/requests/imports_spec.rb @@ -203,6 +203,16 @@ RSpec.describe 'Imports', type: :request do expect(response).to have_http_status(200) end + + context 'when user is a trial user' do + let(:user) { create(:user, status: :trial) } + + it 'returns http success' do + get new_import_path + + expect(response).to have_http_status(200) + end + end end end diff --git a/spec/serializers/track_serializer_spec.rb b/spec/serializers/track_serializer_spec.rb index 6622b23d..cfda4e5c 100644 --- a/spec/serializers/track_serializer_spec.rb +++ b/spec/serializers/track_serializer_spec.rb @@ -123,7 +123,7 @@ RSpec.describe TrackSerializer do context 'with very large values' do let(:track) do create(:track, user: user, - distance: 1_000_000.0, + distance: 999_999.99, avg_speed: 999.99, duration: 86_400, # 24 hours in seconds elevation_gain: 10_000, @@ -133,7 +133,7 @@ RSpec.describe TrackSerializer do end it 'handles large values correctly' do - expect(serialized_track[:distance]).to eq(1_000_000) + expect(serialized_track[:distance]).to eq(999_999) expect(serialized_track[:avg_speed]).to eq(999.99) expect(serialized_track[:duration]).to eq(86_400) expect(serialized_track[:elevation_gain]).to eq(10_000) diff --git a/spec/services/google_maps/phone_takeout_importer_spec.rb b/spec/services/google_maps/phone_takeout_importer_spec.rb index b48f9891..301590d4 100644 --- a/spec/services/google_maps/phone_takeout_importer_spec.rb +++ b/spec/services/google_maps/phone_takeout_importer_spec.rb @@ -14,7 +14,7 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do context 'when file content is an object' do # This file contains 3 duplicates - let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout.json') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout_w_3_duplicates.json') } let(:file) { Rack::Test::UploadedFile.new(file_path, 'application/json') } let(:import) { create(:import, user:, name: 'phone_takeout.json', file:) } diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb index 91fc643b..1410b2ee 100644 --- a/spec/services/imports/create_spec.rb +++ b/spec/services/imports/create_spec.rb @@ -17,9 +17,25 @@ RSpec.describe Imports::Create do it 'sets status to processing at start' do service.call + expect(import.reload.status).to eq('processing').or eq('completed') end + it 'updates the import source' do + service.call + + expect(import.reload.source).to eq('owntracks') + end + + it 'resets points counter cache' do + allow(User).to receive(:reset_counters) + + service.call + + expect(User).to have_received(:reset_counters).with(user.id, :points) + end + + context 'when import succeeds' do it 'sets status to completed' do service.call @@ -29,7 +45,7 @@ RSpec.describe Imports::Create do context 'when import fails' do before do - allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError) + allow(OwnTracks::Importer).to receive(:new).with(import, user.id, kind_of(String)).and_raise(StandardError) end it 'sets status to failed' do @@ -51,7 +67,7 @@ RSpec.describe Imports::Create do it 'calls the GoogleMaps::SemanticHistoryImporter' do expect(GoogleMaps::SemanticHistoryImporter).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end @@ -62,10 +78,16 @@ RSpec.describe Imports::Create do context 'when source is google_phone_takeout' do let(:import) { create(:import, source: 'google_phone_takeout') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout_w_3_duplicates.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'phone-takeout_w_3_duplicates.json', + content_type: 'application/json') + end it 'calls the GoogleMaps::PhoneTakeoutImporter' do expect(GoogleMaps::PhoneTakeoutImporter).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end @@ -81,7 +103,7 @@ RSpec.describe Imports::Create do it 'calls the OwnTracks::Importer' do expect(OwnTracks::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end @@ -102,7 +124,7 @@ RSpec.describe Imports::Create do context 'when import fails' do before do - allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError) + allow(OwnTracks::Importer).to receive(:new).with(import, user.id, kind_of(String)).and_raise(StandardError) end context 'when self-hosted' do @@ -153,37 +175,56 @@ RSpec.describe Imports::Create do it 'calls the Gpx::TrackImporter' do expect(Gpx::TrackImporter).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end context 'when source is geojson' do let(:import) { create(:import, source: 'geojson') } + let(:file_path) { Rails.root.join('spec/fixtures/files/geojson/export.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'export.json', + content_type: 'application/json') + end it 'calls the Geojson::Importer' do expect(Geojson::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end context 'when source is immich_api' do let(:import) { create(:import, source: 'immich_api') } + let(:file_path) { Rails.root.join('spec/fixtures/files/immich/geodata.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'geodata.json', + content_type: 'application/json') + end it 'calls the Photos::Importer' do expect(Photos::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) + service.call end end context 'when source is photoprism_api' do let(:import) { create(:import, source: 'photoprism_api') } + let(:file_path) { Rails.root.join('spec/fixtures/files/immich/geodata.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'geodata.json', + content_type: 'application/json') + end it 'calls the Photos::Importer' do expect(Photos::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end diff --git a/spec/services/imports/source_detector_spec.rb b/spec/services/imports/source_detector_spec.rb new file mode 100644 index 00000000..e3cba810 --- /dev/null +++ b/spec/services/imports/source_detector_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Imports::SourceDetector do + let(:detector) { described_class.new(file_content, filename) } + let(:filename) { nil } + + describe '#detect_source' do + context 'with Google Semantic History format' do + let(:file_content) { file_fixture('google/semantic_history.json').read } + + it 'detects google_semantic_history format' do + expect(detector.detect_source).to eq(:google_semantic_history) + end + end + + context 'with Google Records format' do + let(:file_content) { file_fixture('google/records.json').read } + + it 'detects google_records format' do + expect(detector.detect_source).to eq(:google_records) + end + end + + context 'with Google Phone Takeout format' do + let(:file_content) { file_fixture('google/phone-takeout_w_3_duplicates.json').read } + + it 'detects google_phone_takeout format' do + expect(detector.detect_source).to eq(:google_phone_takeout) + end + end + + context 'with Google Phone Takeout array format' do + let(:file_content) { file_fixture('google/location-history.json').read } + + it 'detects google_phone_takeout format' do + expect(detector.detect_source).to eq(:google_phone_takeout) + end + end + + context 'with GeoJSON format' do + let(:file_content) { file_fixture('geojson/export.json').read } + + it 'detects geojson format' do + expect(detector.detect_source).to eq(:geojson) + end + end + + context 'with OwnTracks REC file' do + let(:file_content) { file_fixture('owntracks/2024-03.rec').read } + let(:filename) { 'test.rec' } + + it 'detects owntracks format' do + expect(detector.detect_source).to eq(:owntracks) + end + end + + context 'with OwnTracks content without .rec extension' do + let(:file_content) { '{"_type":"location","lat":52.225,"lon":13.332}' } + let(:filename) { 'test.json' } + + it 'detects owntracks format based on content' do + expect(detector.detect_source).to eq(:owntracks) + end + end + + context 'with GPX file' do + let(:file_content) { file_fixture('gpx/gpx_track_single_segment.gpx').read } + let(:filename) { 'test.gpx' } + + it 'detects gpx format' do + expect(detector.detect_source).to eq(:gpx) + end + end + + context 'with invalid JSON' do + let(:file_content) { 'invalid json content' } + + it 'returns nil for invalid JSON' do + expect(detector.detect_source).to be_nil + end + end + + context 'with unknown JSON format' do + let(:file_content) { '{"unknown": "format", "data": []}' } + + it 'returns nil for unknown format' do + expect(detector.detect_source).to be_nil + end + end + + context 'with empty content' do + let(:file_content) { '' } + + it 'returns nil for empty content' do + expect(detector.detect_source).to be_nil + end + end + end + + describe '#detect_source!' do + context 'with valid format' do + let(:file_content) { file_fixture('google/records.json').read } + + it 'returns the detected format' do + expect(detector.detect_source!).to eq(:google_records) + end + end + + context 'with unknown format' do + let(:file_content) { '{"unknown": "format"}' } + + it 'raises UnknownSourceError' do + expect { detector.detect_source! }.to raise_error( + Imports::SourceDetector::UnknownSourceError, + 'Unable to detect file format' + ) + end + end + end + + describe '.new_from_file_header' do + context 'with Google Records file' do + let(:fixture_path) { file_fixture('google/records.json').to_s } + + it 'detects source correctly from file path' do + detector = described_class.new_from_file_header(fixture_path) + expect(detector.detect_source).to eq(:google_records) + end + + it 'can detect source efficiently from file' do + detector = described_class.new_from_file_header(fixture_path) + + # Verify it can detect correctly using file-based approach + expect(detector.detect_source).to eq(:google_records) + end + end + + context 'with GeoJSON file' do + let(:fixture_path) { file_fixture('geojson/export.json').to_s } + + it 'detects source correctly from file path' do + detector = described_class.new_from_file_header(fixture_path) + expect(detector.detect_source).to eq(:geojson) + end + end + end + + describe 'detection accuracy with real fixture files' do + shared_examples 'detects format correctly' do |expected_format, fixture_path| + it "detects #{expected_format} format for #{fixture_path}" do + file_content = file_fixture(fixture_path).read + filename = File.basename(fixture_path) + detector = described_class.new(file_content, filename) + + expect(detector.detect_source).to eq(expected_format) + end + end + + # Test various Google Semantic History variations + include_examples 'detects format correctly', :google_semantic_history, 'google/location-history/with_activitySegment_with_startLocation.json' + include_examples 'detects format correctly', :google_semantic_history, 'google/location-history/with_placeVisit_with_location_with_coordinates.json' + + # Test GeoJSON variations + include_examples 'detects format correctly', :geojson, 'geojson/export_same_points.json' + include_examples 'detects format correctly', :geojson, 'geojson/gpslogger_example.json' + + # Test GPX files + include_examples 'detects format correctly', :gpx, 'gpx/arc_example.gpx' + include_examples 'detects format correctly', :gpx, 'gpx/garmin_example.gpx' + include_examples 'detects format correctly', :gpx, 'gpx/gpx_track_multiple_segments.gpx' + end +end diff --git a/spec/services/points_limit_exceeded_spec.rb b/spec/services/points_limit_exceeded_spec.rb index fed8a880..293c7a27 100644 --- a/spec/services/points_limit_exceeded_spec.rb +++ b/spec/services/points_limit_exceeded_spec.rb @@ -24,20 +24,20 @@ RSpec.describe PointsLimitExceeded do context 'when user points count is equal to the limit' do before do - allow(user.tracked_points).to receive(:count).and_return(10) + allow(user).to receive(:points_count).and_return(10) end it { is_expected.to be true } it 'caches the result' do - expect(user.tracked_points).to receive(:count).once + expect(user).to receive(:points_count).once 2.times { described_class.new(user).call } end end context 'when user points count exceeds the limit' do before do - allow(user.tracked_points).to receive(:count).and_return(11) + allow(user).to receive(:points_count).and_return(11) end it { is_expected.to be true } @@ -45,7 +45,7 @@ RSpec.describe PointsLimitExceeded do context 'when user points count is below the limit' do before do - allow(user.tracked_points).to receive(:count).and_return(9) + allow(user).to receive(:points_count).and_return(9) end it { is_expected.to be false } diff --git a/spec/services/subscription/encode_jwt_token_spec.rb b/spec/services/subscription/encode_jwt_token_spec.rb new file mode 100644 index 00000000..9d25c143 --- /dev/null +++ b/spec/services/subscription/encode_jwt_token_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Subscription::EncodeJwtToken do + let(:payload) { { user_id: 123, email: 'test@example.com', action: 'create_user' } } + let(:secret_key) { 'secret_key' } + let(:service) { described_class.new(payload, secret_key) } + + describe '#call' do + it 'encodes JWT with correct algorithm' do + expect(JWT).to receive(:encode) + .with(payload, secret_key, 'HS256') + .and_return('encoded.jwt.token') + + result = service.call + expect(result).to eq('encoded.jwt.token') + end + + it 'returns encoded JWT token' do + token = service.call + + decoded_payload = JWT.decode(token, secret_key, 'HS256').first + + expect(decoded_payload['user_id']).to eq(123) + expect(decoded_payload['email']).to eq('test@example.com') + expect(decoded_payload['action']).to eq('create_user') + end + end +end diff --git a/spec/services/users/export_data_spec.rb b/spec/services/users/export_data_spec.rb index cc603d75..b2c600d2 100644 --- a/spec/services/users/export_data_spec.rb +++ b/spec/services/users/export_data_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) @@ -187,7 +187,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) @@ -267,7 +267,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) @@ -374,7 +374,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) allow(Rails.logger).to receive(:info) diff --git a/spec/services/users/export_import_integration_spec.rb b/spec/services/users/export_import_integration_spec.rb index ed959048..66e3c6a0 100644 --- a/spec/services/users/export_import_integration_spec.rb +++ b/spec/services/users/export_import_integration_spec.rb @@ -312,33 +312,33 @@ RSpec.describe 'Users Export-Import Integration', type: :service do trips: user.trips.count, stats: user.stats.count, notifications: user.notifications.count, - points: user.tracked_points.count, + points: user.points.count, visits: user.visits.count, places: user.places.count } end def verify_relationships_preserved(original_user, target_user) - original_points_with_imports = original_user.tracked_points.where.not(import_id: nil).count - target_points_with_imports = target_user.tracked_points.where.not(import_id: nil).count + original_points_with_imports = original_user.points.where.not(import_id: nil).count + target_points_with_imports = target_user.points.where.not(import_id: nil).count expect(target_points_with_imports).to eq(original_points_with_imports) - original_points_with_countries = original_user.tracked_points.where.not(country_id: nil).count - target_points_with_countries = target_user.tracked_points.where.not(country_id: nil).count + original_points_with_countries = original_user.points.where.not(country_id: nil).count + target_points_with_countries = target_user.points.where.not(country_id: nil).count expect(target_points_with_countries).to eq(original_points_with_countries) - original_points_with_visits = original_user.tracked_points.where.not(visit_id: nil).count - target_points_with_visits = target_user.tracked_points.where.not(visit_id: nil).count + original_points_with_visits = original_user.points.where.not(visit_id: nil).count + target_points_with_visits = target_user.points.where.not(visit_id: nil).count expect(target_points_with_visits).to eq(original_points_with_visits) original_visits_with_places = original_user.visits.where.not(place_id: nil).count target_visits_with_places = target_user.visits.where.not(place_id: nil).count expect(target_visits_with_places).to eq(original_visits_with_places) - original_office_points = original_user.tracked_points.where( + original_office_points = original_user.points.where( latitude: 40.7589, longitude: -73.9851 ).first - target_office_points = target_user.tracked_points.where( + target_office_points = target_user.points.where( latitude: 40.7589, longitude: -73.9851 ).first diff --git a/spec/services/users/import_data/imports_spec.rb b/spec/services/users/import_data/imports_spec.rb index f9ef66e9..9485bb95 100644 --- a/spec/services/users/import_data/imports_spec.rb +++ b/spec/services/users/import_data/imports_spec.rb @@ -221,7 +221,7 @@ RSpec.describe Users::ImportData::Imports, type: :service do created_imports = user.imports.pluck(:name, :source) expect(created_imports).to contain_exactly( ['Valid Import', 'owntracks'], - ['Missing Source Import', 'google_semantic_history'] + ['Missing Source Import', nil] ) end diff --git a/spec/services/users/import_data/points_spec.rb b/spec/services/users/import_data/points_spec.rb index cfb81c28..aa23e316 100644 --- a/spec/services/users/import_data/points_spec.rb +++ b/spec/services/users/import_data/points_spec.rb @@ -35,13 +35,13 @@ RSpec.describe Users::ImportData::Points, type: :service do it 'assigns the correct country association' do service.call - point = user.tracked_points.last + point = user.points.last expect(point.country).to eq(country) end it 'excludes the string country field from attributes' do service.call - point = user.tracked_points.last + point = user.points.last # The country association should be set, not the string attribute expect(point.read_attribute(:country)).to be_nil expect(point.country).to eq(country) @@ -68,7 +68,7 @@ RSpec.describe Users::ImportData::Points, type: :service do it 'does not create country and leaves country_id nil' do expect { service.call }.not_to change(Country, :count) - point = user.tracked_points.last + point = user.points.last expect(point.country_id).to be_nil expect(point.city).to eq('Berlin') end @@ -126,10 +126,10 @@ RSpec.describe Users::ImportData::Points, type: :service do it 'imports valid points and reconstructs lonlat when needed' do expect(service.call).to eq(2) # Two valid points (original + reconstructed) - expect(user.tracked_points.count).to eq(2) + expect(user.points.count).to eq(2) # Check that lonlat was reconstructed properly - munich_point = user.tracked_points.find_by(city: 'Munich') + munich_point = user.points.find_by(city: 'Munich') expect(munich_point).to be_present expect(munich_point.lonlat.to_s).to match(/POINT\s*\(11\.582\s+48\.1351\)/) end diff --git a/spec/services/visits/create_spec.rb b/spec/services/visits/create_spec.rb new file mode 100644 index 00000000..bc10dd3c --- /dev/null +++ b/spec/services/visits/create_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Visits::Create do + let(:user) { create(:user) } + let(:valid_params) do + { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + end + + describe '#call' do + context 'when all parameters are valid' do + subject(:service) { described_class.new(user, valid_params) } + + it 'creates a visit successfully' do + expect { service.call }.to change { user.visits.count }.by(1) + expect(service.call).to be_truthy + expect(service.visit).to be_persisted + end + + it 'creates a visit with correct attributes' do + service.call + visit = service.visit + + expect(visit.name).to eq('Test Visit') + expect(visit.user).to eq(user) + expect(visit.status).to eq('confirmed') + expect(visit.started_at).to eq(DateTime.parse('2023-12-01T10:00:00Z')) + expect(visit.ended_at).to eq(DateTime.parse('2023-12-01T12:00:00Z')) + expect(visit.duration).to eq(120) # 2 hours in minutes + end + + it 'creates a place with correct coordinates' do + service.call + place = service.visit.place + + expect(place).to be_persisted + expect(place.name).to eq('Test Visit') + expect(place.latitude).to eq(52.52) + expect(place.longitude).to eq(13.405) + expect(place.source).to eq('manual') + end + end + + context 'when reusing existing place' do + let!(:existing_place) do + create(:place, + latitude: 52.52, + longitude: 13.405, + lonlat: 'POINT(13.405 52.52)') + end + let!(:existing_visit) { create(:visit, user: user, place: existing_place) } + + subject(:service) { described_class.new(user, valid_params) } + + it 'reuses the existing place' do + expect { service.call }.not_to change { Place.count } + expect(service.visit.place).to eq(existing_place) + end + + it 'creates a new visit with the existing place' do + expect { service.call }.to change { user.visits.count }.by(1) + expect(service.visit.place).to eq(existing_place) + end + end + + context 'when place creation fails' do + subject(:service) { described_class.new(user, valid_params) } + + before do + allow(Place).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(Place.new)) + end + + it 'returns false' do + expect(service.call).to be(false) + end + + it 'calls ExceptionReporter' do + expect(ExceptionReporter).to receive(:call) + + service.call + end + + it 'does not create a visit' do + expect { service.call }.not_to change { Visit.count } + end + end + + context 'when visit creation fails' do + subject(:service) { described_class.new(user, valid_params) } + + before do + allow_any_instance_of(User).to receive_message_chain(:visits, :create!).and_raise(ActiveRecord::RecordInvalid.new(Visit.new)) + end + + it 'returns false' do + expect(service.call).to be(false) + end + + it 'calls ExceptionReporter' do + expect(ExceptionReporter).to receive(:call) + + service.call + end + end + + context 'edge cases' do + context 'when name is not provided but defaults are used' do + let(:params) { valid_params.merge(name: '') } + subject(:service) { described_class.new(user, params) } + + it 'returns false due to validation' do + expect(service.call).to be(false) + end + end + + context 'when coordinates are strings' do + let(:params) do + valid_params.merge( + latitude: '52.52', + longitude: '13.405' + ) + end + subject(:service) { described_class.new(user, params) } + + it 'converts them to floats and creates visit successfully' do + expect(service.call).to be_truthy + place = service.visit.place + expect(place.latitude).to eq(52.52) + expect(place.longitude).to eq(13.405) + end + end + + context 'when visit duration is very short' do + let(:params) do + valid_params.merge( + started_at: '2023-12-01T12:00:00Z', + ended_at: '2023-12-01T12:01:00Z' # 1 minute + ) + end + subject(:service) { described_class.new(user, params) } + + it 'creates visit with correct duration' do + service.call + expect(service.visit.duration).to eq(1) + end + end + + context 'when visit duration is very long' do + let(:params) do + valid_params.merge( + started_at: '2023-12-01T08:00:00Z', + ended_at: '2023-12-02T20:00:00Z' # 36 hours + ) + end + subject(:service) { described_class.new(user, params) } + + it 'creates visit with correct duration' do + service.call + expect(service.visit.duration).to eq(36 * 60) # 36 hours in minutes + end + end + end + end +end diff --git a/spec/services/visits/place_finder_spec.rb b/spec/services/visits/place_finder_spec.rb index b924ffae..3da17828 100644 --- a/spec/services/visits/place_finder_spec.rb +++ b/spec/services/visits/place_finder_spec.rb @@ -58,8 +58,7 @@ RSpec.describe Visits::PlaceFinder do context 'with places from points data' do let(:point_with_geodata) do build_stubbed(:point, - latitude: latitude, - longitude: longitude, + lonlat: "POINT(#{longitude} #{latitude})", geodata: { 'properties' => { 'name' => 'POI from Point', diff --git a/spec/services/visits/smart_detect_spec.rb b/spec/services/visits/smart_detect_spec.rb index 37ea7638..006770ae 100644 --- a/spec/services/visits/smart_detect_spec.rb +++ b/spec/services/visits/smart_detect_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Visits::SmartDetect do let(:created_visits) { [instance_double(Visit)] } before do - allow(user).to receive_message_chain(:tracked_points, :not_visited, :order, :where).and_return(points) + allow(user).to receive_message_chain(:points, :not_visited, :order, :where).and_return(points) allow(Visits::Detector).to receive(:new).with(points).and_return(visit_detector) allow(Visits::Merger).to receive(:new).with(points).and_return(visit_merger) allow(Visits::Creator).to receive(:new).with(user).and_return(visit_creator) diff --git a/spec/swagger/api/v1/visits_controller_spec.rb b/spec/swagger/api/v1/visits_controller_spec.rb new file mode 100644 index 00000000..b83f6beb --- /dev/null +++ b/spec/swagger/api/v1/visits_controller_spec.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +describe 'Visits API', type: :request do + let(:user) { create(:user) } + let(:api_key) { user.api_key } + let(:place) { create(:place) } + let(:test_visit) { create(:visit, user: user, place: place) } + + path '/api/v1/visits' do + get 'List visits' do + tags 'Visits' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :start_at, in: :query, type: :string, required: false, description: 'Start date (ISO 8601)' + parameter name: :end_at, in: :query, type: :string, required: false, description: 'End date (ISO 8601)' + parameter name: :selection, in: :query, type: :string, required: false, description: 'Set to "true" for area-based search' + parameter name: :sw_lat, in: :query, type: :number, required: false, description: 'Southwest latitude for area search' + parameter name: :sw_lng, in: :query, type: :number, required: false, description: 'Southwest longitude for area search' + parameter name: :ne_lat, in: :query, type: :number, required: false, description: 'Northeast latitude for area search' + parameter name: :ne_lng, in: :query, type: :number, required: false, description: 'Northeast longitude for area search' + + response '200', 'visits found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:start_at) { 1.week.ago.iso8601 } + let(:end_at) { Time.current.iso8601 } + + schema type: :array, + items: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string, enum: %w[suggested confirmed declined] }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer, description: 'Duration in minutes' }, + place: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number }, + city: { type: :string }, + country: { type: :string } + } + } + }, + required: %w[id name status started_at ended_at duration] + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + run_test! + end + end + + post 'Create visit' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :visit, in: :body, schema: { + type: :object, + properties: { + visit: { + type: :object, + properties: { + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime } + }, + required: %w[name latitude longitude started_at ended_at] + } + } + } + + response '200', 'visit created' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit) do + { + visit: { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + schema type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer }, + place: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit) do + { + visit: { + name: '', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:visit) do + { + visit: { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + run_test! + end + end + end + + path '/api/v1/visits/{id}' do + patch 'Update visit' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :visit, in: :body, schema: { + type: :object, + properties: { + visit: { + type: :object, + properties: { + name: { type: :string }, + place_id: { type: :integer }, + status: { type: :string, enum: %w[suggested confirmed declined] } + } + } + } + } + + response '200', 'visit updated' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { test_visit.id } + let(:visit) { { visit: { name: 'Updated Visit' } } } + + schema type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer }, + place: { type: :object } + } + + run_test! + end + + response '404', 'visit not found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { 999999 } + let(:visit) { { visit: { name: 'Updated Visit' } } } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:id) { test_visit.id } + let(:visit) { { visit: { name: 'Updated Visit' } } } + + run_test! + end + end + + delete 'Delete visit' do + tags 'Visits' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + + response '204', 'visit deleted' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { test_visit.id } + + run_test! + end + + response '404', 'visit not found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { 999999 } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:id) { test_visit.id } + + run_test! + end + end + end + + path '/api/v1/visits/{id}/possible_places' do + get 'Get possible places for visit' do + tags 'Visits' + produces 'application/json' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'possible places found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { test_visit.id } + + schema type: :array, + items: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number }, + city: { type: :string }, + country: { type: :string } + } + } + + run_test! + end + + response '404', 'visit not found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { 999999 } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:id) { test_visit.id } + + run_test! + end + end + end + + path '/api/v1/visits/merge' do + post 'Merge visits' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :merge_params, in: :body, schema: { + type: :object, + properties: { + visit_ids: { + type: :array, + items: { type: :integer }, + minItems: 2, + description: 'Array of visit IDs to merge (minimum 2)' + } + }, + required: %w[visit_ids] + } + + response '200', 'visits merged' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit1) { create(:visit, user: user) } + let(:visit2) { create(:visit, user: user) } + let(:merge_params) { { visit_ids: [visit1.id, visit2.id] } } + + schema type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer }, + place: { type: :object } + } + + run_test! + end + + response '422', 'invalid request' do + let(:Authorization) { "Bearer #{api_key}" } + let(:merge_params) { { visit_ids: [test_visit.id] } } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:merge_params) { { visit_ids: [test_visit.id] } } + + run_test! + end + end + end + + path '/api/v1/visits/bulk_update' do + post 'Bulk update visits' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :bulk_params, in: :body, schema: { + type: :object, + properties: { + visit_ids: { + type: :array, + items: { type: :integer }, + description: 'Array of visit IDs to update' + }, + status: { + type: :string, + enum: %w[suggested confirmed declined], + description: 'New status for the visits' + } + }, + required: %w[visit_ids status] + } + + response '200', 'visits updated' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit1) { create(:visit, user: user, status: 'suggested') } + let(:visit2) { create(:visit, user: user, status: 'suggested') } + let(:bulk_params) { { visit_ids: [visit1.id, visit2.id], status: 'confirmed' } } + + schema type: :object, + properties: { + message: { type: :string }, + updated_count: { type: :integer } + } + + run_test! + end + + response '422', 'invalid request' do + let(:Authorization) { "Bearer #{api_key}" } + let(:bulk_params) { { visit_ids: [test_visit.id], status: 'invalid_status' } } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:bulk_params) { { visit_ids: [test_visit.id], status: 'confirmed' } } + + run_test! + end + end + end +end \ No newline at end of file diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index bc25a57d..86d72768 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1275,6 +1275,424 @@ paths: responses: '200': description: user found + "/api/v1/visits": + get: + summary: List visits + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + - name: start_at + in: query + required: false + description: Start date (ISO 8601) + schema: + type: string + - name: end_at + in: query + required: false + description: End date (ISO 8601) + schema: + type: string + - name: selection + in: query + required: false + description: Set to "true" for area-based search + schema: + type: string + - name: sw_lat + in: query + required: false + description: Southwest latitude for area search + schema: + type: number + - name: sw_lng + in: query + required: false + description: Southwest longitude for area search + schema: + type: number + - name: ne_lat + in: query + required: false + description: Northeast latitude for area search + schema: + type: number + - name: ne_lng + in: query + required: false + description: Northeast longitude for area search + schema: + type: number + responses: + '200': + description: visits found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + enum: + - suggested + - confirmed + - declined + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + description: Duration in minutes + place: + type: object + properties: + id: + type: integer + name: + type: string + latitude: + type: number + longitude: + type: number + city: + type: string + country: + type: string + required: + - id + - name + - status + - started_at + - ended_at + - duration + '401': + description: unauthorized + post: + summary: Create visit + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visit created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + place: + type: object + properties: + id: + type: integer + name: + type: string + latitude: + type: number + longitude: + type: number + '422': + description: invalid request + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit: + type: object + properties: + name: + type: string + latitude: + type: number + longitude: + type: number + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + required: + - name + - latitude + - longitude + - started_at + - ended_at + "/api/v1/visits/{id}": + patch: + summary: Update visit + tags: + - Visits + parameters: + - name: id + in: path + required: true + description: Visit ID + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visit updated + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + place: + type: object + '404': + description: visit not found + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit: + type: object + properties: + name: + type: string + place_id: + type: integer + status: + type: string + enum: + - suggested + - confirmed + - declined + delete: + summary: Delete visit + tags: + - Visits + parameters: + - name: id + in: path + required: true + description: Visit ID + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '204': + description: visit deleted + '404': + description: visit not found + '401': + description: unauthorized + "/api/v1/visits/{id}/possible_places": + get: + summary: Get possible places for visit + tags: + - Visits + parameters: + - name: id + in: path + required: true + description: Visit ID + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: possible places found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + latitude: + type: number + longitude: + type: number + city: + type: string + country: + type: string + '404': + description: visit not found + '401': + description: unauthorized + "/api/v1/visits/merge": + post: + summary: Merge visits + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visits merged + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + place: + type: object + '422': + description: invalid request + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit_ids: + type: array + items: + type: integer + minItems: 2 + description: Array of visit IDs to merge (minimum 2) + required: + - visit_ids + "/api/v1/visits/bulk_update": + post: + summary: Bulk update visits + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visits updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + updated_count: + type: integer + '422': + description: invalid request + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit_ids: + type: array + items: + type: integer + description: Array of visit IDs to update + status: + type: string + enum: + - suggested + - confirmed + - declined + description: New status for the visits + required: + - visit_ids + - status servers: - url: http://{defaultHost} variables: diff --git a/tests/system/test_scenarios.md b/tests/system/test_scenarios.md deleted file mode 100644 index 4b4177ff..00000000 --- a/tests/system/test_scenarios.md +++ /dev/null @@ -1,352 +0,0 @@ -# Dawarich System Test Scenarios - -This document tracks all system test scenarios for the Dawarich application. Completed scenarios are marked with `[x]` and pending scenarios with `[ ]`. - -## 1. Authentication & User Management - -### Sign In/Out -- [x] User can sign in with valid credentials -- [x] User is redirected to map page after successful sign in -- [x] User cannot sign in with invalid credentials -- [x] User can sign out successfully -- [x] User is redirected to sign in page when accessing protected routes while signed out - -### User Registration -- [ ] New user can register with valid information -- [ ] Registration fails with invalid email format -- [ ] Registration fails with weak password -- [ ] Registration fails with mismatched password confirmation -- [ ] Email confirmation process works correctly - -### Password Management -- [ ] User can request password reset -- [ ] Password reset email is sent -- [ ] User can reset password with valid token -- [ ] Password reset fails with expired token -- [ ] User can change password when signed in - -## 2. Map Functionality - -### Basic Map Operations -- [x] Leaflet map initializes correctly -- [x] Map displays with proper container and panes -- [x] Map tiles load successfully -- [x] Zoom in/out functionality works -- [x] Map controls are present and functional - -### Map Layers -- [x] Base layer switching (OpenStreetMap ↔ OpenTopo) -- [x] Layer control expands and collapses -- [x] Overlay layers can be toggled (Points, Routes, Fog of War, Heatmap, etc.) -- [x] Layer states persist after settings updates -- [ ] Fallback map layer when preferred layer fails -- [ ] Custom tile layer configuration -- [ ] Layer loading error handling - -### Map Data Display -- [x] Route data loads and displays -- [x] Point markers appear on map -- [x] Map statistics display (distance, points count) -- [x] Map scale control shows correctly -- [x] Map attributions are present - -## 3. Route Management - -### Route Display -- [x] Routes render as polylines -- [x] Route opacity can be adjusted -- [x] Speed-colored routes toggle works -- [x] Route splitting settings can be configured - -### Route Interaction -- [x] Route popup displays on hover/click (basic structure) -- [x] Popup shows start/end times, duration, distance, speed -- [x] Distance units convert properly (km ↔ miles) -- [x] Speed units convert properly (km/h ↔ mph) -- [ ] Route deletion with confirmation (not implemented yet) -- [ ] Route merging/splitting operations (not implemented yet) -- [ ] Route export functionality (not implemented yet) - -## 4. Point Management - -### Point Display -- [x] Points display as markers -- [x] Point popups show detailed information -- [x] Point rendering mode can be toggled (raw/simplified) - -### Point Operations -- [x] Point deletion link is present and functional -- [ ] Point deletion confirmation dialog -- [ ] Point editing (coordinates via drag and drop) -- [ ] Point filtering by date/time - -## 5. Settings Panel - -### Map Settings -- [x] Settings panel opens and closes -- [x] Route opacity updates -- [x] Fog of war settings (radius, threshold) -- [x] Route splitting configuration (meters, minutes) -- [x] Points rendering mode toggle -- [x] Live map functionality toggle -- [x] Speed-colored routes toggle -- [x] Speed color scale updates -- [x] Gradient editor modal interaction - -### Settings Validation -- [ ] Invalid settings values are rejected -- [ ] Settings form validation messages -- [ ] Settings reset to defaults -- [ ] Settings import/export functionality - -## 6. Calendar Panel - -### Calendar Display -- [x] Calendar button is functional -- [x] Calendar panel opens and displays correctly -- [ ] Year selection works -- [ ] Month navigation functions -- [ ] Visited cities information displays - -### Calendar Interaction -- [ ] Date selection filters map data -- [x] Calendar state persists in localStorage -- [ ] Calendar navigation with keyboard shortcuts (not implemented yet) - -## 7. Data Import/Export - -### Import Functionality -- [ ] GPX file import -- [ ] JSON data import -- [ ] .rec file import -- [ ] Import validation and error handling -- [ ] Import progress indication -- [ ] Duplicate data handling during import - -### Export Functionality -- [ ] GPX file export -- [ ] JSON data export -- [ ] Date range export filtering -- [ ] Export progress indication - -## 8. Statistics & Analytics - -### Statistics Display -- [x] Map statistics show distance and points -- [ ] Detailed statistics page -- [ ] Distance traveled by time period -- [ ] Speed analytics -- [ ] Location frequency analysis -- [ ] Activity patterns visualization - -### Charts & Visualizations -- [ ] Distance over time charts -- [ ] Speed distribution charts -- [ ] Heatmap visualization -- [ ] Activity timeline -- [ ] Geographic distribution charts - -## 9. Photos & Media - -### Photo Management -- [ ] Photo display on map -- [ ] Photo popup with details - -## 10. Areas & Geofencing - -### Area Management -- [ ] Create new areas -- [ ] Edit existing areas -- [ ] Delete areas -- [ ] Area visualization on map - -### Area Functionality -- [ ] Time spent in areas calculation -- [ ] Area visit history -- [ ] Area-based filtering - -## 11. Performance & Error Handling - -### Performance Testing -- [x] Large dataset handling without crashes -- [x] Memory cleanup on page navigation -- [ ] Tile monitoring functionality -- [ ] Map rendering performance with many points -- [ ] Data loading optimization - -### Error Handling -- [x] Empty markers array handling -- [x] Missing user settings gracefully handled -- [ ] Network connectivity issues -- [ ] Failed API calls handling -- [ ] Invalid coordinates handling -- [ ] Database connection errors -- [ ] File upload errors - -## 12. User Preferences & Persistence - -### Preference Management -- [x] Distance unit preferences (km/miles) -- [ ] Preferred map layer persistence -- [x] Panel state persistence (basic) -- [ ] Theme preferences (light/dark mode) -- [ ] Timezone settings (not implemented yet) - -### Data Persistence -- [ ] Map view state persistence (zoom, center) -- [ ] Filter preferences persistence - -## 13. API Integration - -### External APIs -- [x] GitHub API integration (version checking) -- [ ] Reverse geocoding functionality - -### API Error Handling -- [x] GitHub API stub for testing -- [ ] API rate limiting handling -- [ ] API timeout handling -- [ ] Fallback when APIs are unavailable - -## 14. Mobile Responsiveness - -### Mobile Layout -- [ ] Map displays correctly on mobile devices -- [ ] Touch gestures work (pinch to zoom, pan) -- [ ] Mobile-optimized controls -- [ ] Responsive navigation menu - -## 15. Security & Privacy - -### Data Security -- [ ] User data isolation (users only see their own data) -- [ ] Secure file upload validation -- [ ] XSS protection in user inputs -- [ ] CSRF protection on forms - -### Privacy Features -- [ ] Data anonymization options -- [ ] Location data privacy settings -- [ ] Data deletion functionality -- [ ] Privacy policy compliance - -## 16. Accessibility - -### WCAG Compliance -- [ ] Keyboard navigation support -- [ ] Screen reader compatibility -- [ ] High contrast mode support -- [ ] Focus indicators on interactive elements - -### Usability -- [ ] Tooltips and help text -- [ ] Error message clarity -- [ ] Loading states and progress indicators -- [ ] Consistent UI patterns - -## 17. Integration Testing - -### Database Operations -- [ ] Data migration testing -- [ ] Backup and restore functionality -- [ ] Database performance with large datasets -- [ ] Concurrent user operations - -## 18. Navigation & UI - -### Main Navigation -- [ ] Navigation menu functionality -- [ ] Page transitions work smoothly -- [ ] Back/forward browser navigation - -## 19. Trips & Journey Management - -### Trip Creation -- [ ] Automatic trip detection (not implemented yet) -- [ ] Manual trip creation -- [ ] Trip editing (name, description, dates) -- [ ] Trip deletion with confirmation - -### Trip Display -- [ ] Trip list view -- [ ] Trip detail view -- [ ] Trip statistics -- [ ] Trip sharing functionality (not implemented yet) - -## 21. Notifications & Alerts - -### System Notifications -- [x] Success message display -- [ ] Error message display -- [ ] Warning notifications -- [ ] Info notifications - -### User Notifications -- [ ] Email notifications for important events - -## 20. Search & Filtering - -### Search Functionality -- [ ] Global search across all data -- [ ] Location-based search -- [ ] Date range search -- [ ] Advanced search filters - -### Data Filtering -- [ ] Filter by date range -- [ ] Filter by location/area -- [ ] Filter by activity type -- [ ] Filter by speed/distance - -## 21. Backup & Data Management - -### Data Backup -- [ ] Manual data backup -- [ ] Backup verification -- [ ] Backup restoration - -### Data Cleanup -- [ ] Duplicate data detection -- [ ] Data archiving -- [ ] Data purging (old data) -- [ ] Storage optimization - ---- - -## Test Execution Summary - -**Total Scenarios:** 180+ -**Completed:** 51 βœ… -**Pending:** 129+ ⏳ -**Coverage:** ~28% - -### Priority for Next Implementation: -1. **Authentication flows** (sign out, invalid credentials, registration) -2. **Error handling** (network issues, invalid data, API failures) -3. **Calendar panel JavaScript interactions** -4. **Data import/export functionality** -5. **Mobile responsiveness testing** -6. **Security & privacy features** -7. **Performance optimization tests** -8. **Navigation & UI consistency** - -### High-Impact Areas to Focus On: -- **User Authentication & Security** - Critical for production use -- **Data Import/Export** - Core functionality for user data management -- **Error Handling** - Essential for robust application behavior -- **Mobile Experience** - Important for modern web applications -- **Performance** - Critical for user experience with large datasets - -### Testing Strategy Notes: -- **System Tests**: Focus on user workflows and integration -- **Unit Tests**: Cover individual components and business logic -- **API Tests**: Ensure robust API behavior and error handling -- **Performance Tests**: Validate application behavior under load -- **Security Tests**: Verify data protection and access controls - -### Tools & Frameworks: -- **RSpec + Capybara**: System/integration testing -- **Selenium WebDriver**: Browser automation -- **WebMock**: External API mocking -- **FactoryBot**: Test data generation -- **SimpleCov**: Code coverage analysis