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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..597bf48a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `app/` holds the Rails application: controllers and views under feature-oriented folders, `services/` for importers and background workflows, and `policies/` for Pundit authorization. +- `app/javascript/` contains Stimulus controllers (`controllers/`), map widgets (`maps/`), and Tailwind/Turbo setup in `application.js`. +- `lib/` stores reusable support code and rake tasks, while `config/` tracks environment settings, credentials, and initializers. +- `db/` carries schema migrations and data migrations; `spec/` provides RSpec coverage; `e2e/` hosts Playwright scenarios; `docker/` bundles deployment compose files. + +## Build, Test, and Development Commands +- `bundle exec rails db:prepare` initializes or migrates the PostgreSQL database. +- `bundle exec bin/dev` starts the Rails app plus JS bundler via Foreman using `Procfile.dev` (set `PROMETHEUS_EXPORTER_ENABLED=true` to use the Prometheus profile). +- `bundle exec sidekiq` runs background jobs locally alongside the web server. +- `docker compose -f docker/docker-compose.yml up` brings up the containerized stack for end-to-end smoke checks. + +## Coding Style & Naming Conventions +- Follow default Ruby style with two-space indentation and snake_case filenames; run `bin/rubocop` before pushing. +- JavaScript modules in `app/javascript/` use ES modules and Stimulus naming (`*_controller.js`); keep exports camelCase and limit files to a single controller. +- Tailwind classes power the UI; co-locate shared styles under `app/javascript/styles/` rather than inline overrides. + +## Testing Guidelines +- Use `bundle exec rspec` for unit and feature specs; mirror production behavior by tagging jobs or services with factories in `spec/support`. +- End-to-end flows live in `e2e/`; execute `npx playwright test` (set `BASE_URL` if the server runs on a non-default port). +- Commit failing scenarios together with the fix, and prefer descriptive `it "..."` strings that capture user intent. + +## Commit & Pull Request Guidelines +- Write concise, imperative commit titles (e.g., `Add family sharing policy`); group related changes rather than omnibus commits. +- Target pull requests at the `dev` branch, describe the motivation, reference GitHub issues when applicable, and attach screenshots for UI-facing changes. +- Confirm CI, lint, and test status before requesting review; call out migrations or data tasks in the PR checklist. + +## Environment & Configuration Tips +- Copy `.env.example` to `.env` or rely on Docker secrets to supply API keys, map tokens, and mail credentials. +- Regenerate credentials with `bin/rails credentials:edit` when altering secrets, and avoid committing any generated `.env` or `credentials.yml.enc` changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc0f48b..41b47751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,26 @@ 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 + +## Changed + +- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840 + + +# [0.33.1] - 2025-10-07 ## Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1470f1e..dc0d96fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ ## How to contribute to Dawarich +Refer to [Repository Guidelines](AGENTS.md) for structure, tooling, and workflow expectations before submitting changes. + #### **Did you find a bug?** * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Freika/dawarich/issues). 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..d74cce84 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;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( --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;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-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;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-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%)}.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%)}.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-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-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-10{height:2.5rem}.h-12{height:3rem}.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%}.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-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}.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-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\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\: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}}@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..09c77b51 100644 --- a/app/assets/stylesheets/leaflet_theme.css +++ b/app/assets/stylesheets/leaflet_theme.css @@ -138,4 +138,52 @@ background: var(--leaflet-scale-bg) !important; border-radius: 3px !important; padding: 2px !important; +} + +/* 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%; } \ No newline at end of file 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-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/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/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-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/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..4520d3af --- /dev/null +++ b/app/channels/family_locations_channel.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class FamilyLocationsChannel < ApplicationCable::Channel + def subscribed + return reject unless 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 + + private + + def family_feature_enabled? + DawarichSettings.family_feature_enabled? + 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/application_controller.rb b/app/controllers/application_controller.rb index 03352f1a..515aebef 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -56,6 +56,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 @@ -69,8 +75,8 @@ class ApplicationController < ActionController::Base end def user_not_authorized - redirect_back fallback_location: root_path, - alert: 'You are not authorized to perform this action.', - status: :see_other + redirect_to (request.referer || root_path), + alert: 'You are not authorized to perform this action.', + status: :see_other end end 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..040451c1 --- /dev/null +++ b/app/controllers/family/invitations_controller.rb @@ -0,0 +1,76 @@ +# 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 + @invitation = Family::Invitation.find_by!(token: params[: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..a236ac23 --- /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 + if @invitation.expired? + redirect_to root_path, alert: 'This invitation is no longer valid or has expired' + elsif !@invitation.pending? + redirect_to root_path, alert: 'This invitation has already been processed' + elsif @invitation.email != current_user.email + redirect_to root_path, alert: 'This invitation is not for your email address' + else + redirect_to root_path, alert: 'You are not authorized to accept this invitation' + end + 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/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..151bddc5 --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Users::SessionsController < Devise::SessionsController + before_action :load_invitation_context, only: [:new] + + def new + super + end + + protected + + def after_sign_in_path_for(resource) + if invitation_token.present? + invitation = Family::Invitation.find_by(token: invitation_token) + + if invitation&.can_be_accepted? + return family_invitation_path(invitation.token) + end + end + + super(resource) + end + + private + + def load_invitation_context + return unless invitation_token.present? + + @invitation = Family::Invitation.find_by(token: invitation_token) + 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/family_members_controller.js b/app/javascript/controllers/family_members_controller.js new file mode 100644 index 00000000..b77a9273 --- /dev/null +++ b/app/javascript/controllers/family_members_controller.js @@ -0,0 +1,486 @@ +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); + 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); + 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); + 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) { + return `Last updated: ${lastSeen}`; + } + + 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() || '?'; + + return ` +
+

+ ${emailInitial} + Family Member +

+

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

+

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

+

+ Last updated: ${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 + }); + } + + 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; + } +} \ No newline at end of file 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/maps_controller.js b/app/javascript/controllers/maps_controller.js index 8e623b95..9bfa2e45 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -206,6 +206,9 @@ export default class extends BaseController { // Expose visits manager globally for location search integration window.visitsManager = this.visitsManager; + // Expose maps controller globally for family integration + window.mapsController = this; + // Initialize layers for the layer control const controlsLayer = { Points: this.markersLayer, @@ -1089,7 +1092,15 @@ export default class extends BaseController { const TogglePanelControl = L.Control.extend({ onAdd: function(map) { const button = L.DomUtil.create('button', 'toggle-panel-button'); - button.innerHTML = '📅'; + button.innerHTML = ` + + + + + + + + `; // Style the button with theme-aware styling applyThemeToButton(button, controller.userTheme); @@ -1097,9 +1108,9 @@ export default class extends BaseController { 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.display = 'flex'; + button.style.alignItems = 'center'; + button.style.justifyContent = 'center'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); @@ -1839,4 +1850,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/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/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..c5d5d337 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class User < ApplicationRecord # rubocop:disable Metrics/ClassLength + include UserFamily devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable 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..2e4f2296 --- /dev/null +++ b/app/services/families/locations.rb @@ -0,0 +1,48 @@ +# 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) + } + 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/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/families/edit.html.erb b/app/views/families/edit.html.erb new file mode 100644 index 00000000..89451916 --- /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: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', 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..4ab423f8 --- /dev/null +++ b/app/views/families/show.html.erb @@ -0,0 +1,249 @@ +
+
+ +
+
+
+

<%= @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: { confirm: 'Are you sure you want to leave this family?', 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: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', 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: + + + + 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: + <% 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: { confirm: 'Are you sure you want to cancel this invitation?', turbo_confirm: 'Are you sure you want to cancel this invitation?' }, + class: "btn btn-outline btn-warning btn-sm opacity-70" 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? %> + +
+
+ + + +
+

+ 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..1831ae15 --- /dev/null +++ b/app/views/family/invitations/index.html.erb @@ -0,0 +1,57 @@ +
+
+
+
+

+ <%= 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..fac38927 --- /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 mr-2" %> + + 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/map/index.html.erb b/app/views/map/index.html.erb index 412741cd..3bd5fd5f 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -9,7 +9,7 @@
<%= 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 %> - ◀️ + <%= icon 'chevron-left' %> <% end %>
@@ -30,7 +30,7 @@
<%= 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 %> - ▶️ + <%= icon 'chevron-right' %> <% end %>
@@ -63,7 +63,7 @@
+ data-features='<%= @features.to_json.html_safe %>' + data-family-members-features-value='<%= @features.to_json.html_safe %>' + data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
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..360bbca8 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -8,6 +8,23 @@
  • <%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %>
  • <%= link_to 'Tripsα'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %>
  • <%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %>
  • + <% if user_signed_in? && DawarichSettings.family_feature_enabled? %> +
  • + <% if current_user.in_family? %> +
    + <%= link_to family_path, class: "#{active_class?(family_path)} flex items-center space-x-2" do %> + Family +
    + <% end %> +
    + <% else %> + <%= link_to 'Familyα'.html_safe, new_family_path, class: "#{active_class?(new_family_path)}" %> + <% end %> +
  • + <% end %>
  • My data @@ -56,6 +73,24 @@
  • <%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %>
  • <%= link_to 'Tripsα'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %>
  • <%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %>
  • + <% if user_signed_in? && DawarichSettings.family_feature_enabled? %> +
  • + <% if current_user.in_family? %> +
    + <%= link_to family_path, class: "mx-1 flex items-center space-x-2" do %> + Familyα +
    + <% end %> +
    + <% else %> + <%= link_to 'Familyα'.html_safe, new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %> + <% end %> +
  • + <% end %>
  • My data @@ -121,7 +156,8 @@
  • - <%= "#{current_user.email}" %> + + <%= icon 'user' %> <% if onboarding_modal_showable?(current_user) %> <% end %> diff --git a/app/views/stats/_reverse_geocoding_stats.html.erb b/app/views/stats/_reverse_geocoding_stats.html.erb index 11d3768f..7d387ec9 100644 --- a/app/views/stats/_reverse_geocoding_stats.html.erb +++ b/app/views/stats/_reverse_geocoding_stats.html.erb @@ -38,17 +38,18 @@ <%= current_user.total_cities %>
  • Cities visited
    - - - - + + + + +
    diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 926b5b5e..7b562e40 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -16,10 +16,9 @@
    Geopoints tracked
    - <% 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/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/migrate/20250928000001_add_family_performance_indexes.rb b/db/migrate/20250928000001_add_family_performance_indexes.rb new file mode 100644 index 00000000..3022160d --- /dev/null +++ b/db/migrate/20250928000001_add_family_performance_indexes.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class AddFamilyPerformanceIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + # Index for family invitations queries + unless index_exists?(:family_invitations, %i[family_id status expires_at], + name: 'index_family_invitations_on_family_status_expires') + add_index :family_invitations, %i[family_id status expires_at], + name: 'index_family_invitations_on_family_status_expires', + algorithm: :concurrently + end + + # Index for family membership queries by role + unless index_exists?(:family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role') + add_index :family_memberships, %i[family_id role], + name: 'index_family_memberships_on_family_and_role', + algorithm: :concurrently + end + + # Composite index for active invitations + unless index_exists?(:family_invitations, %i[status expires_at], + name: 'index_family_invitations_on_status_and_expires_at') + add_index :family_invitations, %i[status expires_at], + name: 'index_family_invitations_on_status_and_expires_at', + algorithm: :concurrently + end + + # Cleanup job support for status and updated_at + unless index_exists?(:family_invitations, %i[status updated_at], + name: 'index_family_invitations_on_status_and_updated_at') + add_index :family_invitations, %i[status updated_at], + name: 'index_family_invitations_on_status_and_updated_at', + algorithm: :concurrently + end + 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/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/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..add2d1aa --- /dev/null +++ b/spec/requests/users/registrations_spec.rb @@ -0,0 +1,323 @@ +# 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) { create(:family_invitation, family: family, invited_by: family_owner, email: 'invited@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 '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(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').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 + expect do + post user_registration_path, params: { + user: { + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123' + } + } + end.to change(User, :count).by(1) + + expect(response).to redirect_to(root_path) + 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