diff --git a/.app_version b/.app_version index 8df3f459..85e60ed1 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.33.1 +0.34.0 diff --git a/.env.development b/.env.development index edab341c..5cafd969 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,6 @@ DATABASE_PASSWORD=password DATABASE_NAME=dawarich_development DATABASE_PORT=5432 REDIS_URL=redis://localhost:6379 + +# Fix for macOS fork() issues with Sidekiq +OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index b88c72e8..3c04cdb6 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -74,18 +74,6 @@ jobs: # Set platforms based on version type and release type PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7" - # Check if this is a patch version (x.y.z where z > 0) - if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then - echo "Detected patch version ($VERSION) - building for AMD64 only" - PLATFORMS="linux/amd64" - elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then - echo "Detected minor version ($VERSION) - building for all platforms" - PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7" - else - echo "Version format not recognized or non-semver - using AMD64 only for safety" - PLATFORMS="linux/amd64" - fi - # Add :rc tag for pre-releases if [ "${{ github.event.release.prerelease }}" = "true" ]; then TAGS="${TAGS},freikin/dawarich:rc" diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc0f48b..3c4ca6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,31 @@ 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/). -# [0.33.1] +# [0.34.0] - 2025-10-10 + +## The Family release + +In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time. + +## Added + +- Users can now create family groups and invite members to join. + +## Fixed + +- Sign out button works again. #1844 +- Fixed user deletion bug where user could not be deleted due to counter cache on points. +- Users always have default distance unit set to kilometers. #1832 +- All confirmation dialogs are now showing only once. + +## Changed + +- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840 +- Importing process for Google Maps Timeline exports, GeoJSON and geodata from photos is now significantly faster. +- The Map page now features a full-screen map. + + +# [0.33.1] - 2025-10-07 ## Changed diff --git a/Procfile b/Procfile index fd4fe014..3eb630b7 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,3 @@ +release: bundle exec rails db:migrate web: bundle exec puma -C config/puma.rb worker: bundle exec sidekiq -C config/sidekiq.yml diff --git a/app.json b/app.json index 9c425d4e..fcf27c70 100644 --- a/app.json +++ b/app.json @@ -5,11 +5,6 @@ { "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" }, { "url": "https://github.com/heroku/heroku-buildpack-ruby.git" } ], - "scripts": { - "dokku": { - "predeploy": "bundle exec rails db:migrate" - } - }, "healthchecks": { "web": [ { diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index a47979db..780b039d 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}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}: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-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.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( +*,: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{display:inline-flex;position:relative}.avatar>div{aspect-ratio:1/1;display:block;overflow:hidden}.avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.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}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}: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}:root .countdown{line-height:1em}.countdown{display:inline-flex}.countdown>*{display:inline-block;height:1em;overflow-y:hidden}.countdown>:before{content:"00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";position:relative;text-align:center;top:calc(var(--value)*-1em);transition:all 1s cubic-bezier(1,0,0,1);white-space:pre}.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!important;column-gap:1rem!important;display:grid!important;font-size:.875rem!important;grid-auto-flow:row!important;line-height:1.25rem!important;place-items:start!important;row-gap:2.5rem!important;width:100%!important}.footer{-moz-column-gap:1rem;column-gap:1rem;display:grid;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;place-items:start;row-gap:2.5rem;width:100%}.\!footer>*{display:grid!important;gap:.5rem!important;place-items:start!important}.footer>*{display:grid;gap:.5rem;place-items:start}@media (min-width:48rem){.\!footer{grid-auto-flow:column!important}.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-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.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!important;-moz-appearance:none!important;appearance:none!important;border-color:transparent!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;flex-shrink:1!important;font-size:1rem!important;height:3rem!important;line-height:2!important;line-height:1.5rem!important;padding-left:1rem!important;padding-right:1rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.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[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem!important;margin-top:-1rem!important;margin-inline-end:-1rem!important}.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}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.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))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity: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-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-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/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-ghost{--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:1;color:var(--fallback-bc,oklch(var(--bc)/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 .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.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-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/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-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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)}.toast>*{animation:toast-pop .25s ease-out}@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-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--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)))}.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}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.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-wide{width:16rem}.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}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[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}.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)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y: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))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--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))}.toast:where(.toast-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] .toast:where(.toast-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))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--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))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y: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))}.toast:where(.toast-middle){bottom:auto;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))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y: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))}.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}.inset-0{inset:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.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}.col-span-2{grid-column:span 2/span 2}.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-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-12{height:3rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.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-48{max-height:12rem}.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-8{width:2rem}.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-lg{max-width:32rem}.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))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.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-2{grid-template-columns:repeat(2,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}.place-items-center{place-items:center}.items-start{align-items:flex-start}.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-1{gap:.25rem}.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-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(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-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.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-b-2{border-bottom-width:2px}.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-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/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-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/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-transparent{border-color:transparent}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.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-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/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-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)))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.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}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.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}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-6{padding-top:1.5rem}.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-base{font-size:1rem;line-height:1.5rem}.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-normal{font-weight:400}.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-400{--tw-text-opacity:1;color:rgb(156 163 175/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-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/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-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/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}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.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-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-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-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-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);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.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)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.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-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}.hover\:scale-105:hover,.hover\:scale-\[1\.02\]:hover{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\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.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-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/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\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/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-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--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\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--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,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-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\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,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}} \ 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}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.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))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity: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-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-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/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-ghost{--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:1;color:var(--fallback-bc,oklch(var(--bc)/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!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.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!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.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:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.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,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.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:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.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-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.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 .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.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!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;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))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;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{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;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))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;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::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-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/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-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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)}.toast>*{animation:toast-pop .25s ease-out}@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-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--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)))}.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}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.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-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.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-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.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}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[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-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.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)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y: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))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--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))}.toast:where(.toast-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] .toast:where(.toast-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))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--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))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y: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))}.toast:where(.toast-middle){bottom:auto;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))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y: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))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.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%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.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%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.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}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-20{top:5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.col-span-2{grid-column:span 2/span 2}.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-12{margin-bottom:3rem}.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-3{margin-left:.75rem}.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-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.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%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.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{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.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-8{width:2rem}.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-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.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}.flex-grow{flex-grow:1}.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))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,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-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.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-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.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-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(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-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.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-b-2{border-bottom-width:2px}.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-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/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-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.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-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/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-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/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-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/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{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/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)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.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}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.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-12{padding-bottom:3rem;padding-top:3rem}.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}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.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-base{font-size:1rem;line-height:1.5rem}.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-normal{font-weight:400}.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-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/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-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/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-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/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-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/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}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.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-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-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-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-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);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.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)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.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-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}.hover\:scale-105:hover,.hover\:scale-\[1\.02\]:hover{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\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.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-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/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\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/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-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--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\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--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,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.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}}@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\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\: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\:mt-0{margin-top:0}.lg\:\!block{display:block!important}.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\:items-end{align-items:flex-end}.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\: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)))}.lg\:text-left{text-align:left}}@media (min-width:1280px){.xl\:inline{display:inline}.xl\:hidden{display:none}} \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 5e954a44..52e272ff 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -101,3 +101,63 @@ content: '✅'; animation: none; } + +/* Flash message animations */ +@keyframes slideInFromRight { + 0% { + transform: translateX(100%); + opacity: 0; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutToRight { + 0% { + transform: translateX(0); + opacity: 1; + } + 100% { + transform: translateX(100%); + opacity: 0; + } +} + +/* Family feature specific styles */ +.family-member-card { + transition: all 0.2s ease-in-out; +} + +.family-member-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.invitation-card { + border-left: 4px solid #f59e0b; +} + +.family-invitation-form { + max-width: 500px; +} + +/* Loading states */ +.btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} diff --git a/app/assets/stylesheets/leaflet_theme.css b/app/assets/stylesheets/leaflet_theme.css index 7c7c1bea..f10588a4 100644 --- a/app/assets/stylesheets/leaflet_theme.css +++ b/app/assets/stylesheets/leaflet_theme.css @@ -34,6 +34,7 @@ color: var(--leaflet-text-color) !important; border-color: var(--leaflet-border-color) !important; box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important; + } /* Leaflet zoom buttons */ @@ -51,6 +52,32 @@ .leaflet-control-layers-toggle { background-color: var(--leaflet-bg-color) !important; color: var(--leaflet-text-color) !important; + /* Replace default icon with custom SVG */ + background-image: none !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.leaflet-control-layers-toggle::before { + content: '' !important; + display: block !important; + width: 24px !important; + height: 24px !important; + background-image: url('data:image/svg+xml,') !important; + background-size: contain !important; + background-repeat: no-repeat !important; + background-position: center !important; +} + +/* Dark theme - use white stroke for the icon */ +[data-theme="dark"] .leaflet-control-layers-toggle::before { + background-image: url('data:image/svg+xml,') !important; +} + +/* Light theme - use black stroke for the icon */ +[data-theme="light"] .leaflet-control-layers-toggle::before { + background-image: url('data:image/svg+xml,') !important; } .leaflet-control-layers-expanded { @@ -138,4 +165,72 @@ background: var(--leaflet-scale-bg) !important; border-radius: 3px !important; padding: 2px !important; -} \ No newline at end of file +} + +/* Family member tooltip - dark styled like the visit popup */ +.leaflet-tooltip.family-member-tooltip { + background-color: #374151 !important; + color: #ffffff !important; + border: 1px solid #4b5563 !important; + border-radius: 4px !important; + padding: 4px 8px !important; + font-size: 11px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +.leaflet-tooltip.family-member-tooltip::before { + border-top-color: #374151 !important; +} + +/* Family member popup - just override colors, keep default layout */ +.leaflet-popup-content-wrapper:has(.family-member-popup) { + background-color: #1f2937 !important; + color: #f9fafb !important; +} + +.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip { + background-color: #1f2937 !important; +} + +/* Family member marker pulse animation for recent updates */ +@keyframes family-marker-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); + } + 50% { + box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +.family-member-marker-recent { + animation: family-marker-pulse 2s infinite; + border-radius: 50% !important; +} + +.family-member-marker-recent .leaflet-marker-icon > div { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7); + border-radius: 50%; +} + +/* Fix bottom controls being cut off */ +.leaflet-bottom { + padding-bottom: 10px !important; + transition: padding-bottom 0.3s ease; +} + +.leaflet-bottom.leaflet-left { + padding-left: 10px !important; +} + +.leaflet-bottom.leaflet-right { + padding-right: 10px !important; +} + +/* DaisyUI tooltips on map buttons - ensure they appear above date navigation (z-index: 9999) */ +.tooltip:before, +.tooltip:after { + z-index: 10000 !important; +} diff --git a/app/assets/svg/icons/lucide/outline/chart-column.svg b/app/assets/svg/icons/lucide/outline/chart-column.svg new file mode 100644 index 00000000..bc565a6a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chart-column.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/chevron-down.svg b/app/assets/svg/icons/lucide/outline/chevron-down.svg new file mode 100644 index 00000000..8b4e4328 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/chevron-left.svg b/app/assets/svg/icons/lucide/outline/chevron-left.svg new file mode 100644 index 00000000..47bdb982 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chevron-left.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/chevron-right.svg b/app/assets/svg/icons/lucide/outline/chevron-right.svg new file mode 100644 index 00000000..4c0ff5ee --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/chevron-up.svg b/app/assets/svg/icons/lucide/outline/chevron-up.svg new file mode 100644 index 00000000..69b93bd9 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/circle-alert.svg b/app/assets/svg/icons/lucide/outline/circle-alert.svg new file mode 100644 index 00000000..bce0713a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-alert.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/circle-check.svg b/app/assets/svg/icons/lucide/outline/circle-check.svg new file mode 100644 index 00000000..cd6c711c --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-check.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/circle-x.svg b/app/assets/svg/icons/lucide/outline/circle-x.svg new file mode 100644 index 00000000..db26d2f3 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-x.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/heart.svg b/app/assets/svg/icons/lucide/outline/heart.svg new file mode 100644 index 00000000..fc6135a7 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/heart.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/search.svg b/app/assets/svg/icons/lucide/outline/search.svg new file mode 100644 index 00000000..bb7909be --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/search.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/shield-check.svg b/app/assets/svg/icons/lucide/outline/shield-check.svg new file mode 100644 index 00000000..26716f2f --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/shield-check.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/square-dashed-mouse-pointer.svg b/app/assets/svg/icons/lucide/outline/square-dashed-mouse-pointer.svg new file mode 100644 index 00000000..a2ffde85 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/square-dashed-mouse-pointer.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/square-pen.svg b/app/assets/svg/icons/lucide/outline/square-pen.svg new file mode 100644 index 00000000..06515ddf --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/square-pen.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/trash-2.svg b/app/assets/svg/icons/lucide/outline/trash-2.svg new file mode 100644 index 00000000..1a24da1a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/trash-2.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/triangle-alert.svg b/app/assets/svg/icons/lucide/outline/triangle-alert.svg new file mode 100644 index 00000000..ab5f291b --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/triangle-alert.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/user.svg b/app/assets/svg/icons/lucide/outline/user.svg new file mode 100644 index 00000000..9d318028 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/user.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/users.svg b/app/assets/svg/icons/lucide/outline/users.svg new file mode 100644 index 00000000..e06a26ed --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/users.svg @@ -0,0 +1 @@ + diff --git a/app/channels/family_locations_channel.rb b/app/channels/family_locations_channel.rb new file mode 100644 index 00000000..d1df714e --- /dev/null +++ b/app/channels/family_locations_channel.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class FamilyLocationsChannel < ApplicationCable::Channel + def subscribed + return reject unless DawarichSettings.family_feature_enabled? + return reject unless current_user.in_family? + + stream_for current_user.family + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/app/controllers/api/v1/families_controller.rb b/app/controllers/api/v1/families_controller.rb new file mode 100644 index 00000000..3cd93894 --- /dev/null +++ b/app/controllers/api/v1/families_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Api::V1::FamiliesController < ApiController + before_action :ensure_family_feature_enabled! + before_action :ensure_user_in_family! + + def locations + family_locations = Families::Locations.new(current_api_user).call + + render json: { + locations: family_locations, + updated_at: Time.current.iso8601, + sharing_enabled: current_api_user.family_sharing_enabled? + } + end + + private + + def ensure_user_in_family! + return if current_api_user.in_family? + + render json: { error: 'User is not part of a family' }, status: :forbidden + end +end diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index 7404ec01..6d29bf18 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -30,7 +30,8 @@ class Api::V1::SettingsController < ApiController :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :preferred_map_layer, :points_rendering_mode, :live_map_enabled, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, - :speed_colored_routes, :speed_color_scale, :fog_of_war_threshold + :speed_colored_routes, :speed_color_scale, :fog_of_war_threshold, + enabled_map_layers: [] ) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 03352f1a..bdf00702 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -40,6 +40,14 @@ class ApplicationController < ActionController::Base end def after_sign_in_path_for(resource) + # Check for family invitation first + invitation_token = params[:invitation_token] || session[:invitation_token] + if invitation_token.present? + invitation = Family::Invitation.find_by(token: invitation_token) + return family_invitation_path(invitation.token) if invitation&.can_be_accepted? + end + + # Handle iOS client flow client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client] case client_type @@ -56,6 +64,12 @@ class ApplicationController < ActionController::Base end end + def ensure_family_feature_enabled! + return if DawarichSettings.family_feature_enabled? + + render json: { error: 'Family feature is not enabled' }, status: :forbidden + end + private def set_self_hosted_status diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb new file mode 100644 index 00000000..5ce52f56 --- /dev/null +++ b/app/controllers/families_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class FamiliesController < ApplicationController + before_action :authenticate_user! + before_action :ensure_family_feature_enabled! + before_action :set_family, only: %i[show edit update destroy update_location_sharing] + + def show + authorize @family + + @members = @family.members.includes(:family_membership).order(:email) + @pending_invitations = @family.active_invitations.order(:created_at) + + @member_count = @family.member_count + @can_invite = @family.can_add_members? + end + + def new + redirect_to family_path and return if current_user.in_family? + + @family = Family.new + authorize @family + end + + def create + @family = Family.new(family_params) + authorize @family + + service = Families::Create.new( + user: current_user, + name: family_params[:name] + ) + + if service.call + redirect_to family_path, notice: 'Family created successfully!' + else + @family = Family.new(family_params) + + if service.errors.any? + service.errors.each do |error| + @family.errors.add(error.attribute, error.message) + end + end + + if service.error_message.present? + @family.errors.add(:base, service.error_message) + end + + flash.now[:alert] = service.error_message || 'Failed to create family' + render :new, status: :unprocessable_content + end + end + + def edit + authorize @family + end + + def update + authorize @family + + if @family.update(family_params) + redirect_to family_path, notice: 'Family updated successfully!' + else + render :edit, status: :unprocessable_content + end + end + + def destroy + authorize @family + + if @family.members.count > 1 + redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.' + else + @family.destroy + redirect_to new_family_path, notice: 'Family deleted successfully!' + end + end + + def update_location_sharing + result = Families::UpdateLocationSharing.new( + user: current_user, + enabled: params[:enabled], + duration: params[:duration] + ).call + + render json: result.payload, status: result.status + end + + private + + def set_family + @family = current_user.family + redirect_to new_family_path, alert: 'You are not in a family' unless @family + end + + def family_params + params.require(:family).permit(:name) + end +end diff --git a/app/controllers/family/invitations_controller.rb b/app/controllers/family/invitations_controller.rb new file mode 100644 index 00000000..73bb3da3 --- /dev/null +++ b/app/controllers/family/invitations_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Family::InvitationsController < ApplicationController + before_action :authenticate_user!, except: %i[show] + before_action :ensure_family_feature_enabled!, except: %i[show] + before_action :set_family, except: %i[show] + before_action :set_invitation_by_id_and_family, only: %i[destroy] + + def index + authorize @family, :show? + + @pending_invitations = @family.family_invitations.active + end + + def show + token = params[:token] || params[:id] + @invitation = Family::Invitation.find_by!(token: token) + + if @invitation.expired? + redirect_to root_path, alert: 'This invitation has expired.' and return + end + + unless @invitation.pending? + redirect_to root_path, alert: 'This invitation is no longer valid.' and return + end + end + + def create + authorize @family, :invite? + + service = Families::Invite.new( + family: @family, + email: invitation_params[:email], + invited_by: current_user + ) + + if service.call + redirect_to family_path, notice: 'Invitation sent successfully!' + else + redirect_to family_path, alert: service.error_message || 'Failed to send invitation' + end + end + + def destroy + authorize @family, :manage_invitations? + + begin + if @invitation.update(status: :cancelled) + redirect_to family_path, notice: 'Invitation cancelled' + else + redirect_to family_path, alert: 'Failed to cancel invitation. Please try again' + end + rescue StandardError => e + Rails.logger.error "Error cancelling family invitation: #{e.message}" + redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation' + end + end + + private + + def set_family + @family = current_user.family + + redirect_to new_family_path, alert: 'You are not in a family' and return unless @family + end + + def set_invitation_by_id_and_family + # For authenticated nested routes: /families/:family_id/invitations/:id + # The :id param contains the token value + @family = current_user.family + @invitation = @family.family_invitations.find_by!(token: params[:id]) + end + + def invitation_params + params.require(:family_invitation).permit(:email) + end +end diff --git a/app/controllers/family/memberships_controller.rb b/app/controllers/family/memberships_controller.rb new file mode 100644 index 00000000..23aaaddf --- /dev/null +++ b/app/controllers/family/memberships_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Family::MembershipsController < ApplicationController + before_action :authenticate_user! + before_action :ensure_family_feature_enabled! + before_action :set_family, except: %i[create] + before_action :set_membership, only: %i[destroy] + before_action :set_invitation, only: %i[create] + + def create + authorize @invitation, policy_class: Family::MembershipPolicy + + service = Families::AcceptInvitation.new( + invitation: @invitation, + user: current_user + ) + + if service.call + redirect_to family_path, notice: 'Welcome to the family!' + else + redirect_to root_path, alert: service.error_message || 'Unable to accept invitation' + end + rescue Pundit::NotAuthorizedError + alert = case + when @invitation.expired? then 'This invitation is no longer valid or has expired' + when !@invitation.pending? then 'This invitation has already been processed' + when @invitation.email != current_user.email then 'This invitation is not for your email address' + else 'You are not authorized to accept this invitation' + end + + redirect_to root_path, alert: alert + rescue StandardError => e + Rails.logger.error "Error accepting family invitation: #{e.message}" + + redirect_to root_path, alert: 'An unexpected error occurred. Please try again later' + end + + def destroy + authorize @membership + + member_user = @membership.user + service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user) + + if service.call + if member_user == current_user + redirect_to new_family_path, notice: 'You have left the family' + else + redirect_to family_path, notice: "#{member_user.email} has been removed from the family" + end + else + redirect_to family_path, alert: service.error_message || 'Failed to remove member' + end + end + + private + + def set_family + @family = current_user.family + + redirect_to new_family_path, alert: 'You are not in a family' and return unless @family + end + + def set_membership + @membership = @family.family_memberships.find(params[:id]) + end + + def set_invitation + @invitation = Family::Invitation.find_by!(token: params[:token]) + end +end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index cf4540c4..bffc5461 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -2,6 +2,7 @@ class MapController < ApplicationController before_action :authenticate_user! + layout 'map', only: :index def index @points = filtered_points diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb new file mode 100644 index 00000000..fd6a448c --- /dev/null +++ b/app/controllers/users/registrations_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Users::RegistrationsController < Devise::RegistrationsController + before_action :set_invitation, only: %i[new create] + before_action :check_registration_allowed, only: %i[new create] + + def new + build_resource({}) + + resource.email = @invitation.email if @invitation + + yield resource if block_given? + + respond_with resource + end + + def create + super do |resource| + if resource.persisted? && @invitation + accept_invitation_for_user(resource) + end + end + end + + protected + + def after_sign_up_path_for(resource) + return family_path if @invitation&.family + + super(resource) + end + + def after_inactive_sign_up_path_for(resource) + return family_path if @invitation&.family + + super(resource) + end + + private + + def check_registration_allowed + return unless self_hosted_mode? + return if valid_invitation_token? + + redirect_to root_path, + alert: 'Registration is not available. Please contact your administrator for access.' + end + + def set_invitation + return unless invitation_token.present? + + @invitation = Family::Invitation.find_by(token: invitation_token) + end + + def self_hosted_mode? + env_value = ENV['SELF_HOSTED'] + return ActiveModel::Type::Boolean.new.cast(env_value) unless env_value.nil? + + false + end + + def valid_invitation_token? + @invitation&.can_be_accepted? + end + + def invitation_token + @invitation_token ||= params[:invitation_token] || + params.dig(:user, :invitation_token) || + session[:invitation_token] + end + + def accept_invitation_for_user(user) + return unless @invitation&.can_be_accepted? + + service = Families::AcceptInvitation.new( + invitation: @invitation, + user: user + ) + + if service.call + flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family." + else + flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}" + end + rescue StandardError => e + Rails.logger.error "Error accepting invitation during registration: #{e.message}" + flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again." + end + + def sign_up_params + super + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 00000000..e1760817 --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Users::SessionsController < Devise::SessionsController + before_action :load_invitation_context, only: [:new] + + def new + super + end + + private + + def load_invitation_context + return unless invitation_token.present? + + @invitation = Family::Invitation.find_by(token: invitation_token) + # Store token in session so it persists through the sign-in process + session[:invitation_token] = invitation_token if invitation_token.present? + end + + def invitation_token + @invitation_token ||= params[:invitation_token] || session[:invitation_token] + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5b453fbc..391b6e30 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,12 +1,23 @@ # frozen_string_literal: true module ApplicationHelper - def classes_for_flash(flash_type) - case flash_type.to_sym - when :error - 'bg-red-100 text-red-700 border-red-300' + def flash_alert_class(type) + case type.to_sym + when :notice, :success then 'alert-success' + when :alert, :error then 'alert-error' + when :warning then 'alert-warning' + when :info then 'alert-info' + else 'alert-info' + end + end + + def flash_icon(type) + case type.to_sym + when :notice, :success then icon 'circle-check' + when :alert, :error then icon 'circle-x' + when :warning then icon 'circle-alert' else - 'bg-blue-100 text-blue-700 border-blue-300' + icon 'info' end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 221f2c49..69f196ce 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,5 +1,6 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@rails/ujs" import "@rails/actioncable" import "controllers" import "@hotwired/turbo-rails" @@ -12,3 +13,5 @@ import "./channels" import "trix" import "@rails/actiontext" + +Rails.start() diff --git a/app/javascript/channels/family_locations_channel.js b/app/javascript/channels/family_locations_channel.js new file mode 100644 index 00000000..bdcf330a --- /dev/null +++ b/app/javascript/channels/family_locations_channel.js @@ -0,0 +1,24 @@ +import consumer from "./consumer" + +// Only create subscription if family feature is enabled +const familyFeaturesElement = document.querySelector('[data-family-members-features-value]'); +const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {}; + +if (features.family) { + consumer.subscriptions.create("FamilyLocationsChannel", { + connected() { + // Connected to family locations channel + }, + + disconnected() { + // Disconnected from family locations channel + }, + + received(data) { + // Pass data to family members controller if it exists + if (window.familyMembersController) { + window.familyMembersController.updateSingleMemberLocation(data); + } + } + }); +} diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js index 0c2237ee..382a0dcc 100644 --- a/app/javascript/channels/index.js +++ b/app/javascript/channels/index.js @@ -2,3 +2,4 @@ import "notifications_channel" import "points_channel" import "imports_channel" +import "family_locations_channel" diff --git a/app/javascript/controllers/add_visit_controller.js b/app/javascript/controllers/add_visit_controller.js index 672b0629..b1427993 100644 --- a/app/javascript/controllers/add_visit_controller.js +++ b/app/javascript/controllers/add_visit_controller.js @@ -1,7 +1,10 @@ import { Controller } from "@hotwired/stimulus"; import L from "leaflet"; import { showFlashMessage } from "../maps/helpers"; -import { applyThemeToButton } from "../maps/theme_utils"; +import { + setAddVisitButtonActive, + setAddVisitButtonInactive +} from "../maps/map_controls"; export default class extends Controller { static targets = [""]; @@ -71,39 +74,26 @@ export default class extends Controller { 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'; + // The Add Visit button is now created centrally by maps_controller.js + // via addTopRightButtons(). We just need to find it and attach our handler. + setTimeout(() => { + this.addVisitButton = document.querySelector('.add-visit-button'); - // Style the button with theme-aware styling - applyThemeToButton(button, this.userThemeValue || 'dark'); - button.style.width = '48px'; - button.style.height = '48px'; - 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); - - // Toggle add visit mode on button click - L.DomEvent.on(button, 'click', () => { - this.toggleAddVisitMode(button); - }); - - this.addVisitButton = button; - return button; + if (this.addVisitButton) { + // Attach our click handler to the existing button + // Use event capturing and stopPropagation to prevent map click + this.addVisitButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleAddVisitMode(this.addVisitButton); + }, true); // Use capture phase + } else { + console.warn('Add visit button not found, retrying...'); + // Retry if button hasn't been created yet + this.addVisitButton = null; + setTimeout(() => this.setupAddVisitButton(), 200); } - }); - - // Add the control to the map (top right, below existing buttons) - this.map.addControl(new AddVisitControl({ position: 'topright' })); + }, 100); } toggleAddVisitMode(button) { @@ -120,15 +110,18 @@ export default class extends Controller { this.isAddingVisit = true; // Update button style to show active state - button.style.backgroundColor = '#dc3545'; - button.style.color = 'white'; - button.innerHTML = '✕'; + setAddVisitButtonActive(button); // Change cursor to crosshair this.map.getContainer().style.cursor = 'crosshair'; - // Add map click listener - this.map.on('click', this.onMapClick, this); + // Add map click listener with a small delay to prevent immediate trigger + // This ensures the button click doesn't propagate to the map + setTimeout(() => { + if (this.isAddingVisit) { + this.map.on('click', this.onMapClick, this); + } + }, 100); showFlashMessage('notice', 'Click on the map to place a visit'); } @@ -136,9 +129,8 @@ export default class extends Controller { exitAddVisitMode(button) { this.isAddingVisit = false; - // Reset button style with theme-aware styling - applyThemeToButton(button, this.userThemeValue || 'dark'); - button.innerHTML = '➕'; + // Reset button style to inactive state + setAddVisitButtonInactive(button, this.userThemeValue || 'dark'); // Reset cursor this.map.getContainer().style.cursor = ''; @@ -185,6 +177,12 @@ export default class extends Controller { } showVisitForm(lat, lng) { + // Close any existing popup first to ensure only one popup is open + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + // Get current date/time for default values const now = new Date(); const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)); @@ -290,7 +288,8 @@ export default class extends Controller { started_at: formData.get('started_at'), ended_at: formData.get('ended_at'), latitude: formData.get('latitude'), - longitude: formData.get('longitude') + longitude: formData.get('longitude'), + status: 'confirmed' // Manually created visits should be confirmed } }; @@ -324,15 +323,14 @@ export default class extends Controller { if (response.ok) { showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`); + + // Store the created visit data + const createdVisit = data; + 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); + // Add the newly created visit marker immediately to the map + this.addCreatedVisitToMap(createdVisit, visitData.visit.latitude, visitData.visit.longitude); } else { const errorMessage = data.error || data.message || 'Failed to create visit'; showFlashMessage('error', errorMessage); @@ -347,96 +345,92 @@ export default class extends Controller { } } - refreshVisitsLayer() { - console.log('Attempting to refresh visits layer...'); + addCreatedVisitToMap(visitData, latitude, longitude) { + console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData }); - // 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 { + if (!mapsController) { console.log('Could not find maps controller element'); + return; } + + const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); + if (!stimulusController || !stimulusController.visitsManager) { + console.log('Could not find maps controller or visits manager'); + return; + } + + const visitsManager = stimulusController.visitsManager; + + // Create a circle for the newly created visit (always confirmed) + const circle = L.circle([latitude, longitude], { + color: '#4A90E2', // Border color for confirmed visits + fillColor: '#4A90E2', // Fill color for confirmed visits + fillOpacity: 0.5, + radius: 110, // Confirmed visit size + weight: 2, + interactive: true, + bubblingMouseEvents: false, + pane: 'confirmedVisitsPane' + }); + + // Add the circle to the confirmed visits layer + visitsManager.confirmedVisitCircles.addLayer(circle); + console.log('✅ Added newly created confirmed visit circle to layer'); + console.log('Confirmed visits layer info:', { + layerCount: visitsManager.confirmedVisitCircles.getLayers().length, + isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles) + }); + + // Make sure the layer is visible on the map + if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) { + this.map.addLayer(visitsManager.confirmedVisitCircles); + console.log('✅ Added confirmed visits layer to map'); + } + + // Check if the layer control has the confirmed visits layer enabled + this.ensureConfirmedVisitsLayerEnabled(); } - 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 + ensureConfirmedVisitsLayerEnabled() { + // Find the layer control and check/enable the "Confirmed Visits" checkbox 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; + // Expand the layer control if it's collapsed + const layerControlExpand = layerControlContainer.querySelector('.leaflet-control-layers-toggle'); + if (layerControlExpand) { + layerControlExpand.click(); + } - // Trigger change event to ensure proper state management - input.dispatchEvent(new Event('change', { bubbles: true })); - } - }); + setTimeout(() => { + const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]'); + inputs.forEach(input => { + const label = input.nextElementSibling; + if (label && label.textContent.trim().includes('Confirmed Visits')) { + console.log('Found Confirmed Visits checkbox, current state:', input.checked); + if (!input.checked) { + console.log('Enabling Confirmed Visits layer via checkbox'); + input.checked = true; + input.dispatchEvent(new Event('change', { bubbles: true })); + } + } + }); + }, 100); } + refreshVisitsLayer() { + // Don't auto-refresh after creating a visit + // The visit is already visible on the map from addCreatedVisitToMap() + // Auto-refresh would clear it because fetchAndDisplayVisits uses URL date params + // which might not include the newly created visit + console.log('Skipping auto-refresh - visit already added to map'); + } + + cleanup() { if (this.map) { this.map.off('click', this.onMapClick, this); diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js new file mode 100644 index 00000000..2ca5c141 --- /dev/null +++ b/app/javascript/controllers/clipboard_controller.js @@ -0,0 +1,43 @@ +import { Controller } from "@hotwired/stimulus" +import { showFlashMessage } from "../maps/helpers" + +export default class extends Controller { + static values = { + text: String + } + + static targets = ["icon", "text"] + + copy() { + navigator.clipboard.writeText(this.textValue).then(() => { + this.showButtonFeedback() + showFlashMessage('notice', 'Link copied to clipboard!') + }).catch(err => { + console.error('Failed to copy text: ', err) + showFlashMessage('error', 'Failed to copy link') + }) + } + + showButtonFeedback() { + const button = this.element + const originalClasses = button.className + const originalHTML = button.innerHTML + + // Change button appearance + button.className = 'btn btn-success btn-xs' + button.innerHTML = ` + + + + Copied! + ` + button.disabled = true + + // Reset after 2 seconds + setTimeout(() => { + button.className = originalClasses + button.innerHTML = originalHTML + button.disabled = false + }, 2000) + } +} diff --git a/app/javascript/controllers/family_members_controller.js b/app/javascript/controllers/family_members_controller.js new file mode 100644 index 00000000..9440d536 --- /dev/null +++ b/app/javascript/controllers/family_members_controller.js @@ -0,0 +1,546 @@ +import { Controller } from "@hotwired/stimulus"; +import L from "leaflet"; +import { showFlashMessage } from "../maps/helpers"; + +export default class extends Controller { + static targets = []; + + static values = { + features: Object, + userTheme: String + } + + connect() { + console.log("Family members controller connected"); + + // Wait for maps controller to be ready + this.waitForMap(); + } + + disconnect() { + this.cleanup(); + console.log("Family members controller disconnected"); + } + + waitForMap() { + // Find the maps controller element + const mapElement = document.querySelector('[data-controller*="maps"]'); + if (!mapElement) { + console.warn('Maps controller element not found'); + return; + } + + // Wait for the maps controller to be initialized + const checkMapReady = () => { + if (window.mapsController && window.mapsController.map) { + this.initializeFamilyFeatures(); + } else { + setTimeout(checkMapReady, 100); + } + }; + + checkMapReady(); + } + + initializeFamilyFeatures() { + this.map = window.mapsController.map; + + if (!this.map) { + console.warn('Map not available for family members controller'); + return; + } + + // Initialize family member markers layer + this.familyMarkersLayer = L.layerGroup(); + this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates + this.familyMarkers = {}; // Store marker references by user_id + + // Expose controller globally for ActionCable channel + window.familyMembersController = this; + + // Add to layer control immediately (layer will be empty until data is fetched) + this.addToLayerControl(); + + // Listen for family data updates + this.setupEventListeners(); + } + + createFamilyMarkers() { + // Clear existing family markers + if (this.familyMarkersLayer) { + this.familyMarkersLayer.clearLayers(); + } + + // Clear marker references + this.familyMarkers = {}; + + // Only proceed if family feature is enabled and we have family member locations + if (!this.featuresValue.family || + !this.familyMemberLocations || + Object.keys(this.familyMemberLocations).length === 0) { + return; + } + + const bounds = []; + + Object.values(this.familyMemberLocations).forEach((location) => { + if (!location || !location.latitude || !location.longitude) { + return; + } + + // Get the first letter of the email or use '?' as fallback + const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; + + // Check if this is a recent update (within last 5 minutes) + const isRecent = this.isRecentUpdate(location.updated_at); + const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; + + // Create a distinct marker for family members with email initial + const familyMarker = L.marker([location.latitude, location.longitude], { + icon: L.divIcon({ + html: `
${emailInitial}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + className: markerClass + }) + }); + + // Format timestamp for display + const lastSeen = new Date(location.updated_at).toLocaleString(); + + // Create small tooltip that shows automatically + const tooltipContent = this.createTooltipContent(lastSeen, location.battery); + const tooltip = familyMarker.bindTooltip(tooltipContent, { + permanent: true, + direction: 'top', + offset: [0, -12], + className: 'family-member-tooltip' + }); + + // Create detailed popup that shows on click + const popupContent = this.createPopupContent(location, lastSeen); + familyMarker.bindPopup(popupContent); + + // Hide tooltip when popup opens, show when popup closes + familyMarker.on('popupopen', () => { + familyMarker.closeTooltip(); + }); + familyMarker.on('popupclose', () => { + familyMarker.openTooltip(); + }); + + this.familyMarkersLayer.addLayer(familyMarker); + + // Store marker reference by user_id for efficient updates + this.familyMarkers[location.user_id] = familyMarker; + + // Add to bounds array for auto-zoom + bounds.push([location.latitude, location.longitude]); + }); + + // Store bounds for later use + this.familyMemberBounds = bounds; + } + + // Update a single family member's location in real-time + updateSingleMemberLocation(locationData) { + if (!this.featuresValue.family) return; + if (!locationData || !locationData.user_id) return; + + // Update stored location data + this.familyMemberLocations[locationData.user_id] = locationData; + + // If the Family Members layer is not currently visible, just store the data + if (!this.map.hasLayer(this.familyMarkersLayer)) { + return; + } + + // Get existing marker for this user + const existingMarker = this.familyMarkers[locationData.user_id]; + + if (existingMarker) { + // Update existing marker position and content + existingMarker.setLatLng([locationData.latitude, locationData.longitude]); + + // Update marker icon with pulse animation for recent updates + const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?'; + const isRecent = this.isRecentUpdate(locationData.updated_at); + const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; + + const newIcon = L.divIcon({ + html: `
${emailInitial}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + className: markerClass + }); + existingMarker.setIcon(newIcon); + + // Update tooltip content + const lastSeen = new Date(locationData.updated_at).toLocaleString(); + const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery); + existingMarker.setTooltipContent(tooltipContent); + + // Update popup content + const popupContent = this.createPopupContent(locationData, lastSeen); + existingMarker.setPopupContent(popupContent); + } else { + // Create new marker for this user + this.createSingleFamilyMarker(locationData); + } + } + + // Check if location was updated within the last 5 minutes + isRecentUpdate(updatedAt) { + const updateTime = new Date(updatedAt); + const now = new Date(); + const diffMinutes = (now - updateTime) / 1000 / 60; + return diffMinutes < 5; + } + + // Create a marker for a single family member + createSingleFamilyMarker(location) { + if (!location || !location.latitude || !location.longitude) return; + + const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; + const isRecent = this.isRecentUpdate(location.updated_at); + const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; + + const familyMarker = L.marker([location.latitude, location.longitude], { + icon: L.divIcon({ + html: `
${emailInitial}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + className: markerClass + }) + }); + + const lastSeen = new Date(location.updated_at).toLocaleString(); + + const tooltipContent = this.createTooltipContent(lastSeen, location.battery); + familyMarker.bindTooltip(tooltipContent, { + permanent: true, + direction: 'top', + offset: [0, -12], + className: 'family-member-tooltip' + }); + + const popupContent = this.createPopupContent(location, lastSeen); + familyMarker.bindPopup(popupContent); + + familyMarker.on('popupopen', () => { + familyMarker.closeTooltip(); + }); + familyMarker.on('popupclose', () => { + familyMarker.openTooltip(); + }); + + this.familyMarkersLayer.addLayer(familyMarker); + this.familyMarkers[location.user_id] = familyMarker; + } + + createTooltipContent(lastSeen, battery) { + const batteryInfo = battery !== null && battery !== undefined ? ` | Battery: ${battery}%` : ''; + return `Last seen: ${lastSeen}${batteryInfo}`; + } + + createPopupContent(location, lastSeen) { + const isDark = this.userThemeValue === 'dark'; + const bgColor = isDark ? '#1f2937' : '#ffffff'; + const textColor = isDark ? '#f9fafb' : '#111827'; + const mutedColor = isDark ? '#9ca3af' : '#6b7280'; + + const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; + + // Battery display with icon + const battery = location.battery; + const batteryStatus = location.battery_status; + let batteryDisplay = ''; + + if (battery !== null && battery !== undefined) { + // Determine battery color based on level and status + let batteryColor = '#10B981'; // green + if (batteryStatus === 'charging') { + batteryColor = battery <= 50 ? '#F59E0B' : '#10B981'; // orange if low, green if high + } else if (battery <= 20) { + batteryColor = '#EF4444'; // red + } else if (battery <= 50) { + batteryColor = '#F59E0B'; // orange + } + + // Helper function to get appropriate Lucide battery icon + const getBatteryIcon = (battery, batteryStatus, batteryColor) => { + const baseAttrs = `width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${batteryColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"`; + + // Charging icon + if (batteryStatus === 'charging') { + return ``; + } + + // Full battery + if (battery === 100 || batteryStatus === 'full') { + return ``; + } + + // Low battery (≤20%) + if (battery <= 20) { + return ``; + } + + // Medium battery (21-50%) + if (battery <= 50) { + return ``; + } + + // High battery (>50%, default to full) + return ``; + }; + + const batteryIcon = getBatteryIcon(battery, batteryStatus, batteryColor); + + batteryDisplay = ` +

+ ${batteryIcon}Battery: ${battery}%${batteryStatus ? ` (${batteryStatus})` : ''} +

+ `; + } + + return ` +
+

+ ${emailInitial} + Family Member +

+

+ Email: ${location.email || 'Unknown'} +

+

+ Coordinates:
+ ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)} +

+ ${batteryDisplay} +

+ Last seen: ${lastSeen} +

+
+ `; + } + + addToLayerControl() { + // Add family markers layer to the maps controller's layer control + if (window.mapsController && window.mapsController.layerControl && this.familyMarkersLayer) { + // We need to recreate the layer control to include our new layer + this.updateMapsControllerLayerControl(); + } + } + + updateMapsControllerLayerControl() { + const mapsController = window.mapsController; + if (!mapsController || typeof mapsController.updateLayerControl !== 'function') return; + + // Use the maps controller's helper method to update layer control + mapsController.updateLayerControl({ + "Family Members": this.familyMarkersLayer + }); + + // Dispatch event to notify that Family Members layer is now available + document.dispatchEvent(new CustomEvent('family:layer:ready', { + detail: { layer: this.familyMarkersLayer } + })); + } + + setupEventListeners() { + // Listen for family data updates (for real-time updates in the future) + document.addEventListener('family:locations:updated', (event) => { + this.familyMemberLocations = event.detail.locations; + this.createFamilyMarkers(); + }); + + // Listen for theme changes + document.addEventListener('theme:changed', (event) => { + this.userThemeValue = event.detail.theme; + // Recreate popups with new theme + this.createFamilyMarkers(); + }); + + // Listen for layer control events + this.setupLayerControlEvents(); + } + + setupLayerControlEvents() { + if (!this.map) return; + + // Listen for when the Family Members layer is added + this.map.on('overlayadd', (event) => { + if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) { + // Refresh locations and zoom after data is loaded + this.refreshFamilyLocations().then(() => { + this.zoomToFitAllMembers(); + }); + + // Set up periodic refresh while layer is active + this.startPeriodicRefresh(); + } + }); + + // Listen for when the Family Members layer is removed + this.map.on('overlayremove', (event) => { + if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) { + // Stop periodic refresh when layer is disabled + this.stopPeriodicRefresh(); + } + }); + } + + zoomToFitAllMembers() { + if (!this.familyMemberBounds || this.familyMemberBounds.length === 0) { + return; + } + + // If there's only one member, center on them with a reasonable zoom + if (this.familyMemberBounds.length === 1) { + this.map.setView(this.familyMemberBounds[0], 13); + return; + } + + // For multiple members, fit bounds to show all of them + const bounds = L.latLngBounds(this.familyMemberBounds); + this.map.fitBounds(bounds, { + padding: [50, 50], // Add padding around the edges + maxZoom: 15 // Don't zoom in too close + }); + } + + startPeriodicRefresh() { + // Clear any existing refresh interval + this.stopPeriodicRefresh(); + + // Refresh family locations every 60 seconds while layer is active (as fallback to real-time) + this.refreshInterval = setInterval(() => { + if (this.map && this.map.hasLayer(this.familyMarkersLayer)) { + this.refreshFamilyLocations(); + } else { + // Layer is no longer active, stop refreshing + this.stopPeriodicRefresh(); + } + }, 60000); // 60 seconds (real-time updates via ActionCable are primary) + } + + stopPeriodicRefresh() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + // Method to manually update family member locations (for API calls) + updateFamilyLocations(locations) { + // Convert array to object keyed by user_id + if (Array.isArray(locations)) { + this.familyMemberLocations = {}; + locations.forEach(location => { + if (location.user_id) { + this.familyMemberLocations[location.user_id] = location; + } + }); + } else { + this.familyMemberLocations = locations; + } + + this.createFamilyMarkers(); + + // Dispatch event for other controllers that might be interested + document.dispatchEvent(new CustomEvent('family:locations:updated', { + detail: { locations: this.familyMemberLocations } + })); + } + + // Method to refresh family locations from API + async refreshFamilyLocations() { + if (!window.mapsController?.apiKey) { + console.warn('API key not available for family locations refresh'); + return; + } + + try { + const response = await fetch(`/api/v1/families/locations?api_key=${window.mapsController.apiKey}`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + if (response.status === 403) { + console.warn('Family feature not enabled or user not in family'); + return; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + this.updateFamilyLocations(data.locations || []); + + // Show user feedback if this was a manual refresh + if (this.showUserFeedback) { + const count = data.locations?.length || 0; + this.showFlashMessageToUser('notice', `Family locations updated (${count} members)`); + this.showUserFeedback = false; // Reset flag + } + + } catch (error) { + console.error('Error refreshing family locations:', error); + + // Show error to user if this was a manual refresh + if (this.showUserFeedback) { + this.showFlashMessageToUser('error', 'Failed to refresh family locations'); + this.showUserFeedback = false; // Reset flag + } + } + } + + // Helper method to show flash messages using the imported helper + showFlashMessageToUser(type, message) { + showFlashMessage(type, message); + } + + // Method for manual refresh with user feedback + async manualRefreshFamilyLocations() { + this.showUserFeedback = true; // Enable user feedback for this refresh + await this.refreshFamilyLocations(); + } + + cleanup() { + // Stop periodic refresh + this.stopPeriodicRefresh(); + + // Remove family markers layer from map if it exists + if (this.familyMarkersLayer && this.map && this.map.hasLayer(this.familyMarkersLayer)) { + this.map.removeLayer(this.familyMarkersLayer); + } + + // Remove map event listeners + if (this.map) { + this.map.off('overlayadd'); + this.map.off('overlayremove'); + } + + // Remove document event listeners + document.removeEventListener('family:locations:updated', this.handleLocationUpdates); + document.removeEventListener('theme:changed', this.handleThemeChange); + } + + // Expose layer for external access + getFamilyMarkersLayer() { + return this.familyMarkersLayer; + } + + // Check if family features are enabled + isFamilyFeatureEnabled() { + return this.featuresValue.family === true; + } + + // Get family marker count + getFamilyMemberCount() { + return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0; + } +} diff --git a/app/javascript/controllers/family_navbar_indicator_controller.js b/app/javascript/controllers/family_navbar_indicator_controller.js new file mode 100644 index 00000000..80ba1cbc --- /dev/null +++ b/app/javascript/controllers/family_navbar_indicator_controller.js @@ -0,0 +1,48 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["indicator"]; + static values = { + enabled: Boolean + }; + + connect() { + console.log("Family navbar indicator controller connected"); + this.updateIndicator(); + + // Listen for location sharing updates + document.addEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this)); + document.addEventListener('location-sharing:expired', this.handleSharingExpired.bind(this)); + } + + disconnect() { + document.removeEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this)); + document.removeEventListener('location-sharing:expired', this.handleSharingExpired.bind(this)); + } + + handleSharingUpdate(event) { + // Only update if this is the current user's sharing change + // (we're only showing the current user's status in navbar) + this.enabledValue = event.detail.enabled; + this.updateIndicator(); + } + + handleSharingExpired(event) { + this.enabledValue = false; + this.updateIndicator(); + } + + updateIndicator() { + if (!this.hasIndicatorTarget) return; + + if (this.enabledValue) { + // Green pulsing indicator for enabled + this.indicatorTarget.className = "w-2 h-2 bg-green-500 rounded-full animate-pulse"; + this.indicatorTarget.title = "Location sharing enabled"; + } else { + // Gray indicator for disabled + this.indicatorTarget.className = "w-2 h-2 bg-gray-400 rounded-full"; + this.indicatorTarget.title = "Location sharing disabled"; + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/location_sharing_toggle_controller.js b/app/javascript/controllers/location_sharing_toggle_controller.js new file mode 100644 index 00000000..57e3b1f8 --- /dev/null +++ b/app/javascript/controllers/location_sharing_toggle_controller.js @@ -0,0 +1,276 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["checkbox", "durationContainer", "durationSelect", "expirationInfo"]; + static values = { + memberId: Number, + enabled: Boolean, + familyId: Number, + duration: String, + expiresAt: String + }; + + connect() { + console.log("Location sharing toggle controller connected"); + this.updateToggleState(); + this.setupExpirationTimer(); + } + + disconnect() { + this.clearExpirationTimer(); + } + + toggle() { + const newState = !this.enabledValue; + const duration = this.hasDurationSelectTarget ? this.durationSelectTarget.value : 'permanent'; + + // Optimistically update UI + this.enabledValue = newState; + this.updateToggleState(); + + // Send the update to server + this.updateLocationSharing(newState, duration); + } + + changeDuration() { + if (!this.enabledValue) return; // Only allow duration changes when sharing is enabled + + const duration = this.durationSelectTarget.value; + this.durationValue = duration; + + // Update sharing with new duration + this.updateLocationSharing(true, duration); + } + + updateToggleState() { + const isEnabled = this.enabledValue; + + // Update checkbox (DaisyUI toggle) + this.checkboxTarget.checked = isEnabled; + + // Show/hide duration container + if (this.hasDurationContainerTarget) { + if (isEnabled) { + this.durationContainerTarget.classList.remove('hidden'); + } else { + this.durationContainerTarget.classList.add('hidden'); + } + } + } + + async updateLocationSharing(enabled, duration = 'permanent') { + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + + const response = await fetch(`/family/update_location_sharing`, { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + enabled: enabled, + duration: duration + }) + }); + + const data = await response.json(); + + if (data.success) { + // Update local values from server response + this.durationValue = data.duration; + this.expiresAtValue = data.expires_at; + + // Update duration select if it exists + if (this.hasDurationSelectTarget) { + this.durationSelectTarget.value = data.duration; + } + + // Update expiration info + this.updateExpirationInfo(data.expires_at_formatted); + + // Show success message + this.showFlashMessage('success', data.message); + + // Setup/clear expiration timer + this.setupExpirationTimer(); + + // Trigger custom event for other controllers to listen to + document.dispatchEvent(new CustomEvent('location-sharing:updated', { + detail: { + userId: this.memberIdValue, + enabled: enabled, + duration: data.duration, + expiresAt: data.expires_at + } + })); + } else { + // Revert the UI change if server update failed + this.enabledValue = !enabled; + this.updateToggleState(); + this.showFlashMessage('error', data.message || 'Failed to update location sharing'); + } + } catch (error) { + console.error('Error updating location sharing:', error); + + // Revert the UI change if request failed + this.enabledValue = !enabled; + this.updateToggleState(); + this.showFlashMessage('error', 'Network error occurred while updating location sharing'); + } + } + + setupExpirationTimer() { + this.clearExpirationTimer(); + + if (this.enabledValue && this.expiresAtValue) { + const expiresAt = new Date(this.expiresAtValue); + const now = new Date(); + const msUntilExpiration = expiresAt.getTime() - now.getTime(); + + if (msUntilExpiration > 0) { + // Set timer to automatically disable sharing when it expires + this.expirationTimer = setTimeout(() => { + this.enabledValue = false; + this.updateToggleState(); + this.showFlashMessage('info', 'Location sharing has expired'); + + // Trigger update event + document.dispatchEvent(new CustomEvent('location-sharing:expired', { + detail: { userId: this.memberIdValue } + })); + }, msUntilExpiration); + + // Also set up periodic updates to show countdown + this.updateExpirationCountdown(); + this.countdownInterval = setInterval(() => { + this.updateExpirationCountdown(); + }, 60000); // Update every minute + } + } + } + + clearExpirationTimer() { + if (this.expirationTimer) { + clearTimeout(this.expirationTimer); + this.expirationTimer = null; + } + if (this.countdownInterval) { + clearInterval(this.countdownInterval); + this.countdownInterval = null; + } + } + + updateExpirationInfo(formattedTime) { + if (this.hasExpirationInfoTarget && formattedTime) { + this.expirationInfoTarget.textContent = `Expires ${formattedTime}`; + this.expirationInfoTarget.style.display = 'block'; + } else if (this.hasExpirationInfoTarget) { + this.expirationInfoTarget.style.display = 'none'; + } + } + + updateExpirationCountdown() { + if (!this.hasExpirationInfoTarget || !this.expiresAtValue) return; + + const expiresAt = new Date(this.expiresAtValue); + const now = new Date(); + const msUntilExpiration = expiresAt.getTime() - now.getTime(); + + if (msUntilExpiration <= 0) { + this.expirationInfoTarget.textContent = 'Expired'; + this.expirationInfoTarget.style.display = 'block'; + return; + } + + const hoursLeft = Math.floor(msUntilExpiration / (1000 * 60 * 60)); + const minutesLeft = Math.floor((msUntilExpiration % (1000 * 60 * 60)) / (1000 * 60)); + + let timeText; + if (hoursLeft > 0) { + timeText = `${hoursLeft}h ${minutesLeft}m remaining`; + } else { + timeText = `${minutesLeft}m remaining`; + } + + this.expirationInfoTarget.textContent = `Expires in ${timeText}`; + } + + showFlashMessage(type, message) { + // Create a flash message element matching the project style (_flash.html.erb) + const flashContainer = document.getElementById('flash-messages') || + this.createFlashContainer(); + + const bgClass = this.getFlashClasses(type); + + const flashElement = document.createElement('div'); + flashElement.className = `flex items-center ${bgClass} py-3 px-5 rounded-lg z-[6000]`; + flashElement.innerHTML = ` +
${message}
+ + `; + + // Add click handler to dismiss button + const dismissButton = flashElement.querySelector('button'); + dismissButton.addEventListener('click', () => { + flashElement.classList.add('fade-out'); + setTimeout(() => { + flashElement.remove(); + // Remove the container if it's empty + if (flashContainer && !flashContainer.hasChildNodes()) { + flashContainer.remove(); + } + }, 150); + }); + + flashContainer.appendChild(flashElement); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (flashElement.parentNode) { + flashElement.classList.add('fade-out'); + setTimeout(() => { + flashElement.remove(); + // Remove the container if it's empty + if (flashContainer && !flashContainer.hasChildNodes()) { + flashContainer.remove(); + } + }, 150); + } + }, 5000); + } + + createFlashContainer() { + const container = document.createElement('div'); + container.id = 'flash-messages'; + container.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50'; + document.body.appendChild(container); + return container; + } + + getFlashClasses(type) { + switch (type) { + case 'error': + case 'alert': + return 'bg-red-100 text-red-700 border-red-300'; + default: + return 'bg-blue-100 text-blue-700 border-blue-300'; + } + } + + // Helper method to check if user's own location sharing is enabled + // This can be used by other controllers + static getUserLocationSharingStatus() { + const toggleController = document.querySelector('[data-controller*="location-sharing-toggle"]'); + if (toggleController) { + const controller = this.application.getControllerForElementAndIdentifier(toggleController, 'location-sharing-toggle'); + return controller?.enabledValue || false; + } + return false; + } +} diff --git a/app/javascript/controllers/map_controls_controller.js b/app/javascript/controllers/map_controls_controller.js new file mode 100644 index 00000000..d2fc6b39 --- /dev/null +++ b/app/javascript/controllers/map_controls_controller.js @@ -0,0 +1,45 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["panel", "toggleIcon"] + + connect() { + // Restore panel state from sessionStorage on page load + const panelState = sessionStorage.getItem('mapControlsPanelState') + if (panelState === 'visible') { + this.showPanel() + } + } + + toggle() { + const isHidden = this.panelTarget.classList.contains("hidden") + + if (isHidden) { + this.showPanel() + sessionStorage.setItem('mapControlsPanelState', 'visible') + } else { + this.hidePanel() + sessionStorage.setItem('mapControlsPanelState', 'hidden') + } + } + + showPanel() { + this.panelTarget.classList.remove("hidden") + + // Update icon to chevron-up + const currentIcon = this.toggleIconTarget.querySelector('svg') + currentIcon.classList.remove('lucide-chevron-down') + currentIcon.classList.add('lucide-chevron-up') + currentIcon.innerHTML = '' + } + + hidePanel() { + this.panelTarget.classList.add("hidden") + + // Update icon to chevron-down + const currentIcon = this.toggleIconTarget.querySelector('svg') + currentIcon.classList.remove('lucide-chevron-up') + currentIcon.classList.add('lucide-chevron-down') + currentIcon.innerHTML = '' + } +} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 8e623b95..d2ad1883 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -44,6 +44,7 @@ import { TileMonitor } from "../maps/tile_monitor"; import BaseController from "./base_controller"; import { createAllMapLayers } from "../maps/layers"; import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils"; +import { addTopRightButtons } from "../maps/map_controls"; export default class extends BaseController { static targets = ["container"]; @@ -100,6 +101,9 @@ export default class extends BaseController { this.speedColoredPolylines = this.userSettings.speed_colored_routes || false; this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback); + // Flag to prevent saving layers during initialization/restoration + this.isRestoringLayers = false; + // Ensure we have valid markers array if (!Array.isArray(this.markers)) { console.warn('Markers is not an array, setting to empty array'); @@ -112,7 +116,7 @@ export default class extends BaseController { this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14); // Add scale control - L.control.scale({ + this.scaleControl = L.control.scale({ position: 'bottomright', imperial: this.distanceUnit === 'mi', metric: this.distanceUnit === 'km', @@ -145,7 +149,7 @@ export default class extends BaseController { } }); - new StatsControl().addTo(this.map); + this.statsControl = new StatsControl().addTo(this.map); // Set the maximum bounds to prevent infinite scroll var southWest = L.latLng(-120, -210); @@ -200,27 +204,17 @@ export default class extends BaseController { this.addSettingsButton(); } + // Add info toggle button + this.addInfoToggleButton(); + // Initialize the visits manager this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme); // Expose visits manager globally for location search integration window.visitsManager = this.visitsManager; - // Initialize layers for the layer control - const controlsLayer = { - Points: this.markersLayer, - Routes: this.polylinesLayer, - Tracks: this.tracksLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), - Areas: this.areasLayer, - Photos: this.photoMarkers, - "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), - "Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer() - }; - - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); + // Expose maps controller globally for family integration + window.mapsController = this; // Initialize tile monitor this.tileMonitor = new TileMonitor(this.map, this.apiKey); @@ -238,6 +232,9 @@ export default class extends BaseController { // Initialize layers based on settings this.initializeLayersFromSettings(); + // Listen for Family Members layer becoming ready + this.setupFamilyLayerListener(); + // Initialize tracks layer this.initializeTracksLayer(); @@ -247,11 +244,25 @@ export default class extends BaseController { // Preload areas fetchAndDrawAreas(this.areasLayer, this.apiKey); - // Add right panel toggle - this.addTogglePanelButton(); + // Add all top-right buttons in the correct order + this.initializeTopRightButtons(); + + // Initialize layers for the layer control + const controlsLayer = { + Points: this.markersLayer, + Routes: this.polylinesLayer, + Tracks: this.tracksLayer, + Heatmap: this.heatmapLayer, + "Fog of War": this.fogOverlay, + "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), + Areas: this.areasLayer, + Photos: this.photoMarkers, + "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), + "Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer() + }; + + this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - // Add visits buttons after calendar button to position them below - this.visitsManager.addDrawerButton(); // Initialize Live Map Handler this.initializeLiveMapHandler(); @@ -460,6 +471,11 @@ export default class extends BaseController { // Add event listeners for overlay layer changes to keep routes/tracks selector in sync this.map.on('overlayadd', (event) => { + // Save enabled layers whenever a layer is added (unless we're restoring from settings) + if (!this.isRestoringLayers) { + this.saveEnabledLayers(); + } + if (event.name === 'Routes') { this.handleRouteLayerToggle('routes'); // Re-establish event handlers when routes are manually added @@ -515,6 +531,11 @@ export default class extends BaseController { }); this.map.on('overlayremove', (event) => { + // Save enabled layers whenever a layer is removed (unless we're restoring from settings) + if (!this.isRestoringLayers) { + this.saveEnabledLayers(); + } + if (event.name === 'Routes' || event.name === 'Tracks') { // Don't auto-switch when layers are manually turned off // Just update the radio button state to reflect current visibility @@ -548,9 +569,12 @@ export default class extends BaseController { } updatePreferredBaseLayer(selectedLayerName) { - fetch(`/api/v1/settings?api_key=${this.apiKey}`, { + fetch('/api/v1/settings', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, body: JSON.stringify({ settings: { preferred_map_layer: selectedLayerName @@ -567,6 +591,63 @@ export default class extends BaseController { }); } + saveEnabledLayers() { + const enabledLayers = []; + const layerNames = [ + 'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War', + 'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits', + 'Family Members' + ]; + + const controlsLayer = { + 'Points': this.markersLayer, + 'Routes': this.polylinesLayer, + 'Tracks': this.tracksLayer, + 'Heatmap': this.heatmapLayer, + 'Fog of War': this.fogOverlay, + 'Scratch map': this.scratchLayerManager?.getLayer(), + 'Areas': this.areasLayer, + 'Photos': this.photoMarkers, + 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), + 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(), + 'Family Members': window.familyMembersController?.familyMarkersLayer + }; + + layerNames.forEach(name => { + const layer = controlsLayer[name]; + if (layer && this.map.hasLayer(layer)) { + enabledLayers.push(name); + } + }); + + fetch('/api/v1/settings', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + settings: { + enabled_map_layers: enabledLayers + }, + }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.status === 'success') { + console.log('Enabled layers saved:', enabledLayers); + showFlashMessage('notice', 'Map layer preferences saved'); + } else { + console.error('Failed to save enabled layers:', data.message); + showFlashMessage('error', `Failed to save layer preferences: ${data.message}`); + } + }) + .catch(error => { + console.error('Error saving enabled layers:', error); + showFlashMessage('error', 'Error saving layer preferences'); + }); + } + deletePoint(id, apiKey) { fetch(`/api/v1/points/${id}`, { method: 'DELETE', @@ -723,13 +804,19 @@ export default class extends BaseController { // Define the custom control const SettingsControl = L.Control.extend({ onAdd: (map) => { - const button = L.DomUtil.create('button', 'map-settings-button'); - button.innerHTML = '⚙️'; // Gear icon + const button = L.DomUtil.create('button', 'map-settings-button tooltip tooltip-right'); + button.innerHTML = ''; // Gear icon + button.setAttribute('data-tip', 'Settings'); // Style the button with theme-aware styling applyThemeToButton(button, this.userTheme); - button.style.width = '32px'; - button.style.height = '32px'; + button.style.width = '30px'; + button.style.height = '30px'; + button.style.display = 'flex'; + button.style.alignItems = 'center'; + button.style.justifyContent = 'center'; + button.style.padding = '0'; + button.style.borderRadius = '4px'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); @@ -748,6 +835,104 @@ export default class extends BaseController { this.settingsButtonAdded = true; } + addInfoToggleButton() { + // Store reference to the controller instance for use in the control + const controller = this; + + const InfoToggleControl = L.Control.extend({ + options: { + position: 'bottomleft' + }, + onAdd: function(map) { + const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); + const button = L.DomUtil.create('button', 'map-info-toggle-button tooltip tooltip-right', container); + button.setAttribute('data-tip', 'Toggle footer visibility'); + + // Lucide info icon + button.innerHTML = ` + + + + + + `; + + // Style the button with theme-aware styling + applyThemeToButton(button, controller.userTheme); + button.style.width = '34px'; + button.style.height = '34px'; + button.style.display = 'flex'; + button.style.alignItems = 'center'; + button.style.justifyContent = 'center'; + button.style.cursor = 'pointer'; + button.style.border = 'none'; + button.style.borderRadius = '4px'; + + // Disable map interactions when clicking the button + L.DomEvent.disableClickPropagation(container); + + // Toggle footer visibility on button click + L.DomEvent.on(button, 'click', () => { + controller.toggleFooterVisibility(); + }); + + return container; + } + }); + + // Add the control to the map + this.map.addControl(new InfoToggleControl()); + } + + toggleFooterVisibility() { + // Toggle the page footer + const footer = document.getElementById('map-footer'); + if (!footer) return; + + const isCurrentlyHidden = footer.classList.contains('hidden'); + + // Toggle Tailwind's hidden class + footer.classList.toggle('hidden'); + + // Adjust bottom controls position based on footer visibility + if (isCurrentlyHidden) { + // Footer is being shown - move controls up + setTimeout(() => { + const footerHeight = footer.offsetHeight; + // Add extra 20px margin above footer + this.adjustBottomControls(footerHeight + 20); + }, 10); // Small delay to ensure footer is rendered + } else { + // Footer is being hidden - reset controls position + this.adjustBottomControls(10); // Back to default padding + } + + // Add click event to close footer when clicking on it (only add once) + if (!footer.dataset.clickHandlerAdded) { + footer.addEventListener('click', (e) => { + // Only close if clicking the footer itself, not its contents + if (e.target === footer) { + footer.classList.add('hidden'); + this.adjustBottomControls(10); // Reset controls position + } + }); + footer.dataset.clickHandlerAdded = 'true'; + } + } + + adjustBottomControls(paddingBottom) { + // Adjust all bottom Leaflet controls + const bottomLeftControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-left'); + const bottomRightControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-right'); + + if (bottomLeftControls) { + bottomLeftControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important'); + } + if (bottomRightControls) { + bottomRightControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important'); + } + } + toggleSettingsMenu() { // If the settings panel already exists, just show/hide it if (this.settingsPanel) { @@ -907,9 +1092,12 @@ export default class extends BaseController { const opacityValue = event.target.route_opacity.value.replace('%', ''); const decimalOpacity = parseFloat(opacityValue) / 100; - fetch(`/api/v1/settings?api_key=${this.apiKey}`, { + fetch('/api/v1/settings', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, body: JSON.stringify({ settings: { route_opacity: decimalOpacity.toString(), @@ -1081,40 +1269,35 @@ export default class extends BaseController { } } + initializeTopRightButtons() { + // Add all top-right buttons in the correct order: + // 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer + // Note: Layer control is added separately and appears at the top - addTogglePanelButton() { - // Store reference to the controller instance for use in the control - const controller = this; + this.topRightControls = addTopRightButtons( + this.map, + { + onSelectArea: () => this.visitsManager.toggleSelectionMode(), + // onAddVisit is intentionally null - the add_visit_controller will attach its handler + onAddVisit: null, + onToggleCalendar: () => this.toggleRightPanel(), + onToggleDrawer: () => this.visitsManager.toggleDrawer() + }, + this.userTheme + ); - const TogglePanelControl = L.Control.extend({ - onAdd: function(map) { - const button = L.DomUtil.create('button', 'toggle-panel-button'); - button.innerHTML = '📅'; - - // Style the button with theme-aware styling - applyThemeToButton(button, controller.userTheme); - button.style.width = '48px'; - button.style.height = '48px'; - button.style.borderRadius = '4px'; - button.style.padding = '0'; - button.style.lineHeight = '48px'; - button.style.fontSize = '18px'; - button.style.textAlign = 'center'; - - // Disable map interactions when clicking the button - L.DomEvent.disableClickPropagation(button); - - // Toggle panel on button click - L.DomEvent.on(button, 'click', () => { - controller.toggleRightPanel(); - }); - - return button; - } - }); - - // Add the control to the map - this.map.addControl(new TogglePanelControl({ position: 'topright' })); + // Add CSS for selection button active state (needed by visits manager) + if (!document.getElementById('selection-tool-active-style')) { + const style = document.createElement('style'); + style.id = 'selection-tool-active-style'; + style.textContent = ` + #selection-tool-button.active { + border: 2px dashed #3388ff !important; + box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important; + } + `; + document.head.appendChild(style); + } } shouldShowTracksSelector() { @@ -1286,45 +1469,123 @@ export default class extends BaseController { // Initialize layer visibility based on user settings or defaults // This method sets up the initial state of overlay layers - // Note: Don't automatically add layers to map here - let the layer control and user preferences handle it - // The layer control will manage which layers are visible based on user interaction + // Get enabled layers from user settings + const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap']; + console.log('Initializing layers from settings:', enabledLayers); - // Initialize photos layer if user wants it visible - if (this.userSettings.photos_enabled) { - console.log('Photos layer enabled via user settings'); - const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const endDate = urlParams.get('end_at') || new Date().toISOString(); + const controlsLayer = { + 'Points': this.markersLayer, + 'Routes': this.polylinesLayer, + 'Tracks': this.tracksLayer, + 'Heatmap': this.heatmapLayer, + 'Fog of War': this.fogOverlay, + 'Scratch map': this.scratchLayerManager?.getLayer(), + 'Areas': this.areasLayer, + 'Photos': this.photoMarkers, + 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), + 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(), + 'Family Members': window.familyMembersController?.familyMarkersLayer + }; - console.log('Auto-fetching photos for date range:', { startDate, endDate }); - fetchAndDisplayPhotos({ - map: this.map, - photoMarkers: this.photoMarkers, - apiKey: this.apiKey, - startDate: startDate, - endDate: endDate, - userSettings: this.userSettings - }); - } - - // Initialize fog of war if enabled in settings - if (this.userSettings.fog_of_war_enabled) { - this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); - } - - // Initialize visits manager functionality - // Check if any visits layers are enabled by default and load data - if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { - // Check if confirmed visits layer is enabled by default (it's added to map in constructor) - const confirmedVisitsEnabled = this.map.hasLayer(this.visitsManager.getConfirmedVisitCirclesLayer()); - - console.log('Visits initialization - confirmedVisitsEnabled:', confirmedVisitsEnabled); - - if (confirmedVisitsEnabled) { - console.log('Confirmed visits layer enabled by default - fetching visits data'); - this.visitsManager.fetchAndDisplayVisits(); + // Apply saved layer preferences + Object.entries(controlsLayer).forEach(([name, layer]) => { + if (!layer) { + if (enabledLayers.includes(name)) { + console.log(`Layer ${name} is in enabled layers but layer object is null/undefined`); + } + return; } - } + + const shouldBeEnabled = enabledLayers.includes(name); + const isCurrentlyEnabled = this.map.hasLayer(layer); + + if (name === 'Family Members') { + console.log('Family Members layer check:', { + shouldBeEnabled, + isCurrentlyEnabled, + layerExists: !!layer, + controllerExists: !!window.familyMembersController + }); + } + + if (shouldBeEnabled && !isCurrentlyEnabled) { + // Add layer to map + layer.addTo(this.map); + console.log(`Enabled layer: ${name}`); + + // Trigger special initialization for certain layers + if (name === 'Photos') { + const urlParams = new URLSearchParams(window.location.search); + const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const endDate = urlParams.get('end_at') || new Date().toISOString(); + fetchAndDisplayPhotos({ + map: this.map, + photoMarkers: this.photoMarkers, + apiKey: this.apiKey, + startDate: startDate, + endDate: endDate, + userSettings: this.userSettings + }); + } else if (name === 'Fog of War') { + this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); + } else if (name === 'Suggested Visits' || name === 'Confirmed Visits') { + if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { + this.visitsManager.fetchAndDisplayVisits(); + } + } else if (name === 'Scratch map') { + if (this.scratchLayerManager) { + this.scratchLayerManager.addToMap(); + } + } else if (name === 'Routes') { + // Re-establish event handlers for routes layer + reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); + } else if (name === 'Areas') { + // Show draw control when Areas layer is enabled + if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { + this.map.addControl(this.drawControl); + } + } else if (name === 'Family Members') { + // Refresh family locations when layer is restored + if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') { + window.familyMembersController.refreshFamilyLocations(); + } + } + } else if (!shouldBeEnabled && isCurrentlyEnabled) { + // Remove layer from map + this.map.removeLayer(layer); + console.log(`Disabled layer: ${name}`); + } + }); + } + + setupFamilyLayerListener() { + // Listen for when the Family Members layer becomes available + document.addEventListener('family:layer:ready', (event) => { + console.log('Family layer ready event received'); + const enabledLayers = this.userSettings.enabled_map_layers || []; + + // Check if Family Members should be enabled based on saved settings + if (enabledLayers.includes('Family Members')) { + const layer = event.detail.layer; + if (layer && !this.map.hasLayer(layer)) { + // Set flag to prevent saving during restoration + this.isRestoringLayers = true; + + layer.addTo(this.map); + console.log('Enabled layer: Family Members (from ready event)'); + + // Refresh family locations + if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') { + window.familyMembersController.refreshFamilyLocations(); + } + + // Reset flag after a short delay to allow all events to complete + setTimeout(() => { + this.isRestoringLayers = false; + }, 100); + } + } + }, { once: true }); // Only listen once } toggleRightPanel() { @@ -1839,4 +2100,77 @@ export default class extends BaseController { this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme); } } + + // Helper method for family controller to update layer control + updateLayerControl(additionalLayers = {}) { + if (!this.layerControl) return; + + // Store which base and overlay layers are currently visible + const overlayStates = {}; + let activeBaseLayer = null; + let activeBaseLayerName = null; + + if (this.layerControl._layers) { + Object.values(this.layerControl._layers).forEach(layerObj => { + if (layerObj.overlay && layerObj.layer) { + // Store overlay layer states + overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer); + } else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) { + // Store the currently active base layer + activeBaseLayer = layerObj.layer; + activeBaseLayerName = layerObj.name; + } + }); + } + + // Remove existing layer control + this.map.removeControl(this.layerControl); + + // Create base controls layer object + const baseControlsLayer = { + 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.scratchLayerManager?.getLayer() || 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() + }; + + // Merge with additional layers (like family members) + const controlsLayer = { ...baseControlsLayer, ...additionalLayers }; + + // Get base maps and re-add the layer control + const baseMaps = this.baseMaps(); + this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map); + + // Restore the active base layer if we had one + if (activeBaseLayer && activeBaseLayerName) { + console.log(`Restoring base layer: ${activeBaseLayerName}`); + // Make sure the base layer is added to the map + if (!this.map.hasLayer(activeBaseLayer)) { + activeBaseLayer.addTo(this.map); + } + } else { + // If no active base layer was found, ensure we have a default one + console.log('No active base layer found, adding default'); + const defaultBaseLayer = Object.values(baseMaps)[0]; + if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) { + defaultBaseLayer.addTo(this.map); + } + } + + // Restore overlay layer visibility states + Object.entries(overlayStates).forEach(([name, wasVisible]) => { + const layer = controlsLayer[name]; + if (layer && wasVisible && !this.map.hasLayer(layer)) { + layer.addTo(this.map); + } + }); + } + + } diff --git a/app/javascript/controllers/public_stat_map_controller.js b/app/javascript/controllers/public_stat_map_controller.js index a16d5f29..b0d9918a 100644 --- a/app/javascript/controllers/public_stat_map_controller.js +++ b/app/javascript/controllers/public_stat_map_controller.js @@ -99,13 +99,6 @@ export default class extends BaseController { console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default'); } - console.log('🎯 Public sharing: using manual hexagon loading'); - console.log('🔍 Debug values:'); - console.log(' dataBounds:', dataBounds); - console.log(' point_count:', dataBounds?.point_count); - console.log(' hexagonsAvailableValue:', this.hexagonsAvailableValue); - console.log(' hexagonsAvailableValue type:', typeof this.hexagonsAvailableValue); - // Load hexagons only if they are pre-calculated and data exists if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) { await this.loadStaticHexagons(); @@ -140,7 +133,7 @@ export default class extends BaseController { // Ensure loading overlay is visible and disable map interaction const loadingElement = document.getElementById('map-loading'); - console.log('🔍 Loading element found:', !!loadingElement); + if (loadingElement) { loadingElement.style.display = 'flex'; loadingElement.style.visibility = 'visible'; diff --git a/app/javascript/controllers/sharing_modal_controller.js b/app/javascript/controllers/sharing_modal_controller.js index eb6e9ade..31238d6f 100644 --- a/app/javascript/controllers/sharing_modal_controller.js +++ b/app/javascript/controllers/sharing_modal_controller.js @@ -94,8 +94,8 @@ export default class extends Controller { // Show temporary success feedback const button = this.sharingLinkTarget.nextElementSibling const originalText = button.innerHTML - button.innerHTML = "✅ Copied!" - button.classList.add("btn-success") + button.innerHTML = "✅ Link Copied!" + button.classList.add("btn-outline btn-success") setTimeout(() => { button.innerHTML = originalText diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index a33a9772..f83dc0c2 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -125,30 +125,41 @@ export function showFlashMessage(type, message) { if (!flashContainer) { flashContainer = document.createElement('div'); flashContainer.id = 'flash-messages'; - flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-50'; + flashContainer.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50'; document.body.appendChild(flashContainer); } - // Create the flash message div + // Create the flash message div with DaisyUI alert classes const flashDiv = document.createElement('div'); flashDiv.setAttribute('data-controller', 'removals'); - flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-50`; + flashDiv.setAttribute('data-removals-timeout-value', type === 'notice' || type === 'success' ? '5000' : '0'); + flashDiv.setAttribute('role', 'alert'); + flashDiv.className = `alert ${getAlertClass(type)} shadow-lg z-[6000]`; - // Create the message div - const messageDiv = document.createElement('div'); - messageDiv.className = 'mr-4'; - messageDiv.innerText = message; + // Create the content wrapper + const contentDiv = document.createElement('div'); + contentDiv.className = 'flex items-center gap-2'; + + // Add the icon + const icon = getFlashIcon(type); + contentDiv.appendChild(icon); + + // Create the message span + const messageSpan = document.createElement('span'); + messageSpan.innerText = message; + contentDiv.appendChild(messageSpan); // Create the close button const closeButton = document.createElement('button'); closeButton.setAttribute('type', 'button'); closeButton.setAttribute('data-action', 'click->removals#remove'); - closeButton.className = 'ml-auto'; // Ensures button stays on the right + closeButton.setAttribute('aria-label', 'Close'); + closeButton.className = 'btn btn-sm btn-circle btn-ghost'; // Create the SVG icon for the close button const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - closeIcon.setAttribute('class', 'h-6 w-6'); + closeIcon.setAttribute('class', 'h-5 w-5'); closeIcon.setAttribute('fill', 'none'); closeIcon.setAttribute('viewBox', '0 0 24 24'); closeIcon.setAttribute('stroke', 'currentColor'); @@ -162,33 +173,75 @@ export function showFlashMessage(type, message) { // Append all elements closeIcon.appendChild(closeIconPath); closeButton.appendChild(closeIcon); - flashDiv.appendChild(messageDiv); + flashDiv.appendChild(contentDiv); flashDiv.appendChild(closeButton); flashContainer.appendChild(flashDiv); - // Automatically remove after 5 seconds - setTimeout(() => { - if (flashDiv && flashDiv.parentNode) { - flashDiv.remove(); - // Remove container if empty - if (flashContainer && !flashContainer.hasChildNodes()) { - flashContainer.remove(); + // Automatically remove after 5 seconds for notice/success + if (type === 'notice' || type === 'success') { + setTimeout(() => { + if (flashDiv && flashDiv.parentNode) { + flashDiv.remove(); + // Remove container if empty + if (flashContainer && !flashContainer.hasChildNodes()) { + flashContainer.remove(); + } } - } - }, 5000); + }, 5000); + } } -function classesForFlash(type) { +function getAlertClass(type) { switch (type) { case 'error': - return 'bg-red-100 text-red-700 border-red-300'; + case 'alert': + return 'alert-error'; case 'notice': - return 'bg-blue-100 text-blue-700 border-blue-300'; + case 'info': + return 'alert-info'; + case 'success': + return 'alert-success'; + case 'warning': + return 'alert-warning'; default: - return 'bg-blue-100 text-blue-700 border-blue-300'; + return 'alert-info'; } } +function getFlashIcon(type) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svg.setAttribute('class', 'h-6 w-6 shrink-0 stroke-current'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('viewBox', '0 0 24 24'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('stroke-width', '2'); + + switch (type) { + case 'error': + case 'alert': + path.setAttribute('d', 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z'); + break; + case 'success': + path.setAttribute('d', 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'); + break; + case 'warning': + path.setAttribute('d', 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'); + break; + case 'notice': + case 'info': + default: + path.setAttribute('d', 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'); + break; + } + + svg.appendChild(path); + return svg; +} + export function debounce(func, wait) { let timeout; return function executedFunction(...args) { diff --git a/app/javascript/maps/location_search.js b/app/javascript/maps/location_search.js index e54ff40f..fdf61d08 100644 --- a/app/javascript/maps/location_search.js +++ b/app/javascript/maps/location_search.js @@ -24,7 +24,7 @@ class LocationSearch { const SearchToggleControl = L.Control.extend({ onAdd: function(map) { const button = L.DomUtil.create('button', 'location-search-toggle'); - button.innerHTML = '🔍'; + button.innerHTML = ''; // Style the button with theme-aware styling applyThemeToButton(button, this.userTheme); button.style.width = '48px'; @@ -33,6 +33,9 @@ class LocationSearch { button.style.padding = '0'; button.style.fontSize = '18px'; button.style.marginTop = '10px'; // Space below settings button + button.style.display = 'flex'; + button.style.alignItems = 'center'; + button.style.justifyContent = 'center'; button.title = 'Search locations'; button.id = 'location-search-toggle'; return button; @@ -174,8 +177,6 @@ class LocationSearch { container.addEventListener('DOMMouseScroll', (e) => { e.stopPropagation(); }, { passive: false }); - - console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar'); } }); } diff --git a/app/javascript/maps/map_controls.js b/app/javascript/maps/map_controls.js new file mode 100644 index 00000000..2c353e38 --- /dev/null +++ b/app/javascript/maps/map_controls.js @@ -0,0 +1,193 @@ +// Map control buttons and utilities +// This file contains all button controls that are positioned on the top-right corner of the map +import L from "leaflet"; +import { applyThemeToButton } from "./theme_utils"; + +/** + * Creates a standardized button element for map controls + * @param {String} className - CSS class name for the button + * @param {String} svgIcon - SVG icon HTML + * @param {String} title - Button title/tooltip + * @param {String} userTheme - User's theme preference ('dark' or 'light') + * @param {Function} onClickCallback - Callback function to execute when button is clicked + * @returns {HTMLElement} Button element with tooltip + */ +function createStandardButton(className, svgIcon, title, userTheme, onClickCallback) { + const button = L.DomUtil.create('button', `${className} tooltip tooltip-left`); + button.innerHTML = svgIcon; + button.setAttribute('data-tip', title); + + // Apply standard button styling + applyThemeToButton(button, userTheme); + button.style.width = '48px'; + button.style.height = '48px'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.display = 'flex'; + button.style.alignItems = 'center'; + button.style.justifyContent = 'center'; + button.style.fontSize = '18px'; + button.style.transition = 'all 0.2s ease'; + + // Disable map interactions when clicking the button + L.DomEvent.disableClickPropagation(button); + + // Attach click handler if provided + // Note: Some buttons (like Add Visit) have their handlers attached separately + if (onClickCallback && typeof onClickCallback === 'function') { + L.DomEvent.on(button, 'click', () => { + onClickCallback(button); + }); + } + + return button; +} + +/** + * Creates a "Toggle Panel" button control for the map + * @param {Function} onClickCallback - Callback function to execute when button is clicked + * @param {String} userTheme - User's theme preference ('dark' or 'light') + * @returns {L.Control} Leaflet control instance + */ +export function createTogglePanelControl(onClickCallback, userTheme = 'dark') { + const TogglePanelControl = L.Control.extend({ + onAdd: function(map) { + const svgIcon = ` + + + + + + + + `; + return createStandardButton('toggle-panel-button', svgIcon, 'Toggle Panel', userTheme, onClickCallback); + } + }); + + return TogglePanelControl; +} + +/** + * Creates a "Visits Drawer" button control for the map + * @param {Function} onClickCallback - Callback function to execute when button is clicked + * @param {String} userTheme - User's theme preference ('dark' or 'light') + * @returns {L.Control} Leaflet control instance + */ +export function createVisitsDrawerControl(onClickCallback, userTheme = 'dark') { + const DrawerControl = L.Control.extend({ + onAdd: function(map) { + const svgIcon = ''; + return createStandardButton('leaflet-control-button drawer-button', svgIcon, 'Toggle Visits Drawer', userTheme, onClickCallback); + } + }); + + return DrawerControl; +} + +/** + * Creates an "Area Selection" button control for the map + * @param {Function} onClickCallback - Callback function to execute when button is clicked + * @param {String} userTheme - User's theme preference ('dark' or 'light') + * @returns {L.Control} Leaflet control instance + */ +export function createAreaSelectionControl(onClickCallback, userTheme = 'dark') { + const SelectionControl = L.Control.extend({ + onAdd: function(map) { + const svgIcon = ''; + const button = createStandardButton('leaflet-bar leaflet-control leaflet-control-custom', svgIcon, 'Select Area', userTheme, onClickCallback); + button.id = 'selection-tool-button'; + return button; + } + }); + + return SelectionControl; +} + +/** + * Creates an "Add Visit" button control for the map + * @param {Function} onClickCallback - Callback function to execute when button is clicked + * @param {String} userTheme - User's theme preference ('dark' or 'light') + * @returns {L.Control} Leaflet control instance + */ +export function createAddVisitControl(onClickCallback, userTheme = 'dark') { + const AddVisitControl = L.Control.extend({ + onAdd: function(map) { + const svgIcon = ''; + return createStandardButton('leaflet-control-button add-visit-button', svgIcon, 'Add a visit', userTheme, onClickCallback); + } + }); + + return AddVisitControl; +} + +/** + * Adds all top-right corner buttons to the map in the correct order + * Order: 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer + * Note: Layer control is added separately by Leaflet and appears at the top + * + * @param {Object} map - Leaflet map instance + * @param {Object} callbacks - Object containing callback functions for each button + * @param {Function} callbacks.onSelectArea - Callback for select area button + * @param {Function} callbacks.onAddVisit - Callback for add visit button + * @param {Function} callbacks.onToggleCalendar - Callback for toggle calendar/panel button + * @param {Function} callbacks.onToggleDrawer - Callback for toggle drawer button + * @param {String} userTheme - User's theme preference ('dark' or 'light') + * @returns {Object} Object containing references to all created controls + */ +export function addTopRightButtons(map, callbacks, userTheme = 'dark') { + const controls = {}; + + // 1. Select Area button + if (callbacks.onSelectArea) { + const SelectionControl = createAreaSelectionControl(callbacks.onSelectArea, userTheme); + controls.selectionControl = new SelectionControl({ position: 'topright' }); + map.addControl(controls.selectionControl); + } + + // 2. Add Visit button + // Note: Button is always created, callback is optional (add_visit_controller attaches its own handler) + const AddVisitControl = createAddVisitControl(callbacks.onAddVisit, userTheme); + controls.addVisitControl = new AddVisitControl({ position: 'topright' }); + map.addControl(controls.addVisitControl); + + // 3. Open Calendar (Toggle Panel) button + if (callbacks.onToggleCalendar) { + const TogglePanelControl = createTogglePanelControl(callbacks.onToggleCalendar, userTheme); + controls.togglePanelControl = new TogglePanelControl({ position: 'topright' }); + map.addControl(controls.togglePanelControl); + } + + // 4. Open Drawer button + if (callbacks.onToggleDrawer) { + const DrawerControl = createVisitsDrawerControl(callbacks.onToggleDrawer, userTheme); + controls.drawerControl = new DrawerControl({ position: 'topright' }); + map.addControl(controls.drawerControl); + } + + return controls; +} + +/** + * Updates the Add Visit button to show active state + * @param {HTMLElement} button - The button element to update + */ +export function setAddVisitButtonActive(button) { + if (!button) return; + + button.style.backgroundColor = '#dc3545'; + button.style.color = 'white'; + button.innerHTML = '✕'; +} + +/** + * Updates the Add Visit button to show inactive/default state + * @param {HTMLElement} button - The button element to update + * @param {String} userTheme - User's theme preference ('dark' or 'light') + */ +export function setAddVisitButtonInactive(button, userTheme = 'dark') { + if (!button) return; + + applyThemeToButton(button, userTheme); + button.innerHTML = ''; +} diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js index 4a1bdf35..86daa589 100644 --- a/app/javascript/maps/visits.js +++ b/app/javascript/maps/visits.js @@ -1,6 +1,5 @@ import L from "leaflet"; import { showFlashMessage } from "./helpers"; -import { applyThemeToButton } from "./theme_utils"; /** * Manages visits functionality including displaying, fetching, and interacting with visits @@ -65,74 +64,14 @@ export class VisitsManager { } /** - * Adds a button to toggle the visits drawer + * Note: Drawer and selection buttons are now added centrally via addTopRightButtons() + * in maps_controller.js to ensure correct button ordering. + * + * The methods below are kept for backwards compatibility but are no longer called + * during initialization. Button callbacks are wired directly in maps_controller.js: + * - onSelectArea -> this.toggleSelectionMode() + * - onToggleDrawer -> this.toggleDrawer() */ - addDrawerButton() { - const DrawerControl = L.Control.extend({ - onAdd: (map) => { - const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button'); - button.innerHTML = '⬅️'; // Left arrow icon - // Style the button with theme-aware styling - applyThemeToButton(button, this.userTheme); - button.style.width = '48px'; - button.style.height = '48px'; - button.style.borderRadius = '4px'; - button.style.padding = '0'; - button.style.lineHeight = '48px'; - button.style.fontSize = '18px'; - button.style.textAlign = 'center'; - - L.DomEvent.disableClickPropagation(button); - L.DomEvent.on(button, 'click', () => { - this.toggleDrawer(); - }); - - return button; - } - }); - - this.map.addControl(new DrawerControl({ position: 'topright' })); - - // Add the selection tool button - this.addSelectionButton(); - } - - /** - * Adds a button to enable/disable the area selection tool - */ - addSelectionButton() { - const SelectionControl = L.Control.extend({ - onAdd: (map) => { - const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom'); - button.innerHTML = '⚓️'; - button.title = 'Select Area'; - button.id = 'selection-tool-button'; - // Style the button with theme-aware styling - applyThemeToButton(button, this.userTheme); - button.style.width = '48px'; - button.style.height = '48px'; - button.style.borderRadius = '4px'; - button.style.padding = '0'; - button.style.lineHeight = '48px'; - button.style.fontSize = '18px'; - button.style.textAlign = 'center'; - button.onclick = () => this.toggleSelectionMode(); - return button; - } - }); - - new SelectionControl({ position: 'topright' }).addTo(this.map); - - // Add CSS for selection button active state - const style = document.createElement('style'); - style.textContent = ` - #selection-tool-button.active { - border: 2px dashed #3388ff !important; - box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important; - } - `; - document.head.appendChild(style); - } /** * Toggles the area selection mode @@ -482,7 +421,7 @@ export class VisitsManager { const drawerButton = document.querySelector('.drawer-button'); if (drawerButton) { - drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️'; + drawerButton.innerHTML = this.drawerOpen ? '' : ''; } const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button'); diff --git a/app/jobs/family/invitations/cleanup_job.rb b/app/jobs/family/invitations/cleanup_job.rb new file mode 100644 index 00000000..2f00cdd0 --- /dev/null +++ b/app/jobs/family/invitations/cleanup_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Family::Invitations::CleanupJob < ApplicationJob + queue_as :families + + def perform + Rails.logger.info 'Starting family invitations cleanup' + + expired_count = Family::Invitation.where(status: :pending) + .where('expires_at < ?', Time.current) + .update_all(status: :expired) + + Rails.logger.info "Updated #{expired_count} expired family invitations" + + cleanup_threshold = 30.days.ago + deleted_count = Family::Invitation.where(status: [:expired, :cancelled]) + .where('updated_at < ?', cleanup_threshold) + .delete_all + + Rails.logger.info "Deleted #{deleted_count} old family invitations" + + Rails.logger.info 'Family invitations cleanup completed' + end +end diff --git a/app/jobs/users/import_data_job.rb b/app/jobs/users/import_data_job.rb index e6661f3a..747b7a67 100644 --- a/app/jobs/users/import_data_job.rb +++ b/app/jobs/users/import_data_job.rb @@ -17,6 +17,8 @@ class Users::ImportDataJob < ApplicationJob import_stats = Users::ImportData.new(user, archive_path).import + User.reset_counters(user.id, :points) + Rails.logger.info "Import completed successfully for user #{user.email}: #{import_stats}" rescue ActiveRecord::RecordNotFound => e ExceptionReporter.call(e, "Import job failed for import_id #{import_id} - import not found") diff --git a/app/mailers/family_mailer.rb b/app/mailers/family_mailer.rb new file mode 100644 index 00000000..b0c2673b --- /dev/null +++ b/app/mailers/family_mailer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class FamilyMailer < ApplicationMailer + def invitation(invitation) + @invitation = invitation + @family = invitation.family + @invited_by = invitation.invited_by + @accept_url = family_invitation_url(@invitation.token) + + mail( + to: @invitation.email, + subject: "🎉 You've been invited to join #{@family.name} on Dawarich!" + ) + end + + def member_joined(family, user) + @family = family + @user = user + + mail( + to: @family.owner.email, + subject: "👪 #{@user.name} has joined your family #{@family.name} on Dawarich!" + ) + end +end diff --git a/app/models/concerns/user_family.rb b/app/models/concerns/user_family.rb new file mode 100644 index 00000000..53119792 --- /dev/null +++ b/app/models/concerns/user_family.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module UserFamily + extend ActiveSupport::Concern + + included do + has_one :family_membership, dependent: :destroy, class_name: 'Family::Membership' + has_one :family, through: :family_membership + has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy + has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id', + inverse_of: :invited_by, dependent: :destroy + + before_destroy :check_family_ownership + end + + def in_family? + family_membership.present? + end + + def family_owner? + family_membership&.owner? == true + end + + def can_delete_account? + return true unless family_owner? + return true unless family + + family.members.count <= 1 + end + + def family_sharing_enabled? + return false unless in_family? + + sharing_settings = settings.dig('family', 'location_sharing') + return false unless sharing_settings.is_a?(Hash) + return false unless sharing_settings['enabled'] == true + + expires_at = sharing_settings['expires_at'] + expires_at.blank? || Time.parse(expires_at).future? + end + + def update_family_location_sharing!(enabled, duration: nil) + return false unless in_family? + + current_settings = settings || {} + current_settings['family'] ||= {} + + if enabled + sharing_config = { 'enabled' => true } + + if duration.present? + expiration_time = case duration + when '1h' then 1.hour.from_now + when '6h' then 6.hours.from_now + when '12h' then 12.hours.from_now + when '24h' then 24.hours.from_now + when 'permanent' then nil + else duration.to_i.hours.from_now if duration.to_i > 0 + end + + sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time + sharing_config['duration'] = duration + end + + current_settings['family']['location_sharing'] = sharing_config + else + current_settings['family']['location_sharing'] = { 'enabled' => false } + end + + update!(settings: current_settings) + end + + def family_sharing_expires_at + sharing_settings = settings.dig('family', 'location_sharing') + return nil unless sharing_settings.is_a?(Hash) + + expires_at = sharing_settings['expires_at'] + Time.parse(expires_at) if expires_at.present? + rescue ArgumentError + nil + end + + def family_sharing_duration + settings.dig('family', 'location_sharing', 'duration') || 'permanent' + end + + def latest_location_for_family + return nil unless family_sharing_enabled? + + latest_point = + points.select(:lonlat, :timestamp) + .order(timestamp: :desc) + .limit(1) + .first + + return nil unless latest_point + + { + user_id: id, + email: email, + latitude: latest_point.lat, + longitude: latest_point.lon, + timestamp: latest_point.timestamp, + updated_at: Time.zone.at(latest_point.timestamp) + } + end + + private + + def check_family_ownership + return if can_delete_account? + + errors.add(:base, 'Cannot delete account while being a family owner with other members') + raise ActiveRecord::DeleteRestrictionError, 'Cannot delete user with family members' + end +end diff --git a/app/models/family.rb b/app/models/family.rb new file mode 100644 index 00000000..51123293 --- /dev/null +++ b/app/models/family.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Family < ApplicationRecord + has_many :family_memberships, dependent: :destroy, class_name: 'Family::Membership' + has_many :members, through: :family_memberships, source: :user + has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation' + belongs_to :creator, class_name: 'User' + + validates :name, presence: true, length: { maximum: 50 } + + MAX_MEMBERS = 5 + + def can_add_members? + (member_count + pending_invitations_count) < MAX_MEMBERS + end + + def member_count + @member_count ||= members.count + end + + def pending_invitations_count + @pending_invitations_count ||= family_invitations.active.count + end + + def owners + members.joins(:family_membership) + .where(family_memberships: { role: :owner }) + end + + def owner + @owner ||= creator + end + + def full? + (member_count + pending_invitations_count) >= MAX_MEMBERS + end + + def active_invitations + family_invitations.active.includes(:invited_by) + end + + def clear_member_cache! + @member_count = nil + @pending_invitations_count = nil + @owner = nil + end +end diff --git a/app/models/family/invitation.rb b/app/models/family/invitation.rb new file mode 100644 index 00000000..a2739291 --- /dev/null +++ b/app/models/family/invitation.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Family::Invitation < ApplicationRecord + self.table_name = 'family_invitations' + + EXPIRY_DAYS = 7 + + belongs_to :family + belongs_to :invited_by, class_name: 'User' + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :token, presence: true, uniqueness: true + validates :expires_at, :status, presence: true + + enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 } + + scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) } + + before_validation :generate_token, :set_expiry, on: :create + + after_create :clear_family_cache + after_update :clear_family_cache, if: :saved_change_to_status? + after_destroy :clear_family_cache + + def expired? + expires_at.past? + end + + def can_be_accepted? + pending? && !expired? + end + + private + + def generate_token + self.token = SecureRandom.urlsafe_base64(32) if token.blank? + end + + def set_expiry + self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank? + end + + def clear_family_cache + family.clear_member_cache! + end +end diff --git a/app/models/family/membership.rb b/app/models/family/membership.rb new file mode 100644 index 00000000..da982204 --- /dev/null +++ b/app/models/family/membership.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Family::Membership < ApplicationRecord + self.table_name = 'family_memberships' + + belongs_to :family + belongs_to :user + + validates :user_id, presence: true, uniqueness: true + validates :role, presence: true + + enum :role, { owner: 0, member: 1 } + + after_create :clear_family_cache + after_update :clear_family_cache + after_destroy :clear_family_cache + + private + + def clear_family_cache + family.clear_member_cache! + end +end diff --git a/app/models/point.rb b/app/models/point.rb index 2f1b9fef..b19e828d 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -75,24 +75,49 @@ class Point < ApplicationRecord # rubocop:disable Metrics/MethodLength Metrics/AbcSize def broadcast_coordinates - return unless user.safe_settings.live_map_enabled + if user.safe_settings.live_map_enabled + PointsChannel.broadcast_to( + user, + [ + lat, + lon, + battery.to_s, + altitude.to_s, + timestamp.to_s, + velocity.to_s, + id.to_s, + country_name.to_s + ] + ) + end - PointsChannel.broadcast_to( - user, - [ - lat, - lon, - battery.to_s, - altitude.to_s, - timestamp.to_s, - velocity.to_s, - id.to_s, - country_name.to_s - ] - ) + broadcast_to_family if should_broadcast_to_family? end # rubocop:enable Metrics/MethodLength + def should_broadcast_to_family? + return false unless DawarichSettings.family_feature_enabled? + return false unless user.in_family? + return false unless user.family_sharing_enabled? + + true + end + + def broadcast_to_family + FamilyLocationsChannel.broadcast_to( + user.family, + { + user_id: user.id, + email: user.email, + email_initial: user.email.first.upcase, + latitude: lat, + longitude: lon, + timestamp: timestamp.to_i, + updated_at: Time.zone.at(timestamp.to_i).iso8601 + } + ) + end + def set_country self.country_id = found_in_country&.id save! if changed? diff --git a/app/models/user.rb b/app/models/user.rb index bde8e853..71269d64 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class User < ApplicationRecord # rubocop:disable Metrics/ClassLength + include UserFamily devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable - has_many :points, dependent: :destroy, counter_cache: true + has_many :points, dependent: :destroy has_many :imports, dependent: :destroy has_many :stats, dependent: :destroy has_many :exports, dependent: :destroy diff --git a/app/policies/family/invitation_policy.rb b/app/policies/family/invitation_policy.rb new file mode 100644 index 00000000..2f0b59f5 --- /dev/null +++ b/app/policies/family/invitation_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Family::InvitationPolicy < ApplicationPolicy + def create? + return false unless user + + user.family == record.family && user.family_owner? + end + + def accept? + return false unless user + + user.email == record.email + end + + def destroy? + create? + end +end diff --git a/app/policies/family/membership_policy.rb b/app/policies/family/membership_policy.rb new file mode 100644 index 00000000..d77c7b14 --- /dev/null +++ b/app/policies/family/membership_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Family::MembershipPolicy < ApplicationPolicy + def create? + return false unless user + return false unless record.is_a?(Family::Invitation) + + record.email == user.email && record.pending? && !record.expired? + end + + def destroy? + return false unless user + return true if user == record.user + + user.family == record.family && user.family_owner? + end +end diff --git a/app/policies/family_invitation_policy.rb b/app/policies/family_invitation_policy.rb new file mode 100644 index 00000000..2369458b --- /dev/null +++ b/app/policies/family_invitation_policy.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class FamilyInvitationPolicy < ApplicationPolicy + def show? + # Public endpoint for invitation acceptance - no authentication required + true + end + + def create? + user.family == record.family && user.family_owner? + end + + def accept? + # Users can accept invitations sent to their email + user.email == record.email + end + + def destroy? + # Only family owners can cancel invitations + user.family == record.family && user.family_owner? + end +end diff --git a/app/policies/family_membership_policy.rb b/app/policies/family_membership_policy.rb new file mode 100644 index 00000000..1b50c18e --- /dev/null +++ b/app/policies/family_membership_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class FamilyMembershipPolicy < ApplicationPolicy + def show? + user.family == record.family + end + + def update? + # Users can update their own settings + return true if user == record.user + + # Family owners can update any member's settings + user.family == record.family && user.family_owner? + end + + def destroy? + # Users can remove themselves (handled by family leave logic) + return true if user == record.user + + # Family owners can remove other members + user.family == record.family && user.family_owner? + end +end diff --git a/app/policies/family_policy.rb b/app/policies/family_policy.rb new file mode 100644 index 00000000..b644de53 --- /dev/null +++ b/app/policies/family_policy.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class FamilyPolicy < ApplicationPolicy + def show? + user.family == record + end + + def create? + return false if user.in_family? + return true if DawarichSettings.self_hosted? + + # Add cloud subscription checks here when implemented + # For now, allow all users to create families + true + end + + def update? + user.family == record && user.family_owner? + end + + def destroy? + user.family == record && user.family_owner? + end + + def leave? + user.family == record && !family_owner_with_members? + end + + def invite? + user.family == record && user.family_owner? + end + + def manage_invitations? + user.family == record && user.family_owner? + end + + private + + def family_owner_with_members? + user.family_owner? && record.members.count > 1 + end +end diff --git a/app/services/families/accept_invitation.rb b/app/services/families/accept_invitation.rb new file mode 100644 index 00000000..3e327a43 --- /dev/null +++ b/app/services/families/accept_invitation.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Families + class AcceptInvitation + attr_reader :invitation, :user, :error_message + + def initialize(invitation:, user:) + @invitation = invitation + @user = user + @error_message = nil + end + + def call + return false unless can_accept? + + if user.in_family? + @error_message = 'You must leave your current family before joining a new one.' + + return false + end + + ActiveRecord::Base.transaction do + create_membership + update_invitation + send_notifications + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + false + rescue StandardError => e + handle_generic_error(e) + false + end + + private + + def can_accept? + return false unless validate_invitation + return false unless validate_email_match + return false unless validate_family_capacity + + true + end + + def validate_invitation + return true if invitation.can_be_accepted? + + @error_message = 'This invitation is no longer valid or has expired.' + + false + end + + def validate_email_match + return true if invitation.email == user.email + + @error_message = 'This invitation is not for your email address.' + + false + end + + def validate_family_capacity + return true unless invitation.family.full? + + @error_message = 'This family has reached the maximum number of members.' + + false + end + + def create_membership + Family::Membership.create!( + family: invitation.family, + user: user, + role: :member + ) + end + + def update_invitation + invitation.update!(status: :accepted) + end + + def send_notifications + send_user_notification + send_owner_notification + end + + def send_user_notification + Notification.create!( + user: user, + kind: :info, + title: 'Welcome to Family!', + content: "You've joined the family '#{invitation.family.name}'" + ) + end + + def send_owner_notification + Notification.create!( + user: invitation.family.creator, + kind: :info, + title: 'New Family Member!', + content: "#{user.email} has joined your family" + ) + rescue StandardError => e + ExceptionReporter.call(e, "Unexpected error in Families::AcceptInvitation: #{e.message}") + end + + def handle_record_invalid_error(error) + @error_message = + if error.record&.errors&.any? + error.record.errors.full_messages.first + else + "Failed to join family: #{error.message}" + end + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::AcceptInvitation: #{error.message}") + + @error_message = 'An unexpected error occurred while joining the family. Please try again' + end + end +end diff --git a/app/services/families/create.rb b/app/services/families/create.rb new file mode 100644 index 00000000..08135f99 --- /dev/null +++ b/app/services/families/create.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Families + class Create + include ActiveModel::Validations + + attr_reader :user, :name, :family, :error_message + + validates :name, presence: { message: 'Family name is required' } + validates :name, length: { + maximum: 50, + message: 'Family name must be 50 characters or less' + } + + def initialize(user:, name:) + @user = user + @name = name&.strip + @error_message = nil + end + + def call + return false unless valid? + return false unless validate_user_eligibility + return false unless validate_feature_access + + ActiveRecord::Base.transaction do + create_family + create_owner_membership + send_notification + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + + false + rescue ActiveRecord::RecordNotUnique => e + handle_uniqueness_error(e) + + false + rescue StandardError => e + handle_generic_error(e) + + false + end + + private + + def validate_user_eligibility + if user.in_family? + @error_message = 'You must leave your current family before creating a new one' + return false + end + + if user.created_family.present? + @error_message = 'You have already created a family. Each user can only create one family' + return false + end + + true + end + + def validate_feature_access + return true if can_create_family? + + @error_message = + if DawarichSettings.self_hosted? + 'Family feature is not available on this instance' + else + 'Family feature requires an active subscription' + end + + false + end + + def can_create_family? + return true if DawarichSettings.self_hosted? + + # TODO: Add cloud plan validation here when needed + # For now, allow all users to create families + true + end + + def create_family + @family = Family.create!(name: name, creator: user) + end + + def create_owner_membership + Family::Membership.create!( + family: family, + user: user, + role: :owner + ) + end + + def send_notification + Notification.create!( + user: user, + kind: :info, + title: 'Family Created', + content: "You've successfully created the family '#{family.name}'" + ) + rescue StandardError => e + # Don't fail the entire operation if notification fails + ExceptionReporter.call(e, "Unexpected error in Families::Create: #{e.message}") + end + + def handle_record_invalid_error(error) + @error_message = + if family&.errors&.any? + family.errors.full_messages.first + else + "Failed to create family: #{error.message}" + end + end + + def handle_uniqueness_error(_error) + @error_message = 'A family with this name already exists for your account' + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::Create: #{error.message}") + @error_message = 'An unexpected error occurred while creating the family. Please try again' + end + end +end diff --git a/app/services/families/invite.rb b/app/services/families/invite.rb new file mode 100644 index 00000000..c1d7796b --- /dev/null +++ b/app/services/families/invite.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Families + class Invite + include ActiveModel::Validations + + attr_reader :family, :email, :invited_by, :invitation + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + def initialize(family:, email:, invited_by:) + @family = family + @email = email.downcase.strip + @invited_by = invited_by + end + + def call + return false unless valid? + return false unless invite_sendable? + + ActiveRecord::Base.transaction do + create_invitation + send_invitation_email + send_notification + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + false + rescue Net::SMTPError => e + handle_email_error(e) + false + rescue StandardError => e + handle_generic_error(e) + false + end + + def error_message + return errors.full_messages.first if errors.any? + return @custom_error_message if @custom_error_message + + 'Failed to send invitation' + end + + private + + def invite_sendable? + unless invited_by.family_owner? + return add_error_and_false(:invited_by, + 'You must be a family owner to send invitations') + end + return add_error_and_false(:family, 'Family is full') if family.full? + return add_error_and_false(:email, 'User is already in a family') if user_already_in_family? + return add_error_and_false(:email, 'Invitation already sent to this email') if pending_invitation_exists? + + true + end + + def add_error_and_false(attribute, message) + errors.add(attribute, message) + false + end + + def user_already_in_family? + User.joins(:family_membership) + .where(email: email) + .exists? + end + + def pending_invitation_exists? + family.family_invitations.active.where(email: email).exists? + end + + def create_invitation + @invitation = Family::Invitation.create!( + family: family, + email: email, + invited_by: invited_by + ) + end + + def send_invitation_email + # Send email in background with retry logic + FamilyMailer.invitation(@invitation).deliver_later( + queue: :mailer, + retry: 3, + wait: 30.seconds + ) + end + + def send_notification + Notification.create!( + user: invited_by, + kind: :info, + title: 'Invitation Sent', + content: "Family invitation sent to #{email}" + ) + rescue StandardError => e + # Don't fail the entire operation if notification fails + ExceptionReporter.call(e, "Unexpected error in Families::Invite: #{e.message}") + end + + def handle_record_invalid_error(error) + @custom_error_message = if invitation&.errors&.any? + invitation.errors.full_messages.first + else + "Failed to create invitation: #{error.message}" + end + end + + def handle_email_error(error) + Rails.logger.error "Email delivery failed for family invitation: #{error.message}" + @custom_error_message = 'Failed to send invitation email. Please try again later' + + # Clean up the invitation if email fails + invitation&.destroy + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::Invite: #{error.message}") + @custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again' + end + end +end diff --git a/app/services/families/locations.rb b/app/services/families/locations.rb new file mode 100644 index 00000000..7cc2ea6c --- /dev/null +++ b/app/services/families/locations.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Families::Locations + attr_reader :user + + def initialize(user) + @user = user + end + + def call + return [] unless family_feature_enabled? + return [] unless user.in_family? + + sharing_members = family_members_with_sharing_enabled + return [] unless sharing_members.any? + + build_family_locations(sharing_members) + end + + private + + def family_feature_enabled? + DawarichSettings.family_feature_enabled? + end + + def family_members_with_sharing_enabled + user.family.members + .where.not(id: user.id) + .select(&:family_sharing_enabled?) + end + + def build_family_locations(sharing_members) + latest_points = + sharing_members.map { _1.points.last }.compact + + latest_points.map do |point| + { + user_id: point.user_id, + email: point.user.email, + email_initial: point.user.email.first.upcase, + latitude: point.lat, + longitude: point.lon, + timestamp: point.timestamp.to_i, + updated_at: Time.zone.at(point.timestamp.to_i), + battery: point.battery, + battery_status: point.battery_status + } + end + end +end diff --git a/app/services/families/memberships/destroy.rb b/app/services/families/memberships/destroy.rb new file mode 100644 index 00000000..efdbc914 --- /dev/null +++ b/app/services/families/memberships/destroy.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module Families + module Memberships + class Destroy + attr_reader :user, :member_to_remove, :error_message + + def initialize(user:, member_to_remove: nil) + @user = user + @member_to_remove = member_to_remove || user + @error_message = nil + end + + def call + return false unless validate_can_leave + + @family_name = member_to_remove.family.name + @family_owner = member_to_remove.family.owner + + ActiveRecord::Base.transaction do + remove_membership + send_notifications + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + + false + rescue StandardError => e + handle_generic_error(e) + + false + end + + private + + def validate_can_leave + return false unless validate_in_family + return false unless validate_removal_allowed + + true + end + + def validate_in_family + return true if member_to_remove.in_family? + + @error_message = 'User is not currently in a family.' + false + end + + def validate_removal_allowed + return validate_owner_can_leave if removing_self? + + return false unless validate_remover_is_owner + return false unless validate_same_family + return false unless validate_not_removing_owner + + true + end + + def removing_self? + user == member_to_remove + end + + def validate_owner_can_leave + return true unless member_to_remove.family_owner? + + @error_message = 'Family owners cannot remove their own membership. To leave the family, delete it instead.' + false + end + + def validate_remover_is_owner + return true if user.family_owner? + + @error_message = 'Only family owners can remove other members.' + false + end + + def validate_same_family + return true if user.family == member_to_remove.family + + @error_message = 'Cannot remove members from a different family.' + false + end + + def validate_not_removing_owner + return true unless member_to_remove.family_owner? + + @error_message = 'Cannot remove the family owner. The owner must delete the family or leave on their own.' + false + end + + def remove_membership + member_to_remove.family_membership.destroy! + end + + def send_notifications + if removing_self? + send_self_removal_notifications + else + send_member_removed_notifications + end + end + + def send_self_removal_notifications + Notification.create!( + user: member_to_remove, + kind: :info, + title: 'Left Family', + content: "You've left the family \"#{@family_name}\"" + ) + + return unless @family_owner&.persisted? + + Notification.create!( + user: @family_owner, + kind: :info, + title: 'Family Member Left', + content: "#{member_to_remove.email} has left the family \"#{@family_name}\"" + ) + end + + def send_member_removed_notifications + Notification.create!( + user: member_to_remove, + kind: :info, + title: 'Removed from Family', + content: "You have been removed from the family \"#{@family_name}\" by #{user.email}" + ) + + return unless user != member_to_remove + + Notification.create!( + user: user, + kind: :info, + title: 'Member Removed', + content: "#{member_to_remove.email} has been removed from the family \"#{@family_name}\"" + ) + end + + def handle_record_invalid_error(error) + @error_message = + if error.record&.errors&.any? + error.record.errors.full_messages.first + else + "Failed to leave family: #{error.message}" + end + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::Memberships::Destroy: #{error.message}") + @error_message = 'An unexpected error occurred while removing the membership. Please try again' + end + end + end +end diff --git a/app/services/families/update_location_sharing.rb b/app/services/families/update_location_sharing.rb new file mode 100644 index 00000000..a0cc9f12 --- /dev/null +++ b/app/services/families/update_location_sharing.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Families::UpdateLocationSharing + Result = Struct.new(:success?, :payload, :status, keyword_init: true) + + def initialize(user:, enabled:, duration:) + @user = user + @enabled_param = enabled + @duration_param = duration + @boolean_caster = ActiveModel::Type::Boolean.new + end + + def call + return success_result if update_location_sharing + + failure_result('Failed to update location sharing setting', :unprocessable_content) + rescue => error + ExceptionReporter.call(error, "Error in Families::UpdateLocationSharing: #{error.message}") + + failure_result('An error occurred while updating location sharing', :internal_server_error) + end + + private + + attr_reader :user, :enabled_param, :duration_param, :boolean_caster + + def update_location_sharing + user.update_family_location_sharing!(enabled?, duration: duration_param) + end + + def enabled? + @enabled ||= boolean_caster.cast(enabled_param) + end + + def success_result + payload = { + success: true, + enabled: enabled?, + duration: user.family_sharing_duration, + message: build_sharing_message + } + + if enabled? && user.family_sharing_expires_at.present? + payload[:expires_at] = user.family_sharing_expires_at.iso8601 + payload[:expires_at_formatted] = user.family_sharing_expires_at.strftime('%b %d at %I:%M %p') + end + + Result.new(success?: true, payload: payload, status: :ok) + end + + def failure_result(message, status) + Result.new(success?: false, payload: { success: false, message: message }, status: status) + end + + def build_sharing_message + return 'Location sharing disabled' unless enabled? + + case duration_param + when '1h' then 'Location sharing enabled for 1 hour' + when '6h' then 'Location sharing enabled for 6 hours' + when '12h' then 'Location sharing enabled for 12 hours' + when '24h' then 'Location sharing enabled for 24 hours' + when 'permanent', nil then 'Location sharing enabled' + else + duration_param.to_i.positive? ? "Location sharing enabled for #{duration_param.to_i} hours" : 'Location sharing enabled' + end + end +end diff --git a/app/services/geojson/importer.rb b/app/services/geojson/importer.rb index 94230047..501c0533 100644 --- a/app/services/geojson/importer.rb +++ b/app/services/geojson/importer.rb @@ -5,6 +5,7 @@ class Geojson::Importer include Imports::FileLoader include PointValidation + BATCH_SIZE = 1000 attr_reader :import, :user_id, :file_path def initialize(import, user_id, file_path = nil) @@ -17,13 +18,46 @@ class Geojson::Importer json = load_json_data data = Geojson::Params.new(json).call - data.each.with_index(1) do |point, index| + points_data = data.map do |point| next if point[:lonlat].nil? - next if point_exists?(point, user_id) - Point.create!(point.merge(user_id:, import_id: import.id)) + point.merge( + user_id: user_id, + import_id: import.id, + created_at: Time.current, + updated_at: Time.current + ) + end - broadcast_import_progress(import, index) + points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index| + bulk_insert_points(batch) + broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE) end end + + private + + def bulk_insert_points(batch) + unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + create_notification("Failed to process GeoJSON batch: #{e.message}") + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'GeoJSON Import Error', + content: message, + kind: :error + ) + end end diff --git a/app/services/google_maps/phone_takeout_importer.rb b/app/services/google_maps/phone_takeout_importer.rb index 51cfda5c..4e74bc54 100644 --- a/app/services/google_maps/phone_takeout_importer.rb +++ b/app/services/google_maps/phone_takeout_importer.rb @@ -12,30 +12,23 @@ class GoogleMaps::PhoneTakeoutImporter @file_path = file_path end + BATCH_SIZE = 1000 + def call - points_data = parse_json - - points_data.compact.each.with_index(1) do |point_data, index| - next if Point.exists?( - timestamp: point_data[:timestamp], - lonlat: point_data[:lonlat], - user_id: - ) - - Point.create( - lonlat: point_data[:lonlat], - timestamp: point_data[:timestamp], - raw_data: point_data[:raw_data], - accuracy: point_data[:accuracy], - altitude: point_data[:altitude], - velocity: point_data[:velocity], - import_id: import.id, - topic: 'Google Maps Phone Timeline Export', + points_data = parse_json.compact.map do |point_data| + point_data.merge( + import_id: import.id, + topic: 'Google Maps Phone Timeline Export', tracker_id: 'google-maps-phone-timeline-export', - user_id: + user_id: user_id, + created_at: Time.current, + updated_at: Time.current ) + end - broadcast_import_progress(import, index) + points_data.each_slice(BATCH_SIZE).with_index do |batch, batch_index| + bulk_insert_points(batch) + broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE) end end @@ -177,4 +170,28 @@ class GoogleMaps::PhoneTakeoutImporter point_hash(lat, lon, timestamp, segment) end end + + def bulk_insert_points(batch) + unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + create_notification("Failed to process phone takeout batch: #{e.message}") + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'Google Maps Phone Takeout Import Error', + content: message, + kind: :error + ) + end end diff --git a/app/services/google_maps/records_importer.rb b/app/services/google_maps/records_importer.rb index 3cecb1bd..3986414d 100644 --- a/app/services/google_maps/records_importer.rb +++ b/app/services/google_maps/records_importer.rb @@ -32,6 +32,10 @@ class GoogleMaps::RecordsImporter timestamp: parse_timestamp(location), altitude: location['altitude'], velocity: location['velocity'], + accuracy: location['accuracy'], + vertical_accuracy: location['verticalAccuracy'], + course: location['heading'], + battery: parse_battery_charging(location['batteryCharging']), raw_data: location, topic: 'Google Maps Timeline Export', tracker_id: 'google-maps-timeline-export', @@ -74,6 +78,12 @@ class GoogleMaps::RecordsImporter ) end + def parse_battery_charging(battery_charging) + return nil if battery_charging.nil? + + battery_charging ? 1 : 0 + end + def create_notification(message) Notification.create!( user: @import.user, diff --git a/app/services/google_maps/semantic_history_importer.rb b/app/services/google_maps/semantic_history_importer.rb index e5eeb0b9..d24faa91 100644 --- a/app/services/google_maps/semantic_history_importer.rb +++ b/app/services/google_maps/semantic_history_importer.rb @@ -43,6 +43,7 @@ class GoogleMaps::SemanticHistoryImporter { lonlat: point_data[:lonlat], timestamp: point_data[:timestamp], + accuracy: point_data[:accuracy], raw_data: point_data[:raw_data], topic: 'Google Maps Timeline Export', tracker_id: 'google-maps-timeline-export', @@ -86,6 +87,7 @@ class GoogleMaps::SemanticHistoryImporter longitude: activity['startLocation']['longitudeE7'], latitude: activity['startLocation']['latitudeE7'], timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'], + accuracy: activity.dig('startLocation', 'accuracyMetres'), raw_data: activity ) end @@ -111,6 +113,7 @@ class GoogleMaps::SemanticHistoryImporter longitude: place_visit['location']['longitudeE7'], latitude: place_visit['location']['latitudeE7'], timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'], + accuracy: place_visit.dig('location', 'accuracyMetres'), raw_data: place_visit ) elsif (candidate = place_visit.dig('otherCandidateLocations', 0)) @@ -125,14 +128,16 @@ class GoogleMaps::SemanticHistoryImporter longitude: candidate['longitudeE7'], latitude: candidate['latitudeE7'], timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'], + accuracy: candidate['accuracyMetres'], raw_data: place_visit ) end - def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:) + def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:, accuracy: nil) { lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})", timestamp: Timestamps.parse_timestamp(timestamp), + accuracy: accuracy, raw_data: raw_data } end diff --git a/app/services/photos/importer.rb b/app/services/photos/importer.rb index e307b6b1..48775ca6 100644 --- a/app/services/photos/importer.rb +++ b/app/services/photos/importer.rb @@ -4,6 +4,8 @@ class Photos::Importer include Imports::Broadcaster include Imports::FileLoader include PointValidation + + BATCH_SIZE = 1000 attr_reader :import, :user_id, :file_path def initialize(import, user_id, file_path = nil) @@ -14,25 +16,54 @@ class Photos::Importer def call json = load_json_data + points_data = json.map { |point| prepare_point_data(point) } - json.each.with_index(1) { |point, index| create_point(point, index) } + points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index| + bulk_insert_points(batch) + broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE) + end end - def create_point(point, index) - return 0 unless valid?(point) - return 0 if point_exists?(point, point['timestamp']) + private - Point.create( - lonlat: point['lonlat'], + def prepare_point_data(point) + return nil unless valid?(point) + + { + lonlat: point['lonlat'], longitude: point['longitude'], - latitude: point['latitude'], + latitude: point['latitude'], timestamp: point['timestamp'].to_i, - raw_data: point, + raw_data: point, import_id: import.id, - user_id: - ) + user_id: user_id, + created_at: Time.current, + updated_at: Time.current + } + end - broadcast_import_progress(import, index) + def bulk_insert_points(batch) + unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + create_notification("Failed to process photo location batch: #{e.message}") + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'Photos Import Error', + content: message, + kind: :error + ) end def valid?(point) diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb index 43b6fcac..fb7740a6 100644 --- a/app/services/users/safe_settings.rb +++ b/app/services/users/safe_settings.rb @@ -19,7 +19,8 @@ class Users::SafeSettings 'photoprism_url' => nil, 'photoprism_api_key' => nil, 'maps' => { 'distance_unit' => 'km' }, - 'visits_suggestions_enabled' => 'true' + 'visits_suggestions_enabled' => 'true', + 'enabled_map_layers' => ['Routes', 'Heatmap'] }.freeze def initialize(settings = {}) @@ -47,7 +48,8 @@ class Users::SafeSettings distance_unit: distance_unit, visits_suggestions_enabled: visits_suggestions_enabled?, speed_color_scale: speed_color_scale, - fog_of_war_threshold: fog_of_war_threshold + fog_of_war_threshold: fog_of_war_threshold, + enabled_map_layers: enabled_map_layers } end # rubocop:enable Metrics/MethodLength @@ -127,4 +129,8 @@ class Users::SafeSettings def fog_of_war_threshold settings['fog_of_war_threshold'] end + + def enabled_map_layers + settings['enabled_map_layers'] + end end diff --git a/app/views/devise/registrations/_api_key.html.erb b/app/views/devise/registrations/_api_key.html.erb index 37daa7fd..b8396764 100644 --- a/app/views/devise/registrations/_api_key.html.erb +++ b/app/views/devise/registrations/_api_key.html.erb @@ -28,6 +28,6 @@

<%= 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' %> + <%= link_to "Generate new API key", generate_api_key_path, data: { turbo_confirm: "Are you sure? This will invalidate the current API key.", turbo_method: :post }, class: 'btn btn-primary' %>

diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index eba1382b..257aba87 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -66,7 +66,7 @@ <%= render "devise/shared/links" %> <% end %> -

Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: 'btn' %>

+

Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: 'btn' %>

<%= link_to "Export my data", export_settings_users_path, class: 'btn btn-primary', data: { diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index bf654561..707d9cee 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -1,16 +1,38 @@

-

Register now!

-

and take control over your location data.

+ <% if @invitation %> +

Join <%= @invitation.family.name %>!

+

+ You've been invited by <%= @invitation.invited_by.email %> to join their family. + Create your account to accept the invitation and start sharing location data. +

+
+ + + + + Your email (<%= @invitation.email %>) will be used for this account + +
+ <% else %> +

Register now!

+

and take control over your location data.

+ <% end %>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <% if @invitation %> + <%= f.hidden_field :invitation_token, value: params[:invitation_token] %> + <% end %> +
<%= f.label :email, class: 'label' do %> Email <% end %> - <%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'input input-bordered' %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", + readonly: @invitation.present?, + class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
@@ -18,17 +40,17 @@ Password <% end %> <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) + (<%= @minimum_password_length %> characters minimum) <% end %>
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %>
<%= f.label :password_confirmation, class: 'label' do %> - Password + Password Confirmation <% end %> <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) + (<%= @minimum_password_length %> characters minimum) <% end %>
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %>
@@ -38,10 +60,13 @@ <% end %>
- <%= f.submit "Sign up", class: 'btn btn-primary' %> + <%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"), + class: 'btn btn-primary' %>
- <%= render "devise/shared/links" %> + <% unless @invitation %> + <%= render "devise/shared/links" %> + <% end %> <% end %>
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 1afd05ae..b471a5cf 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,16 +1,29 @@
-

Login now

-

and take control over your location data.

- <% if ENV['DEMO_ENV'] == 'true' %> -

- Demo account: demo@dawarich.app / password: password + <% if @invitation %> +

Sign in to join <%= @invitation.family.name %>!

+

+ You've been invited by <%= @invitation.invited_by.email %> to join their family. + Sign in to your account to accept the invitation.

+
+

+ Don't have an account yet? + <%= link_to "Create one here", new_user_registration_path(invitation_token: @invitation.token), class: "font-semibold underline" %> +

+
+ <% else %> +

Login now

+

and take control over your location data.

<% end %>
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <% if @invitation %> + <%= hidden_field_tag :invitation_token, params[:invitation_token] %> + <% end %> +
<%= f.label :email, class: 'label' do %> Email @@ -32,10 +45,12 @@ <% end %>
- <%= f.submit "Log in", class: 'btn btn-primary' %> + <%= f.submit (@invitation ? "Sign in & Accept Invitation" : "Log in"), class: 'btn btn-primary' %>
- <%= render "devise/shared/links" %> + <% unless @invitation %> + <%= render "devise/shared/links" %> + <% end %> <% end %>
diff --git a/app/views/exports/index.html.erb b/app/views/exports/index.html.erb index 25e94e5a..38761418 100644 --- a/app/views/exports/index.html.erb +++ b/app/views/exports/index.html.erb @@ -49,7 +49,7 @@ <%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %> <% end %> <% end %> - <%= link_to 'Delete', export, 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', export, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> <% end %> diff --git a/app/views/families/edit.html.erb b/app/views/families/edit.html.erb new file mode 100644 index 00000000..d06059c8 --- /dev/null +++ b/app/views/families/edit.html.erb @@ -0,0 +1,99 @@ +
+
+
+
+

+ <%= t('families.edit.title', default: 'Edit Family') %> +

+ <%= link_to family_path, + class: "btn btn-ghost" do %> + <%= t('families.edit.back', default: '← Back to Family') %> + <% end %> +
+ + <%= form_with model: @family, local: true, class: "space-y-6" do |form| %> + <% if @family.errors.any? %> +
+
+

+ <%= t('families.edit.error_title', default: 'There were problems with your submission:') %> +

+
+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %> + <%= form.text_field :name, + class: "input input-bordered w-full", + placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %> +

+ <%= t('families.edit.name_help', default: 'Choose a name that all family members will recognize.') %> +

+
+ +
+

+ <%= t('families.edit.family_info', default: 'Family Information') %> +

+
+
+
+ <%= t('families.edit.creator', default: 'Created by') %> +
+
<%= @family.creator.email %>
+
+
+
+ <%= t('families.edit.created_on', default: 'Created on') %> +
+
<%= @family.created_at.strftime('%B %d, %Y') %>
+
+
+
+ <%= t('families.edit.members_count', default: 'Members') %> +
+
+ <%= pluralize(@family.members.count, 'member') %> +
+
+
+
+ <%= t('families.edit.last_updated', default: 'Last updated') %> +
+
<%= @family.updated_at.strftime('%B %d, %Y') %>
+
+
+
+ +
+
+ <%= form.submit t('families.edit.save_changes', default: 'Save Changes'), + class: "btn btn-primary" %> + <%= link_to family_path, + class: "btn btn-neutral" do %> + <%= t('families.edit.cancel', default: 'Cancel') %> + <% end %> +
+ + <% if policy(@family).destroy? %> + <%= link_to family_path, + method: :delete, + data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, + class: "btn btn-outline btn-error" do %> + <%= icon 'trash-2', class: "inline-block w-4" %> + Delete Family + <% end %> + <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/families/index.html.erb b/app/views/families/index.html.erb new file mode 100644 index 00000000..9a4443e3 --- /dev/null +++ b/app/views/families/index.html.erb @@ -0,0 +1,47 @@ +
+
+
+

+ <%= t('families.index.title', default: 'Family Management') %> +

+

+ <%= t('families.index.description', default: 'Create or join a family to share your location data with loved ones.') %> +

+
+ +
+

+ <%= t('families.index.create_family', default: 'Create Your Family') %> +

+ + <%= form_with model: Family.new, local: true, class: "space-y-4" do |form| %> +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-1" %> + <%= form.text_field :name, + placeholder: t('families.form.name_placeholder', default: 'Enter your family name'), + class: "input input-bordered w-full" %> +
+ +
+ <%= form.submit t('families.form.create', default: 'Create Family'), + class: "btn btn-primary" %> +
+ <% end %> +
+ +
+

+ <%= t('families.index.have_invitation', default: 'Have an invitation?') %> +

+

+ <%= t('families.index.invitation_instructions', default: 'If someone has invited you to join their family, you should have received an email with an invitation link.') %> +

+
+ <%= t('families.index.invitation_help', default: 'Check your email for an invitation link that looks like: ') %> + + <%= "#{request.base_url}/invitations/..." %> + +
+
+
+
\ No newline at end of file diff --git a/app/views/families/new.html.erb b/app/views/families/new.html.erb new file mode 100644 index 00000000..c9350dfd --- /dev/null +++ b/app/views/families/new.html.erb @@ -0,0 +1,66 @@ +
+
+
+

+ <%= t('families.new.title', default: 'Create Your Family') %> +

+

+ <%= t('families.new.description', default: 'Create a family to share your location data with your loved ones.') %> +

+
+ +
+ <%= form_with url: family_path, model: @family, local: true, class: "space-y-6" do |form| %> + <% if @family.errors.any? %> +
+
+

+ <%= t('families.new.error_title', default: 'There were problems with your submission:') %> +

+
+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %> + <%= form.text_field :name, + class: "input input-bordered w-full", + placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %> +

+ <%= t('families.new.name_help', default: 'Choose a name that all family members will recognize, like "The Smith Family" or "Our Travel Group".') %> +

+
+ +
+
+

+ <%= t('families.new.what_happens_title', default: 'What happens next?') %> +

+
    +
  • • <%= t('families.new.what_happens_1', default: 'You will become the family owner') %>
  • +
  • • <%= t('families.new.what_happens_2', default: 'You can invite others to join your family') %>
  • +
  • • <%= t('families.new.what_happens_3', default: 'Family members can view shared location data') %>
  • +
  • • <%= t('families.new.what_happens_4', default: 'You can manage family settings and members') %>
  • +
+
+
+ +
+ <%= form.submit t('families.new.create_family', default: 'Create Family'), + class: "btn btn-primary" %> + <%= link_to root_path, + class: "btn btn-ghost" do %> + <%= t('families.new.back', default: '← Back') %> + <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/families/show.html.erb b/app/views/families/show.html.erb new file mode 100644 index 00000000..19c466d9 --- /dev/null +++ b/app/views/families/show.html.erb @@ -0,0 +1,259 @@ +
+
+ +
+
+
+

<%= @family.name %>

+

+ <%= t('families.show.created_by', default: 'Created by') %> + <%= @family.creator.email %> + <%= t('families.show.on_date', default: 'on') %> + <%= @family.created_at.strftime('%B %d, %Y') %> +

+
+ +
+ <% if policy(@family).update? %> + <%= link_to edit_family_path, + class: "btn btn-outline btn-info" do %> + <%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %> + <% end %> + <% end %> + + <% if !current_user.family_owner? && current_user.family_membership %> + <%= link_to family_member_path(current_user.family_membership), + method: :delete, + data: { turbo_confirm: 'Are you sure you want to leave this family?' }, + class: "btn btn-outline btm-sm btn-warning" do %> + Leave Family + <% end %> + <% end %> + + <% if policy(@family).destroy? %> + <%= link_to family_path, + method: :delete, + data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, + class: "btn btn-outline btm-sm btn-error" do %> + <%= icon 'trash-2', class: "inline-block w-4" %> + Delete + <% end %> + <% end %> +
+
+
+ + +
+
+

+ <%= t('families.show.members_title', default: 'Family Members') %> + (<%= @members.count %>) +

+
+ +
+ <% @members.each do |member| %> +
+
+
+
+ +
+
+
+ + <%= member.email&.first&.upcase || '?' %> + +
+
+ +
+

<%= member.email %>

+
+ <% if member.family_membership.role == 'owner' %> +
+ <%= t('families.show.owner_badge', default: 'Owner') %> +
+ <% else %> + + <%= member.family_membership.role.humanize %> + + <% end %> +
+
+ <%= t('families.show.joined_on', default: 'Joined') %> + <%= member.family_membership.created_at.strftime('%b %d, %Y') %> +
+
+
+
+ + +
+ <% if member == current_user %> + +
+ + Location sharing: + + + + data-location-sharing-toggle-target="checkbox" + data-action="change->location-sharing-toggle#toggle"> + + +
+ +
+ + + <% if member.family_sharing_enabled? && member.family_sharing_expires_at.present? %> +
+ • Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now +
+ <% end %> +
+ + <% else %> + +
+ Location sharing: + <% if member.family_sharing_enabled? %> +
+ + <%= member.family_sharing_duration == 'permanent' ? 'Always' : member.family_sharing_duration&.upcase %> + + <% if member.family_sharing_expires_at.present? %> + + • Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now + + <% end %> + <% else %> +
+ Disabled + <% end %> +
+ <% end %> +
+
+
+
+ <% end %> +
+
+ + +
+
+

+ <%= t('families.show.invitations_title', default: 'Pending Invitations') %> + (<%= @pending_invitations.count %>) +

+
+ + <% if @pending_invitations.any? %> +
+ <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email %>
+
+ <%= t('families.show.invited_on', default: 'Invited') %> + <%= invitation.created_at.strftime('%b %d, %Y') %> +
+
+ <%= t('families.show.expires_on', default: 'Expires') %> + <%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %> +
+
+ +
+
+ <% if policy(@family).manage_invitations? %> +
+ <%= link_to family_invitation_path(invitation.token), + method: :delete, + data: { turbo_confirm: 'Are you sure you want to cancel this invitation?' }, + class: "btn btn-outline btn-warning btn-sm" do %> + Cancel + <% end %> +
+ <% end %> +
+ <% end %> +
+ <% else %> +

+ <%= t('families.show.no_pending_invitations', default: 'No pending invitations') %> +

+ <% end %> + + + <% if policy(@family).invite? && @family.can_add_members? %> +
+

+ <%= t('families.show.invite_member', default: 'Invite New Member') %> +

+ + <%= form_with model: [@family, Family::Invitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %> +
+ <%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "label label-text font-medium mb-1" %> + <%= form.email_field :email, + placeholder: t('families.show.email_placeholder', default: 'Enter email address'), + class: "input input-bordered w-full" %> +
+ +
+ <%= form.submit t('families.show.send_invitation', default: 'Send Invitation'), + class: "btn btn-primary" %> +
+ <% end %> +
+ <% elsif policy(@family).invite? %> + +
+
+ <%= icon 'triangle-alert', class: "inline-block w-6 mr-2 flex-shrink-0" %> +
+

+ Family at Capacity +

+
+

+ Your family has reached the maximum of <%= @family.class::MAX_MEMBERS %> members (including pending invitations). + Cancel existing invitations or wait for them to expire to invite new members. +

+
+
+
+
+ <% end %> +
+
+
diff --git a/app/views/family/invitations/index.html.erb b/app/views/family/invitations/index.html.erb new file mode 100644 index 00000000..b0af0893 --- /dev/null +++ b/app/views/family/invitations/index.html.erb @@ -0,0 +1,68 @@ +
+
+
+
+

+ <%= t('family_invitations.index.title', default: 'Family Invitations') %> +

+ <%= link_to family_path, + class: "btn btn-neutral" do %> + <%= t('family_invitations.index.back_to_family', default: 'Back to Family') %> + <% end %> +
+ + <% if @pending_invitations.any? %> +
+ <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email %>
+
+ <%= t('family_invitations.index.invited_on', default: 'Invited') %> + <%= invitation.created_at.strftime('%B %d, %Y') %> +
+
+ <%= t('family_invitations.index.expires_on', default: 'Expires') %> + <%= invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %> +
+
+ +
+ + + <%= link_to public_invitation_path(invitation.token), + class: "btn btn-ghost btn-sm text-info" do %> + <%= t('family_invitations.index.view_invitation', default: 'View') %> + <% end %> + + <% if policy(@family).manage_invitations? %> + <%= link_to family_invitation_path(invitation.token), + method: :delete, + confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'), + class: "btn btn-ghost btn-sm text-error" do %> + <%= t('family_invitations.index.cancel', default: 'Cancel') %> + <% end %> + <% end %> +
+
+ <% end %> +
+ <% else %> +
+

+ <%= t('family_invitations.index.no_invitations', default: 'No pending invitations') %> +

+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/family/invitations/show.html.erb b/app/views/family/invitations/show.html.erb new file mode 100644 index 00000000..414c9f29 --- /dev/null +++ b/app/views/family/invitations/show.html.erb @@ -0,0 +1,164 @@ +
+
+ +
+
+ <%= icon 'users', class: "h-12 w-12 text-primary-content" %> +
+ +

+ Join <%= @invitation.family.name %>! +

+ +

+ You've been invited by <%= @invitation.invited_by.email %> to join their family. Create your account to accept the invitation and start sharing location data. +

+ +
+ <%= icon 'info', class: "h-5 w-5 shrink-0" %> + + Your email (<%= @invitation.email %>) will be used for this account + +
+
+ + +
+

+ What benefits does joining a family bring? +

+ +
+
+
+
+ <%= icon 'map-pin', class: "h-6 w-6 text-info-content" %> +
+
+
+

+ Share Location Data +

+

+ Share your location history with family members and see where they are +

+
+
+ +
+
+
+ <%= icon 'chart-column', class: "h-6 w-6 text-secondary-content" %> +
+
+
+

+ Track your location history +

+

+ Access interactive maps and personal travel statistics +

+
+
+ +
+
+
+ <%= icon 'heart', class: "h-6 w-6 text-success-content" %> +
+
+
+

+ Stay Connected +

+

+ Keep track of your loved ones' travels and adventures in real-time +

+
+
+ +
+
+
+ <%= icon 'shield-check', class: "h-6 w-6 text-warning-content" %> +
+
+
+

+ Full Control & Privacy +

+

+ You control what and how long you share and can leave the family anytime +

+
+
+
+ + +
+

Invitation Details

+
+
+
Family:
+
<%= @invitation.family.name %>
+
+
+
Invited by:
+
<%= @invitation.invited_by.email %>
+
+
+
Your email:
+
<%= @invitation.email %>
+
+
+
Expires:
+
<%= @invitation.expires_at.strftime('%b %d, %Y') %>
+
+
+
+ + +
+ <% if user_signed_in? %> + + <%= link_to accept_family_invitation_path(token: @invitation.token), + method: :post, + class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %> + ✓ Accept Invitation & Join Family + <% end %> + +

+ Logged in as <%= current_user.email %> + · + <%= link_to destroy_user_session_path, method: :delete, class: "link link-info" do %> + Logout + <% end %> +

+ <% else %> + + <%= link_to new_user_registration_path(invitation_token: @invitation.token), + class: "btn btn-primary btn-lg w-full text-lg shadow-lg" do %> + Create Account & Join Family → + <% end %> + +
+

+ Already have an account? +

+ <%= link_to new_user_session_path(invitation_token: @invitation.token), + class: "link link-info font-medium" do %> + Sign in to accept invitation + <% end %> +
+ <% end %> + + +
+

+ Not interested? You can simply close this page. +

+
+
+
+
+
diff --git a/app/views/family_mailer/invitation.html.erb b/app/views/family_mailer/invitation.html.erb new file mode 100644 index 00000000..7a469b53 --- /dev/null +++ b/app/views/family_mailer/invitation.html.erb @@ -0,0 +1,48 @@ +
+
+

You've been invited to join a family!

+ +

Hi there!

+ +

+ <%= @invited_by.email %> has invited you to join their family + "<%= @family.name %>" on Dawarich. +

+ +
+

By joining this family, you'll be able to:

+
    +
  • Share your current location with family members
  • +
  • See the current location of other family members
  • +
  • Stay connected with your loved ones
  • +
  • Control your privacy with full sharing controls
  • +
+
+ +
+ <%= link_to "Accept Invitation", @accept_url, + style: "background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 600;" %> +
+ +
+

+ ⏰ Important: This invitation will expire in 7 days. +

+
+ +

+ If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation. +

+ +

+ If you didn't expect this invitation, you can safely ignore this email. +

+ +
+ +

+ Best regards,
+ Evgenii from Dawarich +

+
+
diff --git a/app/views/family_mailer/invitation.text.erb b/app/views/family_mailer/invitation.text.erb new file mode 100644 index 00000000..cd9b266c --- /dev/null +++ b/app/views/family_mailer/invitation.text.erb @@ -0,0 +1,22 @@ +You've been invited to join a family! + +Hi there! + +<%= @invited_by.email %> has invited you to join their family "<%= @family.name %>" on Dawarich. + +By joining this family, you'll be able to: +• Share your current location with family members +• See the current location of other family members +• Stay connected with your loved ones +• Control your privacy with full sharing controls + +Accept your invitation here: <%= @accept_url %> + +IMPORTANT: This invitation will expire in 7 days. + +If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation. + +If you didn't expect this invitation, you can safely ignore this email. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/family_mailer/member_joined.html.erb b/app/views/family_mailer/member_joined.html.erb new file mode 100644 index 00000000..f2429b92 --- /dev/null +++ b/app/views/family_mailer/member_joined.html.erb @@ -0,0 +1,39 @@ +
+
+

🎉 Great news! Someone joined your family!

+ +

Hi <%= @family.owner.email %>!

+ +

+ We're excited to let you know that <%= @user.email %> has just joined your family + "<%= @family.name %>" on Dawarich! +

+ +
+

Now you can:

+
    +
  • See <%= @user.email %>'s current location (if they've enabled sharing)
  • +
  • Stay connected with your growing family
  • +
  • Share your location with <%= @user.email %>
  • +
  • Manage family members and settings from your family page
  • +
+
+ +
+

+ 💡 Tip: You can manage your family members and privacy settings at any time from your family dashboard. +

+
+ +

+ Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>. +

+ +
+ +

+ Best regards,
+ Evgenii from Dawarich +

+
+
diff --git a/app/views/family_mailer/member_joined.text.erb b/app/views/family_mailer/member_joined.text.erb new file mode 100644 index 00000000..ba840d38 --- /dev/null +++ b/app/views/family_mailer/member_joined.text.erb @@ -0,0 +1,18 @@ +Great news! Someone joined your family! + +Hi <%= @family.owner.email %>! + +We're excited to let you know that <%= @user.email %> has just joined your family "<%= @family.name %>" on Dawarich! + +Now you can: +• See <%= @user.email %>'s current location (if they've enabled sharing) +• Stay connected with your growing family +• Share your location with <%= @user.email %> +• Manage family members and settings from your family page + +TIP: You can manage your family members and privacy settings at any time from your family dashboard. + +Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index cfcf0bef..8cf60feb 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -6,12 +6,12 @@ <%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> <% if current_user.safe_settings.immich_url && current_user.safe_settings.immich_api_key %> - <%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> + <%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> <% else %> Import Immich data <% end %> <% if current_user.safe_settings.photoprism_url && current_user.safe_settings.photoprism_api_key %> - <%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> + <%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> <% else %> Import Photoprism data <% end %> @@ -75,7 +75,7 @@ <% if import.file.present? %> <%= 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: "btn btn-outline btn-sm btn-error" %> + <%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %> <% end %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index 41fe784c..c92eeb2e 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -10,7 +10,7 @@ <%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content inline-block font-medium" %>
- <%= link_to "Destroy this import", import_path(@import), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This action will delete all points imported with this file", turbo_method: :delete }, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content font-medium" %> + <%= link_to "Destroy this import", import_path(@import), data: { turbo_confirm: "Are you sure? This action will delete all points imported with this file", turbo_method: :delete }, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content font-medium" %>
<%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-secondary-content inline-block font-medium" %>
diff --git a/app/views/layouts/map.html.erb b/app/views/layouts/map.html.erb new file mode 100644 index 00000000..c53260c2 --- /dev/null +++ b/app/views/layouts/map.html.erb @@ -0,0 +1,52 @@ + + + + <%= full_title(yield(:title)) %> + + <%= action_cable_meta_tag %> + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + <%= javascript_include_tag "https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js" %> + + <%= render 'application/favicon' %> + <%= Sentry.get_trace_propagation_meta.html_safe if Sentry.initialized? %> + <% if !DawarichSettings.self_hosted? %> + + <% end %> + + + + +
+
+ <%= render 'shared/navbar' %> +
+
+ + +
+
+ <%= render 'shared/flash' %> +
+
+ + +
+ <%= yield %> +
+ + + + + <%= render 'map/onboarding_modal' %> + + diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 412741cd..40a58ec0 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -1,85 +1,99 @@ <% content_for :title, 'Map' %> -
-
-
- <%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %> -
-
-
- - <%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %> - ◀️ - <% end %> - -
-
-
-
- <%= f.label :start_at, class: "text-sm font-semibold" %> - <%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %> -
-
-
-
- <%= f.label :end_at, class: "text-sm font-semibold" %> - <%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %> -
-
-
-
- - <%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %> - ▶️ - <% end %> - -
-
-
-
- <%= f.submit "Search", class: "btn btn-primary hover:btn-info" %> -
-
-
-
- <%= link_to "Today", - map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), - class: "btn border border-base-300 hover:btn-ghost" %> -
-
-
-
- <%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %> -
-
-
-
- <%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %> -
+ +
+
+ +
+ +
+ + + +
+ + +
+
+
diff --git a/app/views/notifications/show.html.erb b/app/views/notifications/show.html.erb index b2097882..20f51df7 100644 --- a/app/views/notifications/show.html.erb +++ b/app/views/notifications/show.html.erb @@ -5,7 +5,7 @@
<%= link_to "Back to notifications", notifications_path, class: "btn btn-small" %>
- <%= button_to "Destroy this notification", @notification, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-small btn-warning" %> + <%= button_to "Destroy this notification", @notification, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-small btn-warning" %>
diff --git a/app/views/places/index.html.erb b/app/views/places/index.html.erb index fd899884..12195a81 100644 --- a/app/views/places/index.html.erb +++ b/app/views/places/index.html.erb @@ -41,7 +41,7 @@ <%= human_datetime(place.created_at) %> <%= "#{place.lat}, #{place.lon}" %> - <%= link_to 'Delete', place, data: { confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> + <%= link_to 'Delete', place, data: { turbo_confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> <% end %> diff --git a/app/views/points/index.html.erb b/app/views/points/index.html.erb index 098d9860..66c1bf0b 100644 --- a/app/views/points/index.html.erb +++ b/app/views/points/index.html.erb @@ -28,12 +28,12 @@
- <%= link_to 'Export as GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :json), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "btn border border-base-300 hover:btn-ghost" %> + <%= link_to 'Export as GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :json), data: { turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "btn border border-base-300 hover:btn-ghost" %>
- <%= link_to 'Export as GPX', exports_path(start_at: @start_at, end_at: @end_at, file_format: :gpx), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "btn border border-base-300 hover:btn-ghost" %> + <%= link_to 'Export as GPX', exports_path(start_at: @start_at, end_at: @end_at, file_format: :gpx), data: { turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "btn border border-base-300 hover:btn-ghost" %>
@@ -47,7 +47,7 @@
<%= form_with url: bulk_destroy_points_path(params.permit!), method: :delete, id: :bulk_destroy_form do |f| %>
- <%= f.submit "Delete Selected", class: "px-4 py-2 bg-red-500 text-white rounded-md", data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", checkbox_select_all_target: "deleteButton" }, style: "display: none;" %> + <%= f.submit "Delete Selected", class: "px-4 py-2 bg-red-500 text-white rounded-md", data: { turbo_confirm: "Are you sure?", checkbox_select_all_target: "deleteButton" }, style: "display: none;" %>
<%= page_entries_info @points, entry_name: 'point' %>
diff --git a/app/views/settings/background_jobs/index.html.erb b/app/views/settings/background_jobs/index.html.erb index 22813e2a..f9adcb00 100644 --- a/app/views/settings/background_jobs/index.html.erb +++ b/app/views/settings/background_jobs/index.html.erb @@ -25,7 +25,7 @@

Start Reverse Geocoding

This job will re-run reverse geocoding process for all the points in your database. Might take a few days or even weeks based on the amount of points you have!

- <%= link_to 'Start Job', settings_background_jobs_path(job_name: 'start_reverse_geocoding'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'btn btn-primary' %> + <%= link_to 'Start Job', settings_background_jobs_path(job_name: 'start_reverse_geocoding'), method: :post, data: { turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'btn btn-primary' %>
@@ -35,7 +35,7 @@

Continue Reverse Geocoding

This job will process reverse geocoding for all points that don't have geocoding data yet.

- <%= link_to 'Start Job', settings_background_jobs_path(job_name: 'continue_reverse_geocoding'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'btn btn-primary' %> + <%= link_to 'Start Job', settings_background_jobs_path(job_name: 'continue_reverse_geocoding'), method: :post, data: { turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'btn btn-primary' %>
@@ -56,9 +56,9 @@

Enable or disable visits suggestions. It's a background task that runs every day at midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.

<% if current_user.safe_settings.visits_suggestions_enabled? %> - <%= link_to 'Disable', settings_path(settings: { 'visits_suggestions_enabled' => 'false' }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-error' %> + <%= link_to 'Disable', settings_path(settings: { 'visits_suggestions_enabled' => 'false' }), method: :patch, data: { turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-error' %> <% else %> - <%= link_to 'Enable', settings_path(settings: { 'visits_suggestions_enabled' => 'true' }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-success' %> + <%= link_to 'Enable', settings_path(settings: { 'visits_suggestions_enabled' => 'true' }), method: :patch, data: { turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-success' %> <% end %>
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 08166411..876e8d5e 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -1,12 +1,18 @@
<% flash.each do |key, value| %>
-
<%= value %>
- - diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 12b3d3da..1be2eef2 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -1,4 +1,4 @@ - - <% if DawarichSettings.reverse_geocoding_enabled? %> - <%= render 'stats/reverse_geocoding_stats' %> - <% end %> -
+ <% if DawarichSettings.reverse_geocoding_enabled? %> + <%= render 'stats/reverse_geocoding_stats' %> + <% end %>
All stats data above except for total distance and number of geopoints tracked is being updated daily diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb index e852acc8..2d1420af 100644 --- a/app/views/trips/_form.html.erb +++ b/app/views/trips/_form.html.erb @@ -18,7 +18,7 @@ class="w-full h-full rounded-lg" data-trips-target="container" data-api_key="<%= current_user.api_key %>" - data-user_settings="<%= current_user.settings.to_json %>" + data-user_settings="<%= current_user.safe_settings.settings.to_json %>" data-path="<%= trip.path.to_json %>" data-started_at="<%= trip.started_at %>" data-ended_at="<%= trip.ended_at %>" diff --git a/app/views/trips/_path.html.erb b/app/views/trips/_path.html.erb index f3eeb15e..eb0679d2 100644 --- a/app/views/trips/_path.html.erb +++ b/app/views/trips/_path.html.erb @@ -5,7 +5,7 @@ data-controller="trips" data-trips-target="container" data-api_key="<%= trip.user.api_key %>" - data-user_settings="<%= trip.user.settings.to_json %>" + data-user_settings="<%= trip.user.safe_settings.settings.to_json %>" data-path="<%= trip.path.coordinates.to_json %>" data-started_at="<%= trip.started_at %>" data-ended_at="<%= trip.ended_at %>" diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb index 4f6fb95b..87a226b5 100644 --- a/app/views/trips/_trip.html.erb +++ b/app/views/trips/_trip.html.erb @@ -15,7 +15,7 @@ data-trip-map-trip-id-value="<%= trip.id %>" data-trip-map-path-value="<%= trip.path.coordinates.to_json %>" data-trip-map-api-key-value="<%= current_user.api_key %>" - data-trip-map-user-settings-value="<%= current_user.settings.to_json %>" + data-trip-map-user-settings-value="<%= current_user.safe_settings.settings.to_json %>" data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>">
diff --git a/config/importmap.rb b/config/importmap.rb index badf814e..53ca7e84 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -23,5 +23,6 @@ pin 'leaflet-draw' # @1.0.4 pin 'notifications_channel', to: 'channels/notifications_channel.js' pin 'points_channel', to: 'channels/points_channel.js' pin 'imports_channel', to: 'channels/imports_channel.js' +pin 'family_locations_channel', to: 'channels/family_locations_channel.js' pin 'trix' pin '@rails/actiontext', to: 'actiontext.esm.js' diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb index 08cb0785..89a49267 100644 --- a/config/initializers/03_dawarich_settings.rb +++ b/config/initializers/03_dawarich_settings.rb @@ -39,9 +39,14 @@ class DawarichSettings @store_geodata ||= STORE_GEODATA end + def family_feature_enabled? + @family_feature_enabled ||= self_hosted? + end + def features @features ||= { - reverse_geocoding: reverse_geocoding_enabled? + reverse_geocoding: reverse_geocoding_enabled?, + family: family_feature_enabled? } end end diff --git a/config/routes.rb b/config/routes.rb index bc73947c..d34aa775 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,20 @@ Rails.application.routes.draw do resources :places, only: %i[index destroy] resources :exports, only: %i[index create destroy] resources :trips + + # Family management routes (only if feature is enabled) + if DawarichSettings.family_feature_enabled? + resource :family, only: %i[show new create edit update destroy] do + patch :update_location_sharing, on: :member + + resources :invitations, except: %i[edit update], controller: 'family/invitations' + resources :members, only: %i[destroy], controller: 'family/memberships' + end + + get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation + post 'family/memberships', to: 'family/memberships#create', as: :accept_family_invitation + end + resources :points, only: %i[index] do collection do delete :bulk_destroy @@ -87,15 +101,10 @@ Rails.application.routes.draw do get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success - if SELF_HOSTED - devise_for :users, skip: [:registrations] - as :user do - get 'users/edit' => 'devise/registrations#edit', :as => 'edit_user_registration' - put 'users' => 'devise/registrations#update', :as => 'user_registration' - end - else - devise_for :users - end + devise_for :users, controllers: { + registrations: 'users/registrations', + sessions: 'users/sessions' + } resources :metrics, only: [:index] @@ -157,6 +166,12 @@ Rails.application.routes.draw do end end + resources :families, only: [] do + collection do + get :locations + end + end + post 'subscriptions/callback', to: 'subscriptions#callback' end end diff --git a/config/schedule.yml b/config/schedule.yml index cb0c94e7..ae920927 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -44,3 +44,8 @@ nightly_reverse_geocoding_job: cron: "15 1 * * *" # every day at 01:15 class: "Points::NightlyReverseGeocodingJob" queue: reverse_geocoding + +nightly_family_invitations_cleanup_job: + cron: "30 2 * * *" # every day at 02:30 + class: "Family::Invitations::CleanupJob" + queue: family diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 780bbc1c..5f2e133e 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -5,6 +5,7 @@ - points - default - mailers + - families - imports - exports - stats diff --git a/db/migrate/20250926220114_create_families.rb b/db/migrate/20250926220114_create_families.rb new file mode 100644 index 00000000..cbaeaf25 --- /dev/null +++ b/db/migrate/20250926220114_create_families.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateFamilies < ActiveRecord::Migration[8.0] + def change + create_table :families do |t| + t.string :name, null: false, limit: 50 + t.bigint :creator_id, null: false + t.timestamps + end + + add_foreign_key :families, :users, column: :creator_id, validate: false + add_index :families, :creator_id + end +end diff --git a/db/migrate/20250926220135_create_family_memberships.rb b/db/migrate/20250926220135_create_family_memberships.rb new file mode 100644 index 00000000..fa8e051a --- /dev/null +++ b/db/migrate/20250926220135_create_family_memberships.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateFamilyMemberships < ActiveRecord::Migration[8.0] + def change + create_table :family_memberships do |t| + t.bigint :family_id, null: false + t.bigint :user_id, null: false + t.integer :role, null: false, default: 1 # member + t.timestamps + end + + add_foreign_key :family_memberships, :families, validate: false + add_foreign_key :family_memberships, :users, validate: false + add_index :family_memberships, :user_id, unique: true # One family per user + add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role' + end +end diff --git a/db/migrate/20250926220158_create_family_invitations.rb b/db/migrate/20250926220158_create_family_invitations.rb new file mode 100644 index 00000000..be841652 --- /dev/null +++ b/db/migrate/20250926220158_create_family_invitations.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CreateFamilyInvitations < ActiveRecord::Migration[8.0] + def change + create_table :family_invitations do |t| + t.bigint :family_id, null: false + t.string :email, null: false + t.string :token, null: false + t.datetime :expires_at, null: false + t.bigint :invited_by_id, null: false + t.integer :status, null: false, default: 0 # pending + t.timestamps + end + + add_foreign_key :family_invitations, :families, validate: false + add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false + add_index :family_invitations, :token, unique: true + add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email' + add_index :family_invitations, %i[family_id status expires_at], + name: 'index_family_invitations_on_family_status_expires' + add_index :family_invitations, %i[status expires_at], + name: 'index_family_invitations_on_status_and_expires_at' + add_index :family_invitations, %i[status updated_at], + name: 'index_family_invitations_on_status_and_updated_at' + end +end diff --git a/db/migrate/20250926220345_validate_family_foreign_keys.rb b/db/migrate/20250926220345_validate_family_foreign_keys.rb new file mode 100644 index 00000000..45461b79 --- /dev/null +++ b/db/migrate/20250926220345_validate_family_foreign_keys.rb @@ -0,0 +1,9 @@ +class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :families, :users + validate_foreign_key :family_memberships, :families + validate_foreign_key :family_memberships, :users + validate_foreign_key :family_invitations, :families + validate_foreign_key :family_invitations, :users + end +end diff --git a/db/schema.rb b/db/schema.rb index d097aca9..c0f8d0cd 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_09_18_215512) do +ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -96,6 +96,41 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do t.index ["user_id"], name: "index_exports_on_user_id" end + create_table "families", force: :cascade do |t| + t.string "name", limit: 50, null: false + t.bigint "creator_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_families_on_creator_id" + end + + create_table "family_invitations", force: :cascade do |t| + t.bigint "family_id", null: false + t.string "email", null: false + t.string "token", null: false + t.datetime "expires_at", null: false + t.bigint "invited_by_id", null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_family_invitations_on_email" + t.index ["expires_at"], name: "index_family_invitations_on_expires_at" + t.index ["family_id"], name: "index_family_invitations_on_family_id" + t.index ["status"], name: "index_family_invitations_on_status" + t.index ["token"], name: "index_family_invitations_on_token", unique: true + end + + create_table "family_memberships", force: :cascade do |t| + t.bigint "family_id", null: false + t.bigint "user_id", null: false + t.integer "role", default: 1, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "role"], name: "index_family_memberships_on_family_id_and_role" + t.index ["family_id"], name: "index_family_memberships_on_family_id" + t.index ["user_id"], name: "index_family_memberships_on_user_id", unique: true + end + create_table "imports", force: :cascade do |t| t.string "name", null: false t.bigint "user_id", null: false @@ -307,6 +342,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "areas", "users" + add_foreign_key "families", "users", column: "creator_id", validate: false + add_foreign_key "family_invitations", "families", validate: false + add_foreign_key "family_invitations", "users", column: "invited_by_id", validate: false + add_foreign_key "family_memberships", "families", validate: false + add_foreign_key "family_memberships", "users", validate: false add_foreign_key "notifications", "users" add_foreign_key "place_visits", "places" add_foreign_key "place_visits", "visits" diff --git a/e2e/map.spec.js b/e2e/map.spec.js index 03fb59d2..1aac2601 100644 --- a/e2e/map.spec.js +++ b/e2e/map.spec.js @@ -525,7 +525,7 @@ test.describe('Map Functionality', () => { // Verify it's actually a clickable button with gear icon const buttonText = await settingsButton.textContent(); - expect(buttonText).toBe('⚙️'); + expect(buttonText).toBe(''); // Test opening settings panel await settingsButton.click(); diff --git a/spec/factories/families.rb b/spec/factories/families.rb new file mode 100644 index 00000000..9958a049 --- /dev/null +++ b/spec/factories/families.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family do + sequence(:name) { |n| "Test Family #{n}" } + association :creator, factory: :user + end +end diff --git a/spec/factories/family_invitations.rb b/spec/factories/family_invitations.rb new file mode 100644 index 00000000..41e71969 --- /dev/null +++ b/spec/factories/family_invitations.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family_invitation, class: 'Family::Invitation' do + association :family + association :invited_by, factory: :user + sequence(:email) { |n| "invite#{n}@example.com" } + token { SecureRandom.urlsafe_base64(32) } + expires_at { 7.days.from_now } + status { :pending } + + trait :accepted do + status { :accepted } + end + + trait :expired do + status { :expired } + expires_at { 1.day.ago } + end + + trait :cancelled do + status { :cancelled } + end + + trait :with_expired_date do + expires_at { 1.day.ago } + end + end +end diff --git a/spec/factories/family_memberships.rb b/spec/factories/family_memberships.rb new file mode 100644 index 00000000..0796c9af --- /dev/null +++ b/spec/factories/family_memberships.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family_membership, class: 'Family::Membership' do + association :family + association :user + role { :member } + + trait :owner do + role { :owner } + end + end +end diff --git a/spec/models/family/invitation_spec.rb b/spec/models/family/invitation_spec.rb new file mode 100644 index 00000000..2abd5db4 --- /dev/null +++ b/spec/models/family/invitation_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::Invitation, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:family) } + it { is_expected.to belong_to(:invited_by).class_name('User') } + end + + describe 'validations' do + subject { build(:family_invitation) } + + it { is_expected.to validate_presence_of(:email) } + it { is_expected.to allow_value('test@example.com').for(:email) } + it { is_expected.not_to allow_value('invalid-email').for(:email) } + it { is_expected.to validate_uniqueness_of(:token) } + it { is_expected.to validate_presence_of(:status) } + + it 'validates token presence after creation' do + invitation = build(:family_invitation, token: nil) + invitation.save + expect(invitation.token).to be_present + end + + it 'validates expires_at presence after creation' do + invitation = build(:family_invitation, expires_at: nil) + invitation.save + expect(invitation.expires_at).to be_present + end + end + + describe 'enums' do + it { is_expected.to define_enum_for(:status).with_values(pending: 0, accepted: 1, expired: 2, cancelled: 3) } + end + + describe 'scopes' do + let(:family) { create(:family) } + let(:pending_invitation) do + create(:family_invitation, family: family, status: :pending, expires_at: 1.day.from_now) + end + let(:expired_invitation) { create(:family_invitation, family: family, status: :pending, expires_at: 1.day.ago) } + let(:accepted_invitation) { create(:family_invitation, :accepted, family: family) } + + describe '.active' do + it 'returns only pending and non-expired invitations' do + expect(Family::Invitation.active).to include(pending_invitation) + expect(Family::Invitation.active).not_to include(expired_invitation) + expect(Family::Invitation.active).not_to include(accepted_invitation) + end + end + end + + describe 'callbacks' do + describe 'before_validation on create' do + let(:invitation) { build(:family_invitation, token: nil, expires_at: nil) } + + it 'generates a token' do + invitation.save + expect(invitation.token).to be_present + expect(invitation.token.length).to be > 20 + end + + it 'sets expiry date' do + invitation.save + expect(invitation.expires_at).to be_within(1.minute).of(Family::Invitation::EXPIRY_DAYS.days.from_now) + end + + it 'does not override existing token' do + custom_token = 'custom-token' + invitation.token = custom_token + invitation.save + expect(invitation.token).to eq(custom_token) + end + + it 'does not override existing expiry date' do + custom_expiry = 3.days.from_now + invitation.expires_at = custom_expiry + invitation.save + expect(invitation.expires_at).to be_within(1.second).of(custom_expiry) + end + end + end + + describe '#expired?' do + context 'when expires_at is in the future' do + let(:invitation) { create(:family_invitation, expires_at: 1.day.from_now) } + + it 'returns false' do + expect(invitation.expired?).to be false + end + end + + context 'when expires_at is in the past' do + let(:invitation) { create(:family_invitation, expires_at: 1.day.ago) } + + it 'returns true' do + expect(invitation.expired?).to be true + end + end + end + + describe '#can_be_accepted?' do + context 'when invitation is pending and not expired' do + let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.from_now) } + + it 'returns true' do + expect(invitation.can_be_accepted?).to be true + end + end + + context 'when invitation is pending but expired' do + let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.ago) } + + it 'returns false' do + expect(invitation.can_be_accepted?).to be false + end + end + + context 'when invitation is accepted' do + let(:invitation) { create(:family_invitation, :accepted, expires_at: 1.day.from_now) } + + it 'returns false' do + expect(invitation.can_be_accepted?).to be false + end + end + + context 'when invitation is cancelled' do + let(:invitation) { create(:family_invitation, :cancelled, expires_at: 1.day.from_now) } + + it 'returns false' do + expect(invitation.can_be_accepted?).to be false + end + end + end + + describe 'constants' do + it 'defines EXPIRY_DAYS' do + expect(Family::Invitation::EXPIRY_DAYS).to eq(7) + end + end + + describe 'token uniqueness' do + let(:family) { create(:family) } + let(:user) { create(:user) } + + it 'ensures each invitation has a unique token' do + invitation1 = create(:family_invitation, family: family, invited_by: user) + invitation2 = create(:family_invitation, family: family, invited_by: user) + + expect(invitation1.token).not_to eq(invitation2.token) + end + end + + describe 'email format validation' do + let(:invitation) { build(:family_invitation) } + + it 'accepts valid email formats' do + valid_emails = ['test@example.com', 'user.name@domain.co.uk', 'user+tag@example.org'] + + valid_emails.each do |email| + invitation.email = email + expect(invitation).to be_valid + end + end + + it 'rejects invalid email formats' do + invalid_emails = ['invalid-email', '@example.com', 'user@', 'user space@example.com'] + + invalid_emails.each do |email| + invitation.email = email + expect(invitation).not_to be_valid + expect(invitation.errors[:email]).to be_present + end + end + end +end diff --git a/spec/models/family/membership_spec.rb b/spec/models/family/membership_spec.rb new file mode 100644 index 00000000..0cc859e7 --- /dev/null +++ b/spec/models/family/membership_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::Membership, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:family) } + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + subject { build(:family_membership) } + + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_uniqueness_of(:user_id) } + it { is_expected.to validate_presence_of(:role) } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:role).with_values(owner: 0, member: 1) } + end + + describe 'one family per user constraint' do + let(:user) { create(:user) } + let(:family1) { create(:family) } + let(:family2) { create(:family) } + + it 'allows a user to be in one family' do + membership1 = build(:family_membership, family: family1, user: user) + expect(membership1).to be_valid + end + + it 'prevents a user from being in multiple families' do + create(:family_membership, family: family1, user: user) + membership2 = build(:family_membership, family: family2, user: user) + + expect(membership2).not_to be_valid + expect(membership2.errors[:user_id]).to include('has already been taken') + end + end + + describe 'role assignment' do + let(:family) { create(:family) } + + context 'when created as owner' do + let(:membership) { create(:family_membership, :owner, family: family) } + + it 'can be created' do + expect(membership.role).to eq('owner') + expect(membership.owner?).to be true + end + end + + context 'when created as member' do + let(:membership) { create(:family_membership, family: family, role: :member) } + + it 'can be created' do + expect(membership.role).to eq('member') + expect(membership.member?).to be true + end + end + + it 'defaults to member role' do + membership = create(:family_membership, family: family) + expect(membership.role).to eq('member') + end + end +end diff --git a/spec/models/family_spec.rb b/spec/models/family_spec.rb new file mode 100644 index 00000000..7f81b898 --- /dev/null +++ b/spec/models/family_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family, type: :model do + let(:user) { create(:user) } + + describe 'associations' do + it { is_expected.to have_many(:family_memberships).dependent(:destroy) } + it { is_expected.to have_many(:members).through(:family_memberships).source(:user) } + it { is_expected.to have_many(:family_invitations).dependent(:destroy) } + it { is_expected.to belong_to(:creator).class_name('User') } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(50) } + end + + describe 'constants' do + it 'defines MAX_MEMBERS' do + expect(Family::MAX_MEMBERS).to eq(5) + end + end + + describe '#can_add_members?' do + let(:family) { create(:family, creator: user) } + + context 'when family has fewer than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + end + + it 'returns true' do + expect(family.can_add_members?).to be true + end + end + + context 'when family has max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + end + + it 'returns false' do + expect(family.can_add_members?).to be false + end + end + + context 'when family has no members' do + it 'returns true' do + expect(family.can_add_members?).to be true + end + end + end + + describe 'family creation' do + let(:family) { Family.new(name: 'Test Family', creator: user) } + + it 'can be created with valid attributes' do + expect(family).to be_valid + end + + it 'requires a name' do + family.name = nil + + expect(family).not_to be_valid + expect(family.errors[:name]).to include("can't be blank") + end + + it 'requires a creator' do + family.creator = nil + + expect(family).not_to be_valid + end + + it 'rejects names longer than 50 characters' do + long_name = 'a' * 51 + family.name = long_name + + expect(family).not_to be_valid + expect(family.errors[:name]).to include('is too long (maximum is 50 characters)') + end + end + + describe 'members association' do + let(:family) { create(:family, creator: user) } + let(:member1) { create(:user) } + let(:member2) { create(:user) } + + before do + create(:family_membership, family: family, user: user, role: :owner) + create(:family_membership, family: family, user: member1, role: :member) + create(:family_membership, family: family, user: member2, role: :member) + end + + it 'includes all family members' do + expect(family.members).to include(user, member1, member2) + expect(family.members.count).to eq(3) + end + end + + describe 'family invitations association' do + let(:family) { create(:family, creator: user) } + + it 'destroys associated invitations when family is destroyed' do + invitation = create(:family_invitation, family: family, invited_by: user) + + expect { family.destroy }.to change(Family::Invitation, :count).by(-1) + expect(Family::Invitation.find_by(id: invitation.id)).to be_nil + end + end + + describe 'family memberships association' do + let(:family) { create(:family, creator: user) } + + it 'destroys associated memberships when family is destroyed' do + membership = create(:family_membership, family: family, user: user, role: :owner) + + expect { family.destroy }.to change(Family::Membership, :count).by(-1) + expect(Family::Membership.find_by(id: membership.id)).to be_nil + end + end +end diff --git a/spec/models/user_family_spec.rb b/spec/models/user_family_spec.rb new file mode 100644 index 00000000..0a0d2879 --- /dev/null +++ b/spec/models/user_family_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User, 'family methods', type: :model do + let(:user) { create(:user) } + + describe 'family associations' do + it { is_expected.to have_one(:family_membership).dependent(:destroy) } + it { is_expected.to have_one(:family).through(:family_membership) } + it { + is_expected.to have_one(:created_family).class_name('Family').with_foreign_key('creator_id').dependent(:destroy) + } + it { + is_expected.to have_many(:sent_family_invitations).class_name('Family::Invitation').with_foreign_key('invited_by_id').dependent(:destroy) + } + end + + describe '#in_family?' do + context 'when user has no family membership' do + it 'returns false' do + expect(user.in_family?).to be false + end + end + + context 'when user has family membership' do + let(:family) { create(:family, creator: user) } + + before do + create(:family_membership, user: user, family: family) + end + + it 'returns true' do + expect(user.in_family?).to be true + end + end + end + + describe '#family_owner?' do + let(:family) { create(:family, creator: user) } + + context 'when user is family owner' do + before do + create(:family_membership, user: user, family: family, role: :owner) + end + + it 'returns true' do + expect(user.family_owner?).to be true + end + end + + context 'when user is family member' do + before do + create(:family_membership, user: user, family: family, role: :member) + end + + it 'returns false' do + expect(user.family_owner?).to be false + end + end + + context 'when user has no family membership' do + it 'returns false' do + expect(user.family_owner?).to be false + end + end + end + + describe '#can_delete_account?' do + context 'when user is not a family owner' do + it 'returns true' do + expect(user.can_delete_account?).to be true + end + end + + context 'when user is family owner with only themselves as member' do + let(:family) { create(:family, creator: user) } + + before do + create(:family_membership, user: user, family: family, role: :owner) + end + + it 'returns true' do + expect(user.can_delete_account?).to be true + end + end + + context 'when user is family owner with other members' do + let(:family) { create(:family, creator: user) } + let(:other_user) { create(:user) } + + before do + create(:family_membership, user: user, family: family, role: :owner) + create(:family_membership, user: other_user, family: family, role: :member) + end + + it 'returns false' do + expect(user.can_delete_account?).to be false + end + end + end + + describe 'dependent destroy behavior' do + let(:family) { create(:family, creator: user) } + + context 'when user has created families' do + it 'prevents deletion when family has members' do + other_user = create(:user) + create(:family_membership, user: user, family: family, role: :owner) + create(:family_membership, user: other_user, family: family, role: :member) + + expect(user.can_delete_account?).to be false + end + end + + context 'when user has sent invitations' do + before do + create(:family_invitation, family: family, invited_by: user) + end + + it 'destroys associated invitations when user is destroyed' do + expect { user.destroy }.to change(Family::Invitation, :count).by(-1) + end + end + + context 'when user has family membership' do + before do + create(:family_membership, user: user, family: family) + end + + it 'destroys associated membership when user is destroyed' do + expect { user.destroy }.to change(Family::Membership, :count).by(-1) + end + end + end +end diff --git a/spec/policies/family/invitation_policy_spec.rb b/spec/policies/family/invitation_policy_spec.rb new file mode 100644 index 00000000..0478c645 --- /dev/null +++ b/spec/policies/family/invitation_policy_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::InvitationPolicy, type: :policy do + let(:family) { create(:family) } + let(:owner) { family.creator } + let(:member) { create(:user) } + let(:other_user) { create(:user) } + let(:invitation) { create(:family_invitation, family: family, invited_by: owner) } + + before do + create(:family_membership, family: family, user: owner, role: :owner) + create(:family_membership, family: family, user: member, role: :member) + end + + describe '#create?' do + context 'when user is family owner' do + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows family owner to create invitations' do + policy = described_class.new(owner, invitation) + + expect(policy).to permit(:create) + end + end + + context 'when user is regular family member' do + before do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + end + + it 'denies regular family member from creating invitations' do + policy = described_class.new(member, invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'when user is not in the family' do + it 'denies user not in the family from creating invitations' do + policy = described_class.new(other_user, invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from creating invitations' do + policy = described_class.new(nil, invitation) + + expect(policy).not_to permit(:create) + end + end + end + + describe '#accept?' do + context 'when user email matches invitation email' do + let(:invited_user) { create(:user, email: invitation.email) } + + it 'allows user to accept invitation sent to their email' do + policy = described_class.new(invited_user, invitation) + + expect(policy).to permit(:accept) + end + end + + context 'when user email does not match invitation email' do + it 'denies user with different email from accepting invitation' do + policy = described_class.new(other_user, invitation) + + expect(policy).not_to permit(:accept) + end + end + + context 'when family owner tries to accept invitation' do + it 'denies family owner from accepting invitation sent to different email' do + policy = described_class.new(owner, invitation) + + expect(policy).not_to permit(:accept) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from accepting invitation' do + policy = described_class.new(nil, invitation) + + expect(policy).not_to permit(:accept) + end + end + end + + describe '#destroy?' do + context 'when user is family owner' do + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows family owner to cancel invitations' do + policy = described_class.new(owner, invitation) + + expect(policy).to permit(:destroy) + end + end + + context 'when user is regular family member' do + before do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + end + + it 'denies regular family member from cancelling invitations' do + policy = described_class.new(member, invitation) + + expect(policy).not_to permit(:destroy) + end + end + + context 'when user is not in the family' do + it 'denies user not in the family from cancelling invitations' do + policy = described_class.new(other_user, invitation) + + expect(policy).not_to permit(:destroy) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from cancelling invitations' do + policy = described_class.new(nil, invitation) + + expect(policy).not_to permit(:destroy) + end + end + end + + describe 'edge cases' do + context 'when invitation belongs to different family' do + let(:other_family) { create(:family) } + let(:other_family_owner) { other_family.creator } + let(:other_invitation) { create(:family_invitation, family: other_family, invited_by: other_family_owner) } + + before do + create(:family_membership, family: other_family, user: other_family_owner, role: :owner) + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'denies owner from creating invitations for different family' do + policy = described_class.new(owner, other_invitation) + + expect(policy).not_to permit(:create) + end + + it 'denies owner from destroying invitations for different family' do + policy = described_class.new(owner, other_invitation) + + expect(policy).not_to permit(:destroy) + end + end + + context 'with expired invitation' do + let(:expired_invitation) { create(:family_invitation, :expired, family: family, invited_by: owner) } + let(:invited_user) { create(:user, email: expired_invitation.email) } + + it 'still allows user to attempt to accept expired invitation (business logic handles expiry)' do + policy = described_class.new(invited_user, expired_invitation) + + expect(policy).to permit(:accept) + end + + it 'allows owner to destroy expired invitation' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, expired_invitation) + + expect(policy).to permit(:destroy) + end + end + + context 'with accepted invitation' do + let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, invited_by: owner) } + + it 'allows owner to destroy accepted invitation' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, accepted_invitation) + + expect(policy).to permit(:destroy) + end + end + + context 'with cancelled invitation' do + let(:cancelled_invitation) { create(:family_invitation, :cancelled, family: family, invited_by: owner) } + + it 'allows owner to destroy cancelled invitation' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, cancelled_invitation) + + expect(policy).to permit(:destroy) + end + end + end + + describe 'authorization consistency' do + it 'ensures owner can both create and destroy invitations' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, invitation) + + expect(policy).to permit(:create) + expect(policy).to permit(:destroy) + end + + it 'ensures regular members cannot create or destroy invitations' do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + policy = described_class.new(member, invitation) + + expect(policy).not_to permit(:create) + expect(policy).not_to permit(:destroy) + end + + it 'ensures invited users can only accept their own invitations' do + invited_user = create(:user, email: invitation.email) + policy = described_class.new(invited_user, invitation) + + expect(policy).to permit(:accept) + expect(policy).not_to permit(:create) + expect(policy).not_to permit(:destroy) + end + end +end diff --git a/spec/policies/family/membership_policy_spec.rb b/spec/policies/family/membership_policy_spec.rb new file mode 100644 index 00000000..b720149a --- /dev/null +++ b/spec/policies/family/membership_policy_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::MembershipPolicy, type: :policy do + let(:family) { create(:family) } + let(:owner) { family.creator } + let(:member) { create(:user) } + let(:another_member) { create(:user) } + let(:other_user) { create(:user) } + + let(:owner_membership) { create(:family_membership, :owner, family: family, user: owner) } + let(:member_membership) { create(:family_membership, family: family, user: member) } + let(:another_member_membership) { create(:family_membership, family: family, user: another_member) } + + describe '#create?' do + let(:valid_invitation) { create(:family_invitation, family: family, email: member.email) } + let(:expired_invitation) { create(:family_invitation, family: family, email: member.email, expires_at: 1.day.ago) } + let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, email: member.email) } + let(:wrong_email_invitation) { create(:family_invitation, family: family, email: 'wrong@example.com') } + + context 'when user has valid invitation' do + it 'allows user to create membership with valid pending invitation for their email' do + policy = described_class.new(member, valid_invitation) + + expect(policy).to permit(:create) + end + end + + context 'when invitation is expired' do + it 'denies user from creating membership with expired invitation' do + policy = described_class.new(member, expired_invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'when invitation is already accepted' do + it 'denies user from creating membership with already accepted invitation' do + policy = described_class.new(member, accepted_invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'when invitation is for different email' do + it 'denies user from creating membership with invitation for different email' do + policy = described_class.new(member, wrong_email_invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from creating membership' do + policy = described_class.new(nil, valid_invitation) + + expect(policy).not_to permit(:create) + end + end + end + + describe '#destroy?' do + context 'when user is removing themselves' do + it 'allows user to remove their own membership (leave family)' do + allow(member).to receive(:family).and_return(family) + policy = described_class.new(member, member_membership) + + expect(policy).to permit(:destroy) + end + + it 'allows owner to remove their own membership' do + allow(owner).to receive(:family).and_return(family) + policy = described_class.new(owner, owner_membership) + + expect(policy).to permit(:destroy) + end + end + + context 'when user is family owner' do + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows family owner to remove other members' do + policy = described_class.new(owner, member_membership) + + expect(policy).to permit(:destroy) + end + + it 'allows family owner to remove multiple members' do + policy1 = described_class.new(owner, member_membership) + policy2 = described_class.new(owner, another_member_membership) + + expect(policy1).to permit(:destroy) + expect(policy2).to permit(:destroy) + end + end + + context 'when user is regular family member' do + before do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + end + + it 'denies regular member from removing other members' do + policy = described_class.new(member, another_member_membership) + + expect(policy).not_to permit(:destroy) + end + + it 'denies regular member from removing owner' do + policy = described_class.new(member, owner_membership) + + expect(policy).not_to permit(:destroy) + end + end + + context 'when user is not in the family' do + it 'denies user from removing membership of different family' do + policy = described_class.new(other_user, member_membership) + + expect(policy).not_to permit(:destroy) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from removing membership' do + policy = described_class.new(nil, member_membership) + + expect(policy).not_to permit(:destroy) + end + end + end + + describe 'edge cases' do + context 'when membership belongs to different family' do + let(:other_family) { create(:family) } + let(:other_family_owner) { other_family.creator } + let(:other_family_membership) do + create(:family_membership, :owner, family: other_family, user: other_family_owner) + end + + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'denies owner from destroying membership of different family' do + policy = described_class.new(owner, other_family_membership) + + expect(policy).not_to permit(:destroy) + end + end + + context 'when owner tries to modify another owners membership' do + let(:co_owner) { create(:user) } + let(:co_owner_membership) { create(:family_membership, :owner, family: family, user: co_owner) } + + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows owner to remove another owner (family owner has full control)' do + policy = described_class.new(owner, co_owner_membership) + + expect(policy).to permit(:destroy) + end + end + end + + describe 'authorization consistency' do + it 'ensures owner can destroy all memberships in their family' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + + policy = described_class.new(owner, member_membership) + + expect(policy).to permit(:destroy) + end + + it 'ensures regular members can only remove their own membership' do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + + own_policy = described_class.new(member, member_membership) + other_policy = described_class.new(member, another_member_membership) + + # Can remove own membership + expect(own_policy).to permit(:destroy) + + # Cannot remove others + expect(other_policy).not_to permit(:destroy) + end + + it 'ensures users can always leave the family (remove own membership)' do + allow(member).to receive(:family).and_return(family) + policy = described_class.new(member, member_membership) + + expect(policy).to permit(:destroy) + end + end +end diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index 621a86cc..75620f5c 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -166,4 +166,79 @@ RSpec.describe 'Authentication', type: :request do expect(response.location).not_to include('auth/ios/success') end end + + describe 'Family Invitation with Authentication' do + let(:family) { create(:family, creator: user) } + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + let(:invitee) { create(:user, email: 'invitee@example.com', password: 'password123') } + let(:invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) } + + it 'redirects to invitation page when signing in with invitation token in params' do + post user_session_path, params: { + user: { email: invitee.email, password: 'password123' }, + invitation_token: invitation.token + } + + expect(response).to redirect_to(family_invitation_path(invitation.token)) + end + + it 'redirects to invitation page when signing in with invitation token in session' do + # The invitation token is stored in session by Users::SessionsController#load_invitation_context + # when accessing the sign-in page with invitation_token param + get new_user_session_path, params: { invitation_token: invitation.token } + + # Then sign in without the invitation_token in params (should use session value) + post user_session_path, params: { + user: { email: invitee.email, password: 'password123' } + } + + expect(response).to redirect_to(family_invitation_path(invitation.token)) + end + + it 'prioritizes invitation over iOS flow when both are present' do + # Sign in with both iOS header AND invitation token + post user_session_path, params: { + user: { email: invitee.email, password: 'password123' }, + invitation_token: invitation.token + }, headers: { + 'X-Dawarich-Client' => 'ios' + } + + # Should redirect to invitation page, NOT iOS success + expect(response).to redirect_to(family_invitation_path(invitation.token)) + expect(response.location).not_to include('auth/ios/success') + end + + it 'redirects to iOS success when invitation is expired' do + # Create an expired invitation + expired_invitation = create(:family_invitation, + family: family, + invited_by: user, + email: invitee.email, + expires_at: 1.day.ago) + + # Sign in with iOS header and expired invitation token + post user_session_path, params: { + user: { email: invitee.email, password: 'password123' }, + invitation_token: expired_invitation.token + }, headers: { + 'X-Dawarich-Client' => 'ios' + } + + # Should redirect to iOS success since invitation can't be accepted + expect(response).to redirect_to(%r{auth/ios/success\?token=}) + end + + it 'uses default path when invitation token is invalid' do + # Sign in with invalid invitation token + post user_session_path, params: { + user: { email: invitee.email, password: 'password123' }, + invitation_token: 'invalid-token-123' + } + + # Should use default redirect path + expect(response).not_to redirect_to(%r{/invitations/}) + expect(response).to redirect_to(root_path) + end + end end diff --git a/spec/requests/families_spec.rb b/spec/requests/families_spec.rb new file mode 100644 index 00000000..5bc4e826 --- /dev/null +++ b/spec/requests/families_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family', type: :request do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:family) { create(:family, creator: user) } + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + sign_in user + end + + describe 'GET /family' do + it 'shows the family page' do + get "/family" + expect(response).to have_http_status(:ok) + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to new family path' do + get "/family" + expect(response).to redirect_to(new_family_path) + end + end + end + + describe 'GET /family/new' do + context 'when user is not in a family' do + let(:user_without_family) { create(:user) } + + before { sign_in user_without_family } + + it 'renders the new family form' do + get '/family/new' + expect(response).to have_http_status(:ok) + end + end + + context 'when user is already in a family' do + it 'redirects to family show page' do + get '/family/new' + expect(response).to redirect_to(family_path) + end + end + end + + describe 'POST /family' do + let(:user_without_family) { create(:user) } + + before { sign_in user_without_family } + + context 'with valid attributes' do + let(:valid_attributes) { { family: { name: 'Test Family' } } } + + it 'creates a new family' do + expect do + post '/family', params: valid_attributes + end.to change(Family, :count).by(1) + end + + it 'creates a family membership for the user' do + expect do + post '/family', params: valid_attributes + end.to change(Family::Membership, :count).by(1) + end + + it 'redirects to the new family with success message' do + post '/family', params: valid_attributes + + expect(response).to have_http_status(:found) + expect(response.location).to eq family_url + follow_redirect! + expect(response.body).to include('Family created successfully!') + end + end + + context 'with invalid attributes' do + let(:invalid_attributes) { { family: { name: '' } } } + + it 'does not create a family' do + expect do + post '/family', params: invalid_attributes + end.not_to change(Family, :count) + end + + it 'renders the new template with errors' do + post '/family', params: invalid_attributes + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe 'GET /family/edit' do + it 'shows the edit form' do + get "/family/edit" + expect(response).to have_http_status(:ok) + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + get "/family/edit" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + end + + describe 'PATCH /family' do + let(:new_attributes) { { family: { name: 'Updated Family Name' } } } + + context 'with valid attributes' do + it 'updates the family' do + patch "/family", params: new_attributes + family.reload + expect(family.name).to eq('Updated Family Name') + expect(response).to redirect_to(family_path) + end + end + + context 'with invalid attributes' do + let(:invalid_attributes) { { family: { name: '' } } } + + it 'does not update the family' do + original_name = family.name + patch "/family", params: invalid_attributes + family.reload + expect(family.name).to eq(original_name) + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + patch "/family", params: new_attributes + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + end + + describe 'DELETE /family' do + context 'when family has only one member' do + it 'deletes the family' do + expect { delete '/family' }.to change(Family, :count).by(-1) + expect(response).to redirect_to(new_family_path) + end + end + + context 'when family has multiple members' do + before do + create(:family_membership, user: other_user, family: family, role: :member) + end + + it 'does not delete the family' do + expect { delete "/family" }.not_to change(Family, :count) + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Cannot delete family with members') + end + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + delete "/family" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + end + + + describe 'authorization for outsiders' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'denies access to show when user is not in family' do + get "/family" + expect(response).to redirect_to(new_family_path) + end + + it 'redirects to family page when user is not in family for edit' do + get "/family/edit" + expect(response).to redirect_to(new_family_path) + end + + it 'redirects to family page when user is not in family for update' do + patch "/family", params: { family: { name: 'Hacked' } } + expect(response).to redirect_to(new_family_path) + end + + it 'redirects to family page when user is not in family for destroy' do + delete "/family" + expect(response).to redirect_to(new_family_path) + end + + end + + describe 'authentication required' do + before { sign_out user } + + it 'redirects to login for index' do + get '/family' + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for show' do + get "/family" + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for new' do + get '/family/new' + + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for create' do + post '/family', params: { family: { name: 'Test' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for edit' do + get "/family/edit" + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for update' do + patch "/family", params: { family: { name: 'Test' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for destroy' do + delete "/family" + expect(response).to redirect_to(new_user_session_path) + end + end +end diff --git a/spec/requests/family/invitations_spec.rb b/spec/requests/family/invitations_spec.rb new file mode 100644 index 00000000..b75d501e --- /dev/null +++ b/spec/requests/family/invitations_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family::Invitations', type: :request do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + let(:invitation) { create(:family_invitation, family: family, invited_by: user) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'GET /family/invitations' do + before { sign_in user } + + it 'shows pending invitations' do + invitation # create the invitation + get "/family/invitations" + expect(response).to have_http_status(:ok) + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + get "/family/invitations" + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + get "/family/invitations" + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'GET /invitations/:token (public invitation view)' do + context 'when invitation is valid and pending' do + it 'shows the invitation without authentication' do + get "/invitations/#{invitation.token}" + expect(response).to have_http_status(:ok) + end + end + + context 'when invitation is expired' do + before { invitation.update!(expires_at: 1.day.ago) } + + it 'redirects with error message' do + get "/invitations/#{invitation.token}" + expect(response).to redirect_to(root_path) + follow_redirect! + expect(response.body).to include('This invitation has expired') + end + end + + context 'when invitation is not pending' do + before { invitation.update!(status: :accepted) } + + it 'redirects with error message' do + get "/invitations/#{invitation.token}" + expect(response).to redirect_to(root_path) + follow_redirect! + expect(response.body).to include('This invitation is no longer valid') + end + end + + context 'when invitation does not exist' do + it 'returns not found' do + get '/invitations/invalid-token' + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /family/invitations' do + before { sign_in user } + + context 'with valid email' do + let(:valid_params) do + { family_invitation: { email: 'newuser@example.com' } } + end + + it 'creates a new invitation' do + expect do + post "/family/invitations", params: valid_params + end.to change(Family::Invitation, :count).by(1) + end + + it 'redirects with success message' do + post "/family/invitations", params: valid_params + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Invitation sent successfully!') + end + end + + context 'with duplicate email' do + let(:duplicate_params) do + { family_invitation: { email: invitation.email } } + end + + it 'does not create a duplicate invitation' do + invitation # create the existing invitation + expect do + post "/family/invitations", params: duplicate_params + end.not_to change(Family::Invitation, :count) + end + + it 'redirects with error message' do + invitation # create the existing invitation + post "/family/invitations", params: duplicate_params + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Invitation already sent to this email') + end + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + post "/family/invitations", params: { + family_invitation: { email: 'test@example.com' } + } + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + post "/family/invitations", params: { + family_invitation: { email: 'test@example.com' } + } + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + post "/family/invitations", params: { + family_invitation: { email: 'test@example.com' } + } + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'DELETE /family/invitations/:id' do + before { sign_in user } + + it 'cancels the invitation' do + delete "/family/invitations/#{invitation.token}" + invitation.reload + expect(invitation.status).to eq('cancelled') + end + + it 'redirects with success message' do + delete "/family/invitations/#{invitation.token}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Invitation cancelled') + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + delete "/family/invitations/#{invitation.token}" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + delete "/family/invitations/#{invitation.token}" + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + delete "/family/invitations/#{invitation.token}" + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'invitation workflow integration' do + let(:invitee) { create(:user) } + + it 'completes full invitation acceptance workflow' do + # 1. Owner creates invitation + sign_in user + post "/family/invitations", params: { + family_invitation: { email: invitee.email } + } + expect(response).to redirect_to(family_path) + + created_invitation = Family::Invitation.last + expect(created_invitation.email).to eq(invitee.email) + + # 2. Invitee views public invitation page + sign_out user + get "/invitations/#{created_invitation.token}" + expect(response).to have_http_status(:ok) + + # 3. Invitee accepts invitation + sign_in invitee + post accept_family_invitation_path(token: created_invitation.token) + expect(response).to redirect_to(family_path) + + # 4. Verify invitee is now in family + expect(invitee.reload.family).to eq(family) + expect(created_invitation.reload.status).to eq('accepted') + end + end +end diff --git a/spec/requests/family/memberships_spec.rb b/spec/requests/family/memberships_spec.rb new file mode 100644 index 00000000..5efde1ba --- /dev/null +++ b/spec/requests/family/memberships_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family::Memberships', type: :request do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) } + let(:member_user) { create(:user) } + let!(:member_membership) { create(:family_membership, user: member_user, family: family, role: :member) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + sign_in user + end + + describe 'POST /family/memberships' do + let(:invitee) { create(:user) } + let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) } + + context 'with valid invitation and user' do + before { sign_in invitee } + + it 'accepts the invitation' do + expect do + post accept_family_invitation_path(token: invitee_invitation.token) + end.to change { invitee.reload.family }.from(nil).to(family) + end + + it 'redirects with success message' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Welcome to the family!') + end + + it 'marks invitation as accepted' do + post accept_family_invitation_path(token: invitee_invitation.token) + invitee_invitation.reload + expect(invitee_invitation.status).to eq('accepted') + end + end + + context 'when user is already in a family' do + let(:other_family) { create(:family) } + + before do + create(:family_membership, user: invitee, family: other_family, role: :member) + sign_in invitee + end + + it 'does not accept the invitation' do + expect do + post accept_family_invitation_path(token: invitee_invitation.token) + end.not_to(change { invitee.reload.family }) + end + + it 'redirects with error message' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('You must leave your current family before joining a new one') + end + end + + context 'when invitation is expired' do + before do + invitee_invitation.update!(expires_at: 1.day.ago) + sign_in invitee + end + + it 'does not accept the invitation' do + expect do + post accept_family_invitation_path(token: invitee_invitation.token) + end.not_to(change { invitee.reload.family }) + end + + it 'redirects with error message' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('This invitation is no longer valid or has expired') + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'DELETE /family/members/:id' do + context 'when removing a regular member' do + it 'removes the member from the family' do + expect do + delete "/family/members/#{member_membership.id}" + end.to change(Family::Membership, :count).by(-1) + end + + it 'redirects with success message' do + member_email = member_user.email + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include("#{member_email} has been removed from the family") + end + + it 'removes the user from the family' do + delete "/family/members/#{member_membership.id}" + expect(member_user.reload.family).to be_nil + end + end + + context 'when trying to remove the owner' do + it 'does not remove the owner' do + expect do + delete "/family/members/#{owner_membership.id}" + end.not_to change(Family::Membership, :count) + end + + it 'redirects with error message explaining owners must delete family' do + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Family owners cannot remove their own membership. To leave the family, delete it instead.') + end + + it 'prevents owner removal even when they are the only member' do + member_membership.destroy! + + expect do + delete "/family/members/#{owner_membership.id}" + end.not_to change(Family::Membership, :count) + + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Family owners cannot remove their own membership') + end + end + + context 'when membership does not belong to the family' do + let(:other_family) { create(:family) } + let(:other_membership) { create(:family_membership, family: other_family) } + + it 'returns not found' do + delete "/family/members/#{other_membership.id}" + expect(response).to have_http_status(:not_found) + end + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'authorization for different member roles' do + context 'when member tries to remove another member' do + before { sign_in member_user } + + it 'returns forbidden' do + delete "/family/members/#{owner_membership.id}" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + + end + + describe 'member removal workflow' do + it 'removes member and updates family associations' do + # Verify initial state + expect(family.members).to include(user, member_user) + expect(member_user.family).to eq(family) + + # Remove member + delete "/family/members/#{member_membership.id}" + + # Verify removal + expect(response).to redirect_to(family_path) + expect(family.reload.members).to include(user) + expect(family.members).not_to include(member_user) + expect(member_user.reload.family).to be_nil + end + + it 'prevents removing owner regardless of member count' do + # Verify initial state + expect(family.members.count).to eq(2) + expect(user.family_owner?).to be true + + # Try to remove owner + delete "/family/members/#{owner_membership.id}" + + # Verify prevention + expect(response).to redirect_to(family_path) + expect(family.reload.members).to include(user, member_user) + expect(user.reload.family).to eq(family) + end + + it 'prevents removing owner even when they are the only member' do + # Remove other member first + member_membership.destroy! + + # Verify only owner remains + expect(family.reload.members.count).to eq(1) + expect(family.members).to include(user) + + # Try to remove owner - should be prevented + expect do + delete "/family/members/#{owner_membership.id}" + end.not_to change(Family::Membership, :count) + + expect(response).to redirect_to(family_path) + expect(user.reload.family).to eq(family) + expect(family.reload).to be_present + end + + it 'requires owners to use family deletion to leave the family' do + member_membership.destroy! + + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + expect(flash[:alert]).to include('Family owners cannot remove their own membership') + + delete "/family" + expect(response).to redirect_to(new_family_path) + expect(user.reload.family).to be_nil + end + end +end diff --git a/spec/requests/family_workflows_spec.rb b/spec/requests/family_workflows_spec.rb new file mode 100644 index 00000000..38f64ed9 --- /dev/null +++ b/spec/requests/family_workflows_spec.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family Workflows', type: :request do + let(:user1) { create(:user, email: 'alice@example.com') } + let(:user2) { create(:user, email: 'bob@example.com') } + let(:user3) { create(:user, email: 'charlie@example.com') } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'Complete family creation and management workflow' do + it 'allows creating a family, inviting members, and managing the family' do + # Step 1: User1 creates a family + sign_in user1 + + get '/family/new' + expect(response).to have_http_status(:ok) + + post '/family', params: { family: { name: 'The Smith Family' } } + + # The redirect should be to the newly created family + expect(response).to have_http_status(:found) + family = Family.find_by(name: 'The Smith Family') + expect(family).to be_present + expect(family.name).to eq('The Smith Family') + expect(family.creator).to eq(user1) + expect(user1.reload.family).to eq(family) + expect(user1.family_owner?).to be true + + # Step 2: User1 invites User2 + post "/family/invitations", params: { + family_invitation: { email: user2.email } + } + expect(response).to redirect_to(family_path) + + invitation = family.family_invitations.find_by(email: user2.email) + expect(invitation).to be_present + expect(invitation.email).to eq(user2.email) + expect(invitation.family).to eq(family) + expect(invitation.pending?).to be true + + # Step 3: User2 views and accepts invitation + sign_out user1 + + # Public invitation view (no auth required) + get "/invitations/#{invitation.token}" + expect(response).to have_http_status(:ok) + + # User2 accepts invitation + sign_in user2 + post accept_family_invitation_path(token: invitation.token) + expect(response).to redirect_to(family_path) + + expect(user2.reload.family).to eq(family) + expect(user2.family_owner?).to be false + expect(invitation.reload.accepted?).to be true + + # Step 4: User1 invites User3 + sign_in user1 + post "/family/invitations", params: { + family_invitation: { email: user3.email } + } + + invitation2 = family.family_invitations.find_by(email: user3.email) + expect(invitation2).to be_present + expect(invitation2.email).to eq(user3.email) + + # Step 5: User3 accepts invitation + sign_in user3 + post accept_family_invitation_path(token: invitation2.token) + + expect(user3.reload.family).to eq(family) + expect(family.reload.members.count).to eq(3) + + # Step 6: Family owner views members on family show page + sign_in user1 + get "/family" + expect(response).to have_http_status(:ok) + + # Step 7: Owner removes a member + delete "/family/members/#{user2.family_membership.id}" + expect(response).to redirect_to(family_path) + + expect(user2.reload.family).to be_nil + expect(family.reload.members.count).to eq(2) + expect(family.members).to include(user1, user3) + expect(family.members).not_to include(user2) + end + end + + describe 'Family invitation expiration workflow' do + let(:family) { create(:family, name: 'Test Family', creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + let!(:invitation) do + create(:family_invitation, family: family, email: user2.email, invited_by: user1, expires_at: 1.day.ago) + end + + it 'handles expired invitations correctly' do + # User2 tries to view expired invitation + get "/invitations/#{invitation.token}" + expect(response).to redirect_to(root_path) + follow_redirect! + expect(response.body).to include('This invitation has expired') + + # User2 tries to accept expired invitation + sign_in user2 + post accept_family_invitation_path(token: invitation.token) + expect(response).to redirect_to(root_path) + + expect(user2.reload.family).to be_nil + expect(invitation.reload.pending?).to be true + end + end + + describe 'Multiple family membership prevention workflow' do + let(:family1) { create(:family, name: 'Family 1', creator: user1) } + let(:family2) { create(:family, name: 'Family 2', creator: user2) } + let!(:user1_membership) { create(:family_membership, user: user1, family: family1, role: :owner) } + let!(:user2_membership) { create(:family_membership, user: user2, family: family2, role: :owner) } + let!(:invitation1) { create(:family_invitation, family: family1, email: user3.email, invited_by: user1) } + let!(:invitation2) { create(:family_invitation, family: family2, email: user3.email, invited_by: user2) } + + it 'prevents users from joining multiple families' do + # User3 accepts invitation to Family 1 + sign_in user3 + post accept_family_invitation_path(token: invitation1.token) + expect(response).to redirect_to(family_path) + expect(user3.family).to eq(family1) + + # User3 tries to accept invitation to Family 2 + post accept_family_invitation_path(token: invitation2.token) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('You must leave your current family') + + expect(user3.reload.family).to eq(family1) # Still in first family + end + end + + describe 'Family ownership transfer and leaving workflow' do + let(:family) { create(:family, creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) } + + it 'prevents owner from leaving when members exist' do + sign_in user1 + + # Owner tries to leave family with members (using memberships destroy route) + owner_membership = user1.family_membership + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('cannot remove their own membership') + + expect(user1.reload.family).to eq(family) + expect(user1.family_owner?).to be true + end + + it 'allows owner to leave when they are the only member' do + sign_in user1 + + # Remove the member first + delete "/family/members/#{member_membership.id}" + + # Owner cannot leave even when alone - they must delete the family instead + owner_membership = user1.reload.family_membership + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('cannot remove their own membership') + + expect(user1.reload.family).to eq(family) + end + + it 'allows members to leave freely' do + sign_in user2 + + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(new_family_path) + + expect(user2.reload.family).to be_nil + expect(family.reload.members.count).to eq(1) + expect(family.members).to include(user1) + expect(family.members).not_to include(user2) + end + end + + describe 'Family deletion workflow' do + let(:family) { create(:family, creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + + context 'when members exist' do + let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) } + + it 'prevents family deletion when members exist' do + sign_in user1 + + expect do + delete "/family" + end.not_to change(Family, :count) + + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Cannot delete family with members') + end + end + + it 'allows family deletion when owner is the only member' do + sign_in user1 + + expect do + delete "/family" + end.to change(Family, :count).by(-1) + + expect(response).to redirect_to(new_family_path) + expect(user1.reload.family).to be_nil + end + end + + describe 'Authorization workflow' do + let(:family) { create(:family, creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) } + + it 'enforces proper authorization for family management' do + # Member cannot invite others + sign_in user2 + post "/family/invitations", params: { + family_invitation: { email: user3.email } + } + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Member cannot remove other members + delete "/family/members/#{owner_membership.id}" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Member cannot edit family + patch "/family", params: { family: { name: 'Hacked Family' } } + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Member cannot delete family + delete "/family" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Outsider cannot access family + sign_in user3 + get "/family" + expect(response).to redirect_to(new_family_path) + end + end + + describe 'Email invitation workflow' do + let(:family) { create(:family, name: 'Test Family', creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + + it 'handles invitation emails correctly' do + sign_in user1 + + # Mock email delivery + expect do + post "/family/invitations", params: { + family_invitation: { email: 'newuser@example.com' } + } + end.to change(Family::Invitation, :count).by(1) + + invitation = family.family_invitations.find_by(email: 'newuser@example.com') + expect(invitation.email).to eq('newuser@example.com') + expect(invitation.token).to be_present + expect(invitation.expires_at).to be > Time.current + end + end + + describe 'Navigation and redirect workflow' do + it 'handles proper redirects for family-related navigation' do + # User without family can access new family page + sign_in user1 + get '/family/new' + expect(response).to have_http_status(:ok) + + # User creates family + post '/family', params: { family: { name: 'Test Family' } } + expect(response).to have_http_status(:found) + + # User with family can view their family + get '/family' + expect(response).to have_http_status(:ok) + + # User with family gets redirected from new family page + get '/family/new' + expect(response).to redirect_to(family_path) + end + end +end diff --git a/spec/requests/users/registrations_spec.rb b/spec/requests/users/registrations_spec.rb new file mode 100644 index 00000000..96b1469f --- /dev/null +++ b/spec/requests/users/registrations_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::Registrations', type: :request do + let(:family_owner) { create(:user) } + let(:family) { create(:family, creator: family_owner) } + let!(:owner_membership) { create(:family_membership, user: family_owner, family: family, role: :owner) } + let(:invitation) do + create(:family_invitation, family: family, invited_by: family_owner, email: 'invited@example.com') + end + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'Family Invitation Registration Flow' do + context 'when accessing registration with a valid invitation token' do + it 'shows family-focused registration page' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Join #{family.name}!") + expect(response.body).to include(family_owner.email) + expect(response.body).to include(invitation.email) + expect(response.body).to include('Create Account & Join Family') + end + + it 'pre-fills email field with invitation email' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include('value="invited@example.com"') + end + + it 'makes email field readonly' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include('readonly') + end + + it 'hides normal login links' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).not_to include('devise/shared/links') + end + end + + context 'when accessing registration without invitation token' do + it 'shows normal registration page' do + get new_user_registration_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Register now!') + expect(response.body).to include('take control over your location data') + expect(response.body).not_to include('Join') + expect(response.body).to include('Sign up') + end + end + + context 'when creating account with valid invitation token' do + let(:user_params) do + { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + } + end + + let(:request_params) do + { + user: user_params, + invitation_token: invitation.token + } + end + + it 'creates user and accepts invitation automatically' do + expect do + post user_registration_path, params: request_params + end.to change(User, :count).by(1) + .and change { invitation.reload.status }.from('pending').to('accepted') + + new_user = User.find_by(email: invitation.email) + expect(new_user).to be_present + expect(new_user.family).to eq(family) + expect(family.reload.members).to include(new_user) + end + + it 'redirects to family page after successful registration' do + post user_registration_path, params: request_params + + expect(response).to redirect_to(family_path) + end + + it 'displays success message with family name' do + post user_registration_path, params: request_params + + # Check that user got the default registration success message + # (family welcome message is set but may be overridden by Devise) + expect(flash[:notice]).to include('signed up successfully') + end + end + + context 'when creating account with invalid invitation token' do + it 'creates user but does not accept any invitation' do + expect do + post user_registration_path, params: { + user: { + email: 'user@example.com', + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: 'invalid-token' + } + end.to change(User, :count).by(1) + + new_user = User.find_by(email: 'user@example.com') + expect(new_user.family).to be_nil + end + end + + context 'when invitation email does not match registration email' do + it 'creates user but does not accept invitation' do + expect do + post user_registration_path, params: { + user: { + email: 'different@example.com', + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + new_user = User.find_by(email: 'different@example.com') + expect(new_user.family).to be_nil + expect(invitation.reload.status).to eq('pending') + end + end + end + + describe 'Self-Hosted Mode' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true') + end + + context 'when accessing registration without invitation token' do + it 'redirects to root with error message' do + get new_user_registration_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + + it 'prevents account creation' do + expect do + post user_registration_path, params: { + user: { + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123' + } + } + end.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + end + + context 'when accessing registration with valid invitation token' do + it 'allows registration page access' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Join #{family.name}!") + end + + it 'allows account creation' do + expect do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + expect(response).to redirect_to(family_path) + end + end + + context 'when accessing registration with expired invitation' do + before { invitation.update!(expires_at: 1.day.ago) } + + it 'redirects to root with error message' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + end + + context 'when accessing registration with cancelled invitation' do + before { invitation.update!(status: :cancelled) } + + it 'redirects to root with error message' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + end + end + + describe 'Non-Self-Hosted Mode' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + allow(DawarichSettings).to receive(:family_feature_enabled?).and_return(false) + end + + context 'when accessing registration without invitation token' do + it 'allows normal registration' do + get new_user_registration_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Register now!') + end + + it 'allows account creation' do + unique_email = "newuser-#{Time.current.to_i}@example.com" + + expect do + post user_registration_path, params: { + user: { + email: unique_email, + password: 'password123', + password_confirmation: 'password123' + } + } + end.to change(User, :count).by(1) + + expect(response).to redirect_to(root_path) + expect(User.find_by(email: unique_email)).to be_present + end + end + end + + describe 'Invitation Token Handling' do + it 'accepts invitation token from params' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include("Join #{invitation.family.name}!") + end + + it 'accepts invitation token from nested user params' do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + + new_user = User.find_by(email: invitation.email) + expect(new_user.family).to eq(family) + end + + it 'handles session-stored invitation token' do + # Simulate session storage by passing the token directly in params + # (In real usage, this would come from the session after redirect from invitation page) + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include("Join #{invitation.family.name}!") + end + end + + describe 'Error Handling' do + context 'when invitation acceptance fails' do + before do + # Mock service failure + allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_return(false) + allow_any_instance_of(Families::AcceptInvitation).to receive(:error_message).and_return('Mock error') + end + + it 'creates user but shows invitation error in flash' do + expect do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + expect(flash[:alert]).to include('Mock error') + end + end + + context 'when invitation acceptance raises exception' do + before do + # Mock service exception + allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_raise(StandardError, 'Test error') + end + + it 'creates user but shows generic error in flash' do + expect do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + expect(flash[:alert]).to include('there was an issue accepting the invitation') + end + end + end +end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb index 8c0bcdf5..219c4d4d 100644 --- a/spec/requests/users_spec.rb +++ b/spec/requests/users_spec.rb @@ -11,19 +11,21 @@ RSpec.describe 'Users', type: :request do describe 'GET /users/sign_up' do context 'when self-hosted' do before do - stub_const('SELF_HOSTED', true) + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true') end - it 'returns http success' do + it 'redirects to root path' do get '/users/sign_up' - expect(response).to have_http_status(:not_found) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') end end context 'when not self-hosted' do before do - stub_const('SELF_HOSTED', false) - Rails.application.reload_routes! + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return(nil) end it 'returns http success' do diff --git a/spec/services/families/accept_invitation_spec.rb b/spec/services/families/accept_invitation_spec.rb new file mode 100644 index 00000000..28dca538 --- /dev/null +++ b/spec/services/families/accept_invitation_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::AcceptInvitation do + let(:family) { create(:family) } + let(:invitee) { create(:user, email: 'invitee@example.com') } + let(:invitation) { create(:family_invitation, family: family, email: invitee.email) } + let(:service) { described_class.new(invitation: invitation, user: invitee) } + + describe '#call' do + context 'when invitation can be accepted' do + it 'creates membership for user' do + expect { service.call }.to change(Family::Membership, :count).by(1) + membership = invitee.reload.family_membership + expect(membership.family).to eq(family) + expect(membership.role).to eq('member') + end + + it 'updates invitation status to accepted' do + service.call + invitation.reload + expect(invitation.status).to eq('accepted') + end + + it 'sends notifications to both parties' do + expect { service.call }.to change(Notification, :count).by(2) + + user_notification = Notification.find_by(user: invitee, title: 'Welcome to Family!') + expect(user_notification).to be_present + + owner_notification = Notification.find_by(user: family.creator, title: 'New Family Member!') + expect(owner_notification).to be_present + end + + it 'returns true' do + expect(service.call).to be true + end + end + + context 'when user is already in another family' do + let(:other_family) { create(:family) } + let!(:existing_membership) { create(:family_membership, user: invitee, family: other_family) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + + it 'sets appropriate error message' do + service.call + expect(service.error_message).to eq('You must leave your current family before joining a new one.') + end + + it 'does not change user family' do + expect { service.call }.not_to(change { invitee.reload.family }) + end + end + + context 'when invitation is expired' do + let(:invitation) { create(:family_invitation, family: family, email: invitee.email, expires_at: 1.day.ago) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + + context 'when invitation is not pending' do + let(:invitation) { create(:family_invitation, :accepted, family: family, email: invitee.email) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + + context 'when email does not match user' do + let(:wrong_user) { create(:user, email: 'wrong@example.com') } + let(:service) { described_class.new(invitation: invitation, user: wrong_user) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + + context 'when family is at max capacity' do + before do + # Fill family to max capacity + create_list(:family_membership, Family::MAX_MEMBERS, family: family, role: :member) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + end +end diff --git a/spec/services/families/create_spec.rb b/spec/services/families/create_spec.rb new file mode 100644 index 00000000..216c344b --- /dev/null +++ b/spec/services/families/create_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::Create do + let(:user) { create(:user) } + let(:service) { described_class.new(user: user, name: 'Test Family') } + + describe '#call' do + context 'when user is not in a family' do + it 'creates a family successfully' do + expect { service.call }.to change(Family, :count).by(1) + expect(service.family.name).to eq('Test Family') + expect(service.family.creator).to eq(user) + end + + it 'creates owner membership' do + service.call + membership = user.reload.family_membership + expect(membership.role).to eq('owner') + expect(membership.family).to eq(service.family) + end + + it 'returns true on success' do + expect(service.call).to be true + end + end + + context 'when user is already in a family' do + before { create(:family_membership, user: user) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create a family' do + expect { service.call }.not_to change(Family, :count) + end + + it 'does not create a membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + + it 'sets appropriate error message' do + service.call + expect(service.error_message).to eq('You must leave your current family before creating a new one') + end + end + + context 'when user has already created a family before' do + before do + # User creates and then deletes their family membership, but family still exists + old_family = create(:family, creator: user) + membership = create(:family_membership, user: user, family: old_family, role: :owner) + membership.destroy! # User leaves the family but family still exists + user.reload # Ensure user association is refreshed + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create a family' do + expect { service.call }.not_to change(Family, :count) + end + + it 'does not create a membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + + it 'sets appropriate error message' do + service.call + expect(service.error_message).to eq('You have already created a family. Each user can only create one family') + end + end + end +end diff --git a/spec/services/families/invite_spec.rb b/spec/services/families/invite_spec.rb new file mode 100644 index 00000000..8ea3c747 --- /dev/null +++ b/spec/services/families/invite_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::Invite do + let(:owner) { create(:user) } + let(:family) { create(:family, creator: owner) } + let!(:owner_membership) { create(:family_membership, user: owner, family: family, role: :owner) } + let(:email) { 'invitee@example.com' } + let(:service) { described_class.new(family: family, email: email, invited_by: owner) } + + describe '#call' do + context 'when invitation is valid' do + it 'creates an invitation' do + expect { service.call }.to change(Family::Invitation, :count).by(1) + + invitation = owner.sent_family_invitations.last + + expect(invitation.family).to eq(family) + expect(invitation.email).to eq(email) + expect(invitation.invited_by).to eq(owner) + end + + it 'sends invitation email' do + expect(FamilyMailer).to receive(:invitation).and_call_original + expect_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_later) + service.call + end + + it 'sends notification to inviter' do + expect { service.call }.to change(Notification, :count).by(1) + + notification = owner.notifications.last + + expect(notification.user).to eq(owner) + expect(notification.title).to eq('Invitation Sent') + end + + it 'returns true' do + expect(service.call).to be true + end + end + + context 'when inviter is not family owner' do + let(:member) { create(:user) } + let!(:member_membership) { create(:family_membership, user: member, family: family, role: :member) } + let(:service) { described_class.new(family: family, email: email, invited_by: member) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'when family is at max capacity' do + before do + # Create max members (5 total including owner) + create_list(:family_membership, Family::MAX_MEMBERS - 1, family: family, role: :member) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'when user is already in a family' do + let(:existing_user) { create(:user, email: email) } + let(:other_family) { create(:family) } + + before do + create(:family_membership, user: existing_user, family: other_family) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'when pending invitation already exists' do + before do + create(:family_invitation, family: family, email: email, invited_by: owner) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create another invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'with invalid email' do + let(:service) { described_class.new(family: family, email: 'invalid-email', invited_by: owner) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'has validation errors' do + service.call + expect(service.errors[:email]).to be_present + end + end + end + + describe 'email normalization' do + let(:service) { described_class.new(family: family, email: ' UPPER@EXAMPLE.COM ', invited_by: owner) } + + it 'normalizes email to lowercase and strips whitespace' do + service.call + invitation = family.family_invitations.last + + expect(invitation.email).to eq('upper@example.com') + end + end + + describe 'validations' do + it 'validates presence of email' do + service = described_class.new(family: family, email: '', invited_by: owner) + expect(service).not_to be_valid + expect(service.errors[:email]).to include("can't be blank") + end + + it 'validates email format' do + service = described_class.new(family: family, email: 'invalid-email', invited_by: owner) + expect(service).not_to be_valid + expect(service.errors[:email]).to include('is invalid') + end + end +end diff --git a/spec/services/families/memberships/destroy_spec.rb b/spec/services/families/memberships/destroy_spec.rb new file mode 100644 index 00000000..ac2475e5 --- /dev/null +++ b/spec/services/families/memberships/destroy_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::Memberships::Destroy do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let(:service) { described_class.new(user: user) } + + describe '#call' do + context 'when user is a member (not owner)' do + let(:member) { create(:user) } + let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: member, family: family, role: :member) } + let(:service) { described_class.new(user: member) } + + it 'removes the membership' do + result = service.call + expect(result).to be_truthy, "Expected service to succeed but got error: #{service.error_message}" + expect(Family::Membership.count).to eq(1) # Only owner should remain + expect(member.reload.family_membership).to be_nil + end + + it 'sends notification to member who left' do + expect { service.call }.to change(Notification, :count).by(2) + + member_notification = member.notifications.last + expect(member_notification.title).to eq('Left Family') + expect(member_notification.content).to include(family.name) + end + + it 'sends notification to family owner' do + service.call + + owner_notification = user.notifications.last + expect(owner_notification.title).to eq('Family Member Left') + expect(owner_notification.content).to include(member.email) + expect(owner_notification.content).to include(family.name) + end + + it 'returns true' do + expect(service.call).to be true + end + end + + context 'when user is family owner with no other members' do + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + + it 'prevents owner from leaving' do + expect { service.call }.not_to change(Family::Membership, :count) + expect(user.reload.family_membership).to be_present + end + + it 'does not delete the family' do + expect { service.call }.not_to change(Family, :count) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'sets error message' do + service.call + expect(service.error_message).to include('cannot remove their own membership') + end + end + + context 'when user is family owner with other members' do + let(:member) { create(:user) } + let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: member, family: family, role: :member) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not remove membership' do + expect { service.call }.not_to change(Family::Membership, :count) + expect(user.reload.family_membership).to be_present + end + end + + context 'when user is not in a family' do + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create any notifications' do + expect { service.call }.not_to change(Notification, :count) + end + end + end +end diff --git a/spec/services/families/update_location_sharing_spec.rb b/spec/services/families/update_location_sharing_spec.rb new file mode 100644 index 00000000..72243d5b --- /dev/null +++ b/spec/services/families/update_location_sharing_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::UpdateLocationSharing do + include ActiveSupport::Testing::TimeHelpers + + describe '.call' do + subject(:call_service) do + described_class.new(user: user, enabled: enabled, duration: duration).call + end + + let(:duration) { '1h' } + + context 'when the user is in a family' do + let(:user) { create(:user) } + let!(:family_membership) { create(:family_membership, user: user) } + + context 'when enabling location sharing with a duration' do + let(:enabled) { true } + + around do |example| + travel_to(Time.zone.local(2024, 1, 1, 12, 0, 0)) { example.run } + end + + it 'returns a successful result with the expected payload' do + result = call_service + + expect(result).to be_success + expect(result.status).to eq(:ok) + expect(result.payload[:success]).to be true + expect(result.payload[:enabled]).to be true + expect(result.payload[:duration]).to eq('1h') + expect(result.payload[:message]).to eq('Location sharing enabled for 1 hour') + expect(result.payload[:expires_at]).to eq(1.hour.from_now.iso8601) + expect(result.payload[:expires_at_formatted]).to eq(1.hour.from_now.strftime('%b %d at %I:%M %p')) + end + end + + context 'when disabling location sharing' do + let(:enabled) { false } + let(:duration) { nil } + + it 'returns a successful result without expiration details' do + result = call_service + + expect(result).to be_success + expect(result.payload[:success]).to be true + expect(result.payload[:enabled]).to be false + expect(result.payload[:message]).to eq('Location sharing disabled') + expect(result.payload).not_to have_key(:expires_at) + expect(result.payload).not_to have_key(:expires_at_formatted) + end + end + + context 'when update raises an unexpected error' do + let(:enabled) { true } + + before do + allow(user).to receive(:update_family_location_sharing!).and_raise(StandardError, 'boom') + end + + it 'returns a failure result with internal server error status' do + result = call_service + + expect(result).not_to be_success + expect(result.status).to eq(:internal_server_error) + expect(result.payload[:success]).to be false + expect(result.payload[:message]).to eq('An error occurred while updating location sharing') + end + end + end + + context 'when the user is not in a family' do + let(:user) { create(:user) } + let(:enabled) { true } + + it 'returns a failure result with unprocessable content status' do + result = call_service + + expect(result).not_to be_success + expect(result.status).to eq(:unprocessable_content) + expect(result.payload[:success]).to be false + expect(result.payload[:message]).to eq('Failed to update location sharing setting') + end + end + end +end diff --git a/spec/services/google_maps/records_importer_spec.rb b/spec/services/google_maps/records_importer_spec.rb index e2761d4f..c0cf5033 100644 --- a/spec/services/google_maps/records_importer_spec.rb +++ b/spec/services/google_maps/records_importer_spec.rb @@ -17,6 +17,12 @@ RSpec.describe GoogleMaps::RecordsImporter do 'accuracy' => 10, 'altitude' => 100, 'verticalAccuracy' => 5, + 'heading' => 270, + 'velocity' => 15, + 'batteryCharging' => true, + 'source' => 'GPS', + 'deviceTag' => 1234567890, + 'platformType' => 'ANDROID', 'activity' => [ { 'timestampMs' => (time.to_f * 1000).to_i.to_s, @@ -111,5 +117,87 @@ RSpec.describe GoogleMaps::RecordsImporter do expect(created_point.timestamp).to eq(time.to_i) end end + + context 'with additional Records.json schema fields' do + let(:locations) do + [ + { + 'timestamp' => time.iso8601, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789, + 'accuracy' => 20, + 'altitude' => 150, + 'verticalAccuracy' => 10, + 'heading' => 270, + 'velocity' => 10, + 'batteryCharging' => true, + 'source' => 'WIFI', + 'deviceTag' => 1234567890, + 'platformType' => 'ANDROID' + } + ] + end + + it 'extracts all supported fields' do + expect { parser }.to change(Point, :count).by(1) + + created_point = Point.last + expect(created_point.accuracy).to eq(20) + expect(created_point.altitude).to eq(150) + expect(created_point.vertical_accuracy).to eq(10) + expect(created_point.course).to eq(270) + expect(created_point.velocity).to eq('10') + expect(created_point.battery).to eq(1) # true -> 1 + end + + it 'stores all fields in raw_data' do + parser + created_point = Point.last + + expect(created_point.raw_data['source']).to eq('WIFI') + expect(created_point.raw_data['deviceTag']).to eq(1234567890) + expect(created_point.raw_data['platformType']).to eq('ANDROID') + end + end + + context 'with batteryCharging false' do + let(:locations) do + [ + { + 'timestamp' => time.iso8601, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789, + 'batteryCharging' => false + } + ] + end + + it 'stores battery as 0' do + expect { parser }.to change(Point, :count).by(1) + expect(Point.last.battery).to eq(0) + end + end + + context 'with missing optional fields' do + let(:locations) do + [ + { + 'timestamp' => time.iso8601, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789 + } + ] + end + + it 'handles missing fields gracefully' do + expect { parser }.to change(Point, :count).by(1) + + created_point = Point.last + expect(created_point.accuracy).to be_nil + expect(created_point.vertical_accuracy).to be_nil + expect(created_point.course).to be_nil + expect(created_point.battery).to be_nil + end + end end end diff --git a/spec/services/users/safe_settings_spec.rb b/spec/services/users/safe_settings_spec.rb index 573009c9..45ac2fec 100644 --- a/spec/services/users/safe_settings_spec.rb +++ b/spec/services/users/safe_settings_spec.rb @@ -29,7 +29,8 @@ RSpec.describe Users::SafeSettings do distance_unit: 'km', visits_suggestions_enabled: true, speed_color_scale: nil, - fog_of_war_threshold: nil + fog_of_war_threshold: nil, + enabled_map_layers: ['Routes', 'Heatmap'] } ) end @@ -53,7 +54,8 @@ RSpec.describe Users::SafeSettings do 'photoprism_url' => 'https://photoprism.example.com', 'photoprism_api_key' => 'photoprism-key', 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }, - 'visits_suggestions_enabled' => false + 'visits_suggestions_enabled' => false, + 'enabled_map_layers' => ['Points', 'Routes', 'Areas', 'Photos'] } end let(:safe_settings) { described_class.new(settings) } @@ -76,7 +78,8 @@ RSpec.describe Users::SafeSettings do "photoprism_url" => "https://photoprism.example.com", "photoprism_api_key" => "photoprism-key", "maps" => { "name" => "custom", "url" => "https://custom.example.com" }, - "visits_suggestions_enabled" => false + "visits_suggestions_enabled" => false, + "enabled_map_layers" => ['Points', 'Routes', 'Areas', 'Photos'] } ) end @@ -102,7 +105,8 @@ RSpec.describe Users::SafeSettings do distance_unit: nil, visits_suggestions_enabled: false, speed_color_scale: nil, - fog_of_war_threshold: nil + fog_of_war_threshold: nil, + enabled_map_layers: ['Points', 'Routes', 'Areas', 'Photos'] } ) end @@ -132,6 +136,7 @@ RSpec.describe Users::SafeSettings do expect(safe_settings.photoprism_api_key).to be_nil expect(safe_settings.maps).to eq({ "distance_unit" => "km" }) expect(safe_settings.visits_suggestions_enabled?).to be true + expect(safe_settings.enabled_map_layers).to eq(['Routes', 'Heatmap']) end end @@ -153,7 +158,8 @@ RSpec.describe Users::SafeSettings do 'photoprism_url' => 'https://photoprism.example.com', 'photoprism_api_key' => 'photoprism-key', 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }, - 'visits_suggestions_enabled' => false + 'visits_suggestions_enabled' => false, + 'enabled_map_layers' => ['Points', 'Tracks', 'Fog of War', 'Suggested Visits'] } end @@ -174,6 +180,7 @@ RSpec.describe Users::SafeSettings do expect(safe_settings.photoprism_api_key).to eq('photoprism-key') expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' }) expect(safe_settings.visits_suggestions_enabled?).to be false + expect(safe_settings.enabled_map_layers).to eq(['Points', 'Tracks', 'Fog of War', 'Suggested Visits']) end end end