From 1df8d66d5d98244dcce1e25c1ea763161b4d7f41 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 21 Jun 2026 23:46:37 +0200 Subject: [PATCH] discount profiles and discounts overall implemented and working --- assets/i18n/en/main.ftl | 17 + assets/i18n/sk/main.ftl | 17 + assets/static/css/app.css | 2 +- assets/views/admin/base.html | 4 + .../admin/catalog/discount_profile_form.html | 71 +++++ .../admin/catalog/discount_profiles.html | 58 ++++ assets/views/admin/customers/show.html | 50 ++- migration/src/lib.rs | 2 + .../src/m20260621_000003_discount_profiles.rs | 91 ++++++ src/app.rs | 7 +- src/controllers/admin_customers.rs | 167 ++++++++-- src/controllers/admin_discount_profiles.rs | 298 ++++++++++++++++++ src/controllers/admin_discounts.rs | 8 +- src/controllers/mod.rs | 1 + .../_entities/account_discount_profiles.rs | 48 +++ .../_entities/account_product_resolutions.rs | 64 ++++ .../_entities/discount_profile_products.rs | 48 +++ src/models/_entities/discount_profiles.rs | 39 +++ src/models/_entities/mod.rs | 4 + src/models/_entities/prelude.rs | 4 + src/models/account_discount_profiles.rs | 16 + src/models/account_product_resolutions.rs | 19 ++ src/models/discount_profile_products.rs | 17 + src/models/discount_profiles.rs | 42 +++ src/models/mod.rs | 4 + src/shared/money.rs | 38 +++ src/shared/pricing.rs | 270 ++++++++++++---- 27 files changed, 1317 insertions(+), 89 deletions(-) create mode 100644 assets/views/admin/catalog/discount_profile_form.html create mode 100644 assets/views/admin/catalog/discount_profiles.html create mode 100644 migration/src/m20260621_000003_discount_profiles.rs create mode 100644 src/controllers/admin_discount_profiles.rs create mode 100644 src/models/_entities/account_discount_profiles.rs create mode 100644 src/models/_entities/account_product_resolutions.rs create mode 100644 src/models/_entities/discount_profile_products.rs create mode 100644 src/models/_entities/discount_profiles.rs create mode 100644 src/models/account_discount_profiles.rs create mode 100644 src/models/account_product_resolutions.rs create mode 100644 src/models/discount_profile_products.rs create mode 100644 src/models/discount_profiles.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 71c9f2f..f64a9f1 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -237,6 +237,23 @@ manage-prices = Manage prices public-price = Public price negotiated-price = Negotiated price effective-price = Effective price +admin-discount-profiles = Discount profiles +admin-discount-profiles-desc = Create reusable discount layers (a % over chosen products) and assign them to business accounts. +admin-no-profiles = No discount profiles yet. +new-profile = New profile +edit-profile = Edit profile +profile-name-required = Profile name is required. +scope = Scope +products = Products +scope-include = Selected products +scope-all-except = All except selected +scope-include-hint = Applies only to the products selected below. +scope-all-except-hint = Applies to every product except those selected below. +automated-price = Automated price +discount-profiles = Discount profiles +collision = Conflict +resolve = Resolve +no-profiles-assigned = No profiles assigned. stock = Stock sku = SKU currency = Currency diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 3450702..e5c054e 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -237,6 +237,23 @@ manage-prices = Spravovať ceny public-price = Verejná cena negotiated-price = Dohodnutá cena effective-price = Výsledná cena +admin-discount-profiles = Zľavové profily +admin-discount-profiles-desc = Vytvorte opakovane použiteľné zľavové vrstvy (% na vybrané produkty) a priraďte ich firemným účtom. +admin-no-profiles = Zatiaľ žiadne zľavové profily. +new-profile = Nový profil +edit-profile = Upraviť profil +profile-name-required = Názov profilu je povinný. +scope = Rozsah +products = Produkty +scope-include = Vybrané produkty +scope-all-except = Všetky okrem vybraných +scope-include-hint = Platí len pre vybrané produkty nižšie. +scope-all-except-hint = Platí pre všetky produkty okrem vybraných nižšie. +automated-price = Automatická cena +discount-profiles = Zľavové profily +collision = Konflikt +resolve = Vyriešiť +no-profiles-assigned = Žiadne priradené profily. stock = Sklad sku = Kód (SKU) currency = Mena diff --git a/assets/static/css/app.css b/assets/static/css/app.css index 7adb520..a4ed9c3 100644 --- a/assets/static/css/app.css +++ b/assets/static/css/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-600:oklch(57.7% .245 27.325);--color-amber-500:oklch(76.9% .188 70.08);--color-green-600:oklch(62.7% .194 149.214);--color-emerald-600:oklch(59.6% .145 163.225);--color-sky-500:oklch(68.5% .169 237.323);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-600:oklch(51.1% .262 276.966);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface:var(--color-white);--color-surface-alt:var(--color-slate-100);--color-on-surface:var(--color-slate-700);--color-on-surface-strong:var(--color-slate-900);--color-primary:var(--color-indigo-600);--color-on-primary:var(--color-white);--color-secondary:var(--color-slate-600);--color-on-secondary:var(--color-white);--color-outline:var(--color-slate-300);--color-outline-strong:var(--color-slate-800);--color-surface-dark:var(--color-slate-900);--color-surface-dark-alt:var(--color-slate-800);--color-on-surface-dark:var(--color-slate-300);--color-on-surface-dark-strong:var(--color-white);--color-primary-dark:var(--color-indigo-400);--color-on-primary-dark:var(--color-slate-950);--color-secondary-dark:var(--color-slate-300);--color-on-secondary-dark:var(--color-slate-950);--color-outline-dark:var(--color-slate-700);--color-outline-dark-strong:var(--color-slate-300);--color-info:var(--color-sky-500);--color-on-info:var(--color-white);--color-success:var(--color-green-600);--color-on-success:var(--color-white);--color-warning:var(--color-amber-500);--color-on-warning:var(--color-white);--color-danger:var(--color-red-600);--color-on-danger:var(--color-white);--radius-radius:.375rem}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-x-8{inset-inline:calc(var(--spacing) * 8)}.inset-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.top-1\/2{top:50%}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-99{z-index:99}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-48{width:calc(var(--spacing) * 48);height:calc(var(--spacing) * 48)}.size-full{width:100%;height:100%}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.h-44{height:calc(var(--spacing) * 44)}.h-fit{height:fit-content}.h-px{height:1px}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-80{max-height:calc(var(--spacing) * 80)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-11{width:calc(var(--spacing) * 11)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-56{width:calc(var(--spacing) * 56)}.w-60{width:calc(var(--spacing) * 60)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-24{--tw-translate-x:calc(var(--spacing) * -24);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-60{--tw-translate-x:calc(var(--spacing) * -60);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-8{--tw-translate-y:calc(var(--spacing) * 8);translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-0{rotate:0deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.appearance-none{appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 12) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:var(--spacing)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-outline>:not(:last-child)){border-color:var(--color-outline)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-radius{border-radius:var(--radius-radius)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-l-radius{border-top-left-radius:var(--radius-radius);border-bottom-left-radius:var(--radius-radius)}.rounded-r-radius{border-top-right-radius:var(--radius-radius);border-bottom-right-radius:var(--radius-radius)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-danger{border-color:var(--color-danger)}.border-danger\/40{border-color:#e4001466}@supports (color:color-mix(in lab, red, red)){.border-danger\/40{border-color:color-mix(in oklab, var(--color-danger) 40%, transparent)}}.border-info{border-color:var(--color-info)}.border-outline{border-color:var(--color-outline)}.border-primary{border-color:var(--color-primary)}.border-primary\/40{border-color:#4f39f666}@supports (color:color-mix(in lab, red, red)){.border-primary\/40{border-color:color-mix(in oklab, var(--color-primary) 40%, transparent)}}.border-secondary{border-color:var(--color-secondary)}.border-success{border-color:var(--color-success)}.border-warning{border-color:var(--color-warning)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-danger{background-color:var(--color-danger)}.bg-danger\/5{background-color:#e400140d}@supports (color:color-mix(in lab, red, red)){.bg-danger\/5{background-color:color-mix(in oklab, var(--color-danger) 5%, transparent)}}.bg-danger\/10{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.bg-danger\/10{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.bg-danger\/15{background-color:#e4001426}@supports (color:color-mix(in lab, red, red)){.bg-danger\/15{background-color:color-mix(in oklab, var(--color-danger) 15%, transparent)}}.bg-info{background-color:var(--color-info)}.bg-info\/10{background-color:#00a5ef1a}@supports (color:color-mix(in lab, red, red)){.bg-info\/10{background-color:color-mix(in oklab, var(--color-info) 10%, transparent)}}.bg-info\/15{background-color:#00a5ef26}@supports (color:color-mix(in lab, red, red)){.bg-info\/15{background-color:color-mix(in oklab, var(--color-info) 15%, transparent)}}.bg-outline{background-color:var(--color-outline)}.bg-primary{background-color:var(--color-primary)}.bg-primary\/5{background-color:#4f39f60d}@supports (color:color-mix(in lab, red, red)){.bg-primary\/5{background-color:color-mix(in oklab, var(--color-primary) 5%, transparent)}}.bg-primary\/10{background-color:#4f39f61a}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.bg-secondary{background-color:var(--color-secondary)}.bg-success{background-color:var(--color-success)}.bg-success\/10{background-color:#00a5441a}@supports (color:color-mix(in lab, red, red)){.bg-success\/10{background-color:color-mix(in oklab, var(--color-success) 10%, transparent)}}.bg-success\/15{background-color:#00a54426}@supports (color:color-mix(in lab, red, red)){.bg-success\/15{background-color:color-mix(in oklab, var(--color-success) 15%, transparent)}}.bg-surface{background-color:var(--color-surface)}.bg-surface-alt{background-color:var(--color-surface-alt)}.bg-surface-alt\/40{background-color:#f1f5f966}@supports (color:color-mix(in lab, red, red)){.bg-surface-alt\/40{background-color:color-mix(in oklab, var(--color-surface-alt) 40%, transparent)}}.bg-surface-alt\/50{background-color:#f1f5f980}@supports (color:color-mix(in lab, red, red)){.bg-surface-alt\/50{background-color:color-mix(in oklab, var(--color-surface-alt) 50%, transparent)}}.bg-surface\/40{background-color:#fff6}@supports (color:color-mix(in lab, red, red)){.bg-surface\/40{background-color:color-mix(in oklab, var(--color-surface) 40%, transparent)}}.bg-surface\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-surface\/95{background-color:color-mix(in oklab, var(--color-surface) 95%, transparent)}}.bg-transparent{background-color:#0000}.bg-warning{background-color:var(--color-warning)}.bg-warning\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-warning\/10{background-color:color-mix(in oklab, var(--color-warning) 10%, transparent)}}.bg-warning\/15{background-color:#f99c0026}@supports (color:color-mix(in lab, red, red)){.bg-warning\/15{background-color:color-mix(in oklab, var(--color-warning) 15%, transparent)}}.bg-white{background-color:var(--color-white)}.object-cover{object-fit:cover}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pr-7{padding-right:calc(var(--spacing) * 7)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-6{padding-left:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-danger{color:var(--color-danger)}.text-info{color:var(--color-info)}.text-on-danger{color:var(--color-on-danger)}.text-on-info{color:var(--color-on-info)}.text-on-primary{color:var(--color-on-primary)}.text-on-primary\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-on-primary\/90{color:color-mix(in oklab, var(--color-on-primary) 90%, transparent)}}.text-on-secondary{color:var(--color-on-secondary)}.text-on-success{color:var(--color-on-success)}.text-on-surface{color:var(--color-on-surface)}.text-on-surface-strong{color:var(--color-on-surface-strong)}.text-on-surface\/40{color:#31415866}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/40{color:color-mix(in oklab, var(--color-on-surface) 40%, transparent)}}.text-on-surface\/50{color:#31415880}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/50{color:color-mix(in oklab, var(--color-on-surface) 50%, transparent)}}.text-on-surface\/60{color:#31415899}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/60{color:color-mix(in oklab, var(--color-on-surface) 60%, transparent)}}.text-on-surface\/70{color:#314158b3}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/70{color:color-mix(in oklab, var(--color-on-surface) 70%, transparent)}}.text-on-surface\/80{color:#314158cc}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/80{color:color-mix(in oklab, var(--color-on-surface) 80%, transparent)}}.text-on-warning{color:var(--color-on-warning)}.text-outline{color:var(--color-outline)}.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-secondary)}.text-success{color:var(--color-success)}.text-warning{color:var(--color-warning)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.outline-danger{outline-color:var(--color-danger)}.outline-primary{outline-color:var(--color-primary)}.outline-secondary{outline-color:var(--color-secondary)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}@media (hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.peer-checked\:visible:is(:where(.peer):checked~*){visibility:visible}.peer-checked\:bg-primary:is(:where(.peer):checked~*){background-color:var(--color-primary)}.peer-focus\:outline-2:is(:where(.peer):focus~*){outline-style:var(--tw-outline-style);outline-width:2px}.peer-focus\:outline-offset-2:is(:where(.peer):focus~*){outline-offset:2px}.peer-focus\:outline-outline-strong:is(:where(.peer):focus~*){outline-color:var(--color-outline-strong)}.peer-focus\:peer-checked\:outline-primary:is(:where(.peer):focus~*):is(:where(.peer):checked~*){outline-color:var(--color-primary)}.peer-active\:outline-offset-0:is(:where(.peer):active~*){outline-offset:0px}.file\:mr-4::file-selector-button{margin-right:calc(var(--spacing) * 4)}.file\:border-none::file-selector-button{--tw-border-style:none;border-style:none}.file\:bg-surface-alt::file-selector-button{background-color:var(--color-surface-alt)}.file\:px-4::file-selector-button{padding-inline:calc(var(--spacing) * 4)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-on-surface-strong::file-selector-button{color:var(--color-on-surface-strong)}.before\:invisible:before{content:var(--tw-content);visibility:hidden}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:inset-0:before{content:var(--tw-content);inset:0}.before\:top-1\/2:before{content:var(--tw-content);top:50%}.before\:left-1\/2:before{content:var(--tw-content);left:50%}.before\:h-1\.5:before{content:var(--tw-content);height:calc(var(--spacing) * 1.5)}.before\:w-1\.5:before{content:var(--tw-content);width:calc(var(--spacing) * 1.5)}.before\:-translate-x-1\/2:before{content:var(--tw-content);--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.before\:-translate-y-1\/2:before{content:var(--tw-content);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:bg-on-primary:before{content:var(--tw-content);background-color:var(--color-on-primary)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-0:after{content:var(--tw-content);top:0}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-\[0\.0625rem\]:after{content:var(--tw-content);left:.0625rem}.after\:my-auto:after{content:var(--tw-content);margin-block:auto}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:bg-on-surface:after{content:var(--tw-content);background-color:var(--color-on-surface)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-5:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * 5);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:bg-on-primary:is(:where(.peer):checked~*):after{content:var(--tw-content);background-color:var(--color-on-primary)}.checked\:border-primary:checked{border-color:var(--color-primary)}.checked\:bg-primary:checked{background-color:var(--color-primary)}.checked\:before\:visible:checked:before{content:var(--tw-content);visibility:visible}.checked\:before\:bg-primary:checked:before{content:var(--tw-content);background-color:var(--color-primary)}@media (hover:hover){.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:bg-danger\/5:hover{background-color:#e400140d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-danger\/5:hover{background-color:color-mix(in oklab, var(--color-danger) 5%, transparent)}}.hover\:bg-info\/5:hover{background-color:#00a5ef0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-info\/5:hover{background-color:color-mix(in oklab, var(--color-info) 5%, transparent)}}.hover\:bg-primary\/5:hover{background-color:#4f39f60d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/5:hover{background-color:color-mix(in oklab, var(--color-primary) 5%, transparent)}}.hover\:bg-surface-alt:hover{background-color:var(--color-surface-alt)}.hover\:bg-surface-dark-alt\/5:hover{background-color:#1d293d0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface-dark-alt\/5:hover{background-color:color-mix(in oklab, var(--color-surface-dark-alt) 5%, transparent)}}.hover\:bg-surface\/60:hover{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface\/60:hover{background-color:color-mix(in oklab, var(--color-surface) 60%, transparent)}}.hover\:text-on-surface-strong:hover{color:var(--color-on-surface-strong)}.hover\:text-primary:hover{color:var(--color-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-75:hover{opacity:.75}.hover\:opacity-90:hover{opacity:.9}}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-offset-2:focus{outline-offset:2px}.focus\:outline-outline-strong:focus{outline-color:var(--color-outline-strong)}.focus\:outline-primary:focus,.checked\:focus\:outline-primary:checked:focus{outline-color:var(--color-primary)}.focus-visible\:bg-primary\/10:focus-visible{background-color:#4f39f61a}@supports (color:color-mix(in lab, red, red)){.focus-visible\:bg-primary\/10:focus-visible{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.focus-visible\:bg-surface-dark-alt\/10:focus-visible{background-color:#1d293d1a}@supports (color:color-mix(in lab, red, red)){.focus-visible\:bg-surface-dark-alt\/10:focus-visible{background-color:color-mix(in oklab, var(--color-surface-dark-alt) 10%, transparent)}}.focus-visible\:text-on-surface-strong:focus-visible{color:var(--color-on-surface-strong)}.focus-visible\:underline:focus-visible{text-decoration-line:underline}.focus-visible\:outline-hidden:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus-visible\:outline-hidden:focus-visible{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-danger:focus-visible{outline-color:var(--color-danger)}.focus-visible\:outline-info:focus-visible{outline-color:var(--color-info)}.focus-visible\:outline-outline:focus-visible{outline-color:var(--color-outline)}.focus-visible\:outline-primary:focus-visible{outline-color:var(--color-primary)}.focus-visible\:outline-secondary:focus-visible{outline-color:var(--color-secondary)}.focus-visible\:outline-success:focus-visible{outline-color:var(--color-success)}.focus-visible\:outline-warning:focus-visible{outline-color:var(--color-warning)}.active\:opacity-100:active{opacity:1}.active\:outline-offset-0:active{outline-offset:0px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-75:disabled{opacity:.75}.has-checked\:text-on-surface-strong:has(:checked){color:var(--color-on-surface-strong)}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-75:has(:disabled){opacity:.75}.has-\[\:checked\]\:border-primary:has(:checked){border-color:var(--color-primary)}.aria-\[current\=page\]\:bg-primary\/10[aria-current=page]{background-color:#4f39f61a}@supports (color:color-mix(in lab, red, red)){.aria-\[current\=page\]\:bg-primary\/10[aria-current=page]{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.aria-\[current\=page\]\:font-semibold[aria-current=page]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.aria-\[current\=page\]\:text-on-surface-strong[aria-current=page]{color:var(--color-on-surface-strong)}.aria-\[current\=page\]\:text-primary[aria-current=page]{color:var(--color-primary)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:top-\[unset\]{top:unset}.md\:right-0{right:0}.md\:bottom-0{bottom:0}.md\:left-\[unset\]{left:unset}.md\:ml-60{margin-left:calc(var(--spacing) * 60)}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-64{height:calc(var(--spacing) * 64)}.md\:max-w-sm{max-width:var(--container-sm)}.md\:translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:translate-x-24{--tw-translate-x:calc(var(--spacing) * 24);translate:var(--tw-translate-x) var(--tw-translate-y)}}@media (min-width:64rem){.lg\:static{position:static}.lg\:z-auto{z-index:auto}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:hidden{display:none}.lg\:w-64{width:calc(var(--spacing) * 64)}.lg\:shrink-0{flex-shrink:0}.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\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:self-start{align-self:flex-start}.lg\:overflow-visible{overflow:visible}.lg\:rounded-radius{border-radius:var(--radius-radius)}.lg\:border{border-style:var(--tw-border-style);border-width:1px}.lg\:p-3{padding:calc(var(--spacing) * 3)}}@media (min-width:80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-outline-dark:where([data-theme=dark],[data-theme=dark] *)>:not(:last-child)){border-color:var(--color-outline-dark)}.dark\:border-danger:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-danger)}.dark\:border-info:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-info)}.dark\:border-outline-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-outline-dark)}.dark\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-primary-dark)}.dark\:border-primary-dark\/40:where([data-theme=dark],[data-theme=dark] *){border-color:#7d87ff66}@supports (color:color-mix(in lab, red, red)){.dark\:border-primary-dark\/40:where([data-theme=dark],[data-theme=dark] *){border-color:color-mix(in oklab, var(--color-primary-dark) 40%, transparent)}}.dark\:border-secondary-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-secondary-dark)}.dark\:border-success:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-success)}.dark\:border-warning:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-warning)}.dark\:bg-danger:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-danger)}.dark\:bg-info:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-info)}.dark\:bg-outline-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-outline-dark)}.dark\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-primary-dark)}.dark\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *){background-color:#7d87ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-primary-dark) 10%, transparent)}}.dark\:bg-secondary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-secondary-dark)}.dark\:bg-success:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-success)}.dark\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark)}.dark\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark-alt)}.dark\:bg-surface-dark-alt\/40:where([data-theme=dark],[data-theme=dark] *){background-color:#1d293d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark-alt\/40:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark-alt) 40%, transparent)}}.dark\:bg-surface-dark-alt\/50:where([data-theme=dark],[data-theme=dark] *){background-color:#1d293d80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark-alt\/50:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark-alt) 50%, transparent)}}.dark\:bg-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){background-color:#0f172b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark) 40%, transparent)}}.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:#0f172bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark) 95%, transparent)}}.dark\:bg-warning:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-warning)}.dark\:text-danger:where([data-theme=dark],[data-theme=dark] *){color:var(--color-danger)}.dark\:text-on-danger:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-danger)}.dark\:text-on-info:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-info)}.dark\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-primary-dark)}.dark\:text-on-primary-dark\/90:where([data-theme=dark],[data-theme=dark] *){color:#020618e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-primary-dark\/90:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-primary-dark) 90%, transparent)}}.dark\:text-on-secondary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-secondary-dark)}.dark\:text-on-success:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-success)}.dark\:text-on-surface-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark)}.dark\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark-strong)}.dark\:text-on-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 40%, transparent)}}.dark\:text-on-surface-dark\/50:where([data-theme=dark],[data-theme=dark] *){color:#cad5e280}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/50:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 50%, transparent)}}.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:#cad5e299}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 60%, transparent)}}.dark\:text-on-surface-dark\/70:where([data-theme=dark],[data-theme=dark] *){color:#cad5e2b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/70:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 70%, transparent)}}.dark\:text-on-surface-dark\/80:where([data-theme=dark],[data-theme=dark] *){color:#cad5e2cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/80:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 80%, transparent)}}.dark\:text-on-warning:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-warning)}.dark\:text-outline-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-outline-dark)}.dark\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-primary-dark)}.dark\:text-secondary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-secondary-dark)}.dark\:text-warning:where([data-theme=dark],[data-theme=dark] *){color:var(--color-warning)}.dark\:peer-checked\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):checked~*){background-color:var(--color-primary-dark)}.dark\:peer-focus\:outline-outline-dark-strong:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):focus~*){outline-color:var(--color-outline-dark-strong)}.dark\:peer-focus\:peer-checked\:outline-primary-dark:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):focus~*):is(:where(.peer):checked~*){outline-color:var(--color-primary-dark)}.dark\:file\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{background-color:var(--color-surface-dark-alt)}.dark\:file\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{color:var(--color-on-surface-dark-strong)}.dark\:before\:bg-on-primary-dark:where([data-theme=dark],[data-theme=dark] *):before{content:var(--tw-content);background-color:var(--color-on-primary-dark)}.dark\:after\:bg-on-surface-dark:where([data-theme=dark],[data-theme=dark] *):after{content:var(--tw-content);background-color:var(--color-on-surface-dark)}.dark\:peer-checked\:after\:bg-on-primary-dark:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):checked~*):after{content:var(--tw-content);background-color:var(--color-on-primary-dark)}.dark\:checked\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked{border-color:var(--color-primary-dark)}.dark\:checked\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked{background-color:var(--color-primary-dark)}.dark\:checked\:before\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked:before{content:var(--tw-content);background-color:var(--color-primary-dark)}@media (hover:hover){.dark\:hover\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{border-color:var(--color-primary-dark)}.dark\:hover\:bg-primary-dark\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:#7d87ff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-primary-dark\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:color-mix(in oklab, var(--color-primary-dark) 5%, transparent)}}.dark\:hover\:bg-surface-alt\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:#f1f5f90d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-surface-alt\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:color-mix(in oklab, var(--color-surface-alt) 5%, transparent)}}.dark\:hover\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark)}.dark\:hover\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark-alt)}.dark\:hover\:bg-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *):hover{background-color:#0f172b99}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *):hover{background-color:color-mix(in oklab, var(--color-surface-dark) 60%, transparent)}}.dark\:hover\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-on-surface-dark-strong)}.dark\:hover\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-primary-dark)}}.dark\:focus\:outline-outline-dark-strong:where([data-theme=dark],[data-theme=dark] *):focus{outline-color:var(--color-outline-dark-strong)}.dark\:checked\:focus\:outline-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked:focus{outline-color:var(--color-primary-dark)}.dark\:focus-visible\:bg-surface-alt\/10:where([data-theme=dark],[data-theme=dark] *):focus-visible{background-color:#f1f5f91a}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:bg-surface-alt\/10:where([data-theme=dark],[data-theme=dark] *):focus-visible{background-color:color-mix(in oklab, var(--color-surface-alt) 10%, transparent)}}.dark\:focus-visible\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *):focus-visible{color:var(--color-on-surface-dark-strong)}.dark\:focus-visible\:outline-danger:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-danger)}.dark\:focus-visible\:outline-info:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-info)}.dark\:focus-visible\:outline-outline-dark:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-outline-dark)}.dark\:focus-visible\:outline-primary-dark:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-primary-dark)}.dark\:focus-visible\:outline-secondary-dark:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-secondary-dark)}.dark\:focus-visible\:outline-success:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-success)}.dark\:focus-visible\:outline-warning:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-warning)}.dark\:has-checked\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *):has(:checked){color:var(--color-on-surface-dark-strong)}.dark\:has-\[\:checked\]\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):has(:checked){border-color:var(--color-primary-dark)}.dark\:aria-\[current\=page\]\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:#7d87ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:aria-\[current\=page\]\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:color-mix(in oklab, var(--color-primary-dark) 10%, transparent)}}.dark\:aria-\[current\=page\]\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-on-surface-dark-strong)}.dark\:aria-\[current\=page\]\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-primary-dark)}}[x-cloak]{display:none!important}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-content{syntax:"*";inherits:false;initial-value:""} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-600:oklch(57.7% .245 27.325);--color-amber-500:oklch(76.9% .188 70.08);--color-green-600:oklch(62.7% .194 149.214);--color-emerald-600:oklch(59.6% .145 163.225);--color-sky-500:oklch(68.5% .169 237.323);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-600:oklch(51.1% .262 276.966);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface:var(--color-white);--color-surface-alt:var(--color-slate-100);--color-on-surface:var(--color-slate-700);--color-on-surface-strong:var(--color-slate-900);--color-primary:var(--color-indigo-600);--color-on-primary:var(--color-white);--color-secondary:var(--color-slate-600);--color-on-secondary:var(--color-white);--color-outline:var(--color-slate-300);--color-outline-strong:var(--color-slate-800);--color-surface-dark:var(--color-slate-900);--color-surface-dark-alt:var(--color-slate-800);--color-on-surface-dark:var(--color-slate-300);--color-on-surface-dark-strong:var(--color-white);--color-primary-dark:var(--color-indigo-400);--color-on-primary-dark:var(--color-slate-950);--color-secondary-dark:var(--color-slate-300);--color-on-secondary-dark:var(--color-slate-950);--color-outline-dark:var(--color-slate-700);--color-outline-dark-strong:var(--color-slate-300);--color-info:var(--color-sky-500);--color-on-info:var(--color-white);--color-success:var(--color-green-600);--color-on-success:var(--color-white);--color-warning:var(--color-amber-500);--color-on-warning:var(--color-white);--color-danger:var(--color-red-600);--color-on-danger:var(--color-white);--radius-radius:.375rem}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-x-8{inset-inline:calc(var(--spacing) * 8)}.inset-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.top-1\/2{top:50%}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-99{z-index:99}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-48{width:calc(var(--spacing) * 48);height:calc(var(--spacing) * 48)}.size-full{width:100%;height:100%}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.h-44{height:calc(var(--spacing) * 44)}.h-fit{height:fit-content}.h-px{height:1px}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-11{width:calc(var(--spacing) * 11)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-56{width:calc(var(--spacing) * 56)}.w-60{width:calc(var(--spacing) * 60)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-24{--tw-translate-x:calc(var(--spacing) * -24);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-60{--tw-translate-x:calc(var(--spacing) * -60);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-8{--tw-translate-y:calc(var(--spacing) * 8);translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-0{rotate:0deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.appearance-none{appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 12) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:var(--spacing)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-outline>:not(:last-child)){border-color:var(--color-outline)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-radius{border-radius:var(--radius-radius)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-l-radius{border-top-left-radius:var(--radius-radius);border-bottom-left-radius:var(--radius-radius)}.rounded-r-radius{border-top-right-radius:var(--radius-radius);border-bottom-right-radius:var(--radius-radius)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-danger{border-color:var(--color-danger)}.border-danger\/40{border-color:#e4001466}@supports (color:color-mix(in lab, red, red)){.border-danger\/40{border-color:color-mix(in oklab, var(--color-danger) 40%, transparent)}}.border-info{border-color:var(--color-info)}.border-outline{border-color:var(--color-outline)}.border-primary{border-color:var(--color-primary)}.border-primary\/40{border-color:#4f39f666}@supports (color:color-mix(in lab, red, red)){.border-primary\/40{border-color:color-mix(in oklab, var(--color-primary) 40%, transparent)}}.border-secondary{border-color:var(--color-secondary)}.border-success{border-color:var(--color-success)}.border-warning{border-color:var(--color-warning)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-danger{background-color:var(--color-danger)}.bg-danger\/5{background-color:#e400140d}@supports (color:color-mix(in lab, red, red)){.bg-danger\/5{background-color:color-mix(in oklab, var(--color-danger) 5%, transparent)}}.bg-danger\/10{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.bg-danger\/10{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.bg-danger\/15{background-color:#e4001426}@supports (color:color-mix(in lab, red, red)){.bg-danger\/15{background-color:color-mix(in oklab, var(--color-danger) 15%, transparent)}}.bg-info{background-color:var(--color-info)}.bg-info\/10{background-color:#00a5ef1a}@supports (color:color-mix(in lab, red, red)){.bg-info\/10{background-color:color-mix(in oklab, var(--color-info) 10%, transparent)}}.bg-info\/15{background-color:#00a5ef26}@supports (color:color-mix(in lab, red, red)){.bg-info\/15{background-color:color-mix(in oklab, var(--color-info) 15%, transparent)}}.bg-outline{background-color:var(--color-outline)}.bg-primary{background-color:var(--color-primary)}.bg-primary\/5{background-color:#4f39f60d}@supports (color:color-mix(in lab, red, red)){.bg-primary\/5{background-color:color-mix(in oklab, var(--color-primary) 5%, transparent)}}.bg-primary\/10{background-color:#4f39f61a}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.bg-secondary{background-color:var(--color-secondary)}.bg-success{background-color:var(--color-success)}.bg-success\/10{background-color:#00a5441a}@supports (color:color-mix(in lab, red, red)){.bg-success\/10{background-color:color-mix(in oklab, var(--color-success) 10%, transparent)}}.bg-success\/15{background-color:#00a54426}@supports (color:color-mix(in lab, red, red)){.bg-success\/15{background-color:color-mix(in oklab, var(--color-success) 15%, transparent)}}.bg-surface{background-color:var(--color-surface)}.bg-surface-alt{background-color:var(--color-surface-alt)}.bg-surface-alt\/40{background-color:#f1f5f966}@supports (color:color-mix(in lab, red, red)){.bg-surface-alt\/40{background-color:color-mix(in oklab, var(--color-surface-alt) 40%, transparent)}}.bg-surface-alt\/50{background-color:#f1f5f980}@supports (color:color-mix(in lab, red, red)){.bg-surface-alt\/50{background-color:color-mix(in oklab, var(--color-surface-alt) 50%, transparent)}}.bg-surface\/40{background-color:#fff6}@supports (color:color-mix(in lab, red, red)){.bg-surface\/40{background-color:color-mix(in oklab, var(--color-surface) 40%, transparent)}}.bg-surface\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-surface\/95{background-color:color-mix(in oklab, var(--color-surface) 95%, transparent)}}.bg-transparent{background-color:#0000}.bg-warning{background-color:var(--color-warning)}.bg-warning\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-warning\/10{background-color:color-mix(in oklab, var(--color-warning) 10%, transparent)}}.bg-warning\/15{background-color:#f99c0026}@supports (color:color-mix(in lab, red, red)){.bg-warning\/15{background-color:color-mix(in oklab, var(--color-warning) 15%, transparent)}}.bg-white{background-color:var(--color-white)}.object-cover{object-fit:cover}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pr-7{padding-right:calc(var(--spacing) * 7)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-6{padding-left:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-danger{color:var(--color-danger)}.text-info{color:var(--color-info)}.text-on-danger{color:var(--color-on-danger)}.text-on-info{color:var(--color-on-info)}.text-on-primary{color:var(--color-on-primary)}.text-on-primary\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-on-primary\/90{color:color-mix(in oklab, var(--color-on-primary) 90%, transparent)}}.text-on-secondary{color:var(--color-on-secondary)}.text-on-success{color:var(--color-on-success)}.text-on-surface{color:var(--color-on-surface)}.text-on-surface-strong{color:var(--color-on-surface-strong)}.text-on-surface\/40{color:#31415866}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/40{color:color-mix(in oklab, var(--color-on-surface) 40%, transparent)}}.text-on-surface\/50{color:#31415880}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/50{color:color-mix(in oklab, var(--color-on-surface) 50%, transparent)}}.text-on-surface\/60{color:#31415899}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/60{color:color-mix(in oklab, var(--color-on-surface) 60%, transparent)}}.text-on-surface\/70{color:#314158b3}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/70{color:color-mix(in oklab, var(--color-on-surface) 70%, transparent)}}.text-on-surface\/80{color:#314158cc}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/80{color:color-mix(in oklab, var(--color-on-surface) 80%, transparent)}}.text-on-warning{color:var(--color-on-warning)}.text-outline{color:var(--color-outline)}.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-secondary)}.text-success{color:var(--color-success)}.text-warning{color:var(--color-warning)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.outline-danger{outline-color:var(--color-danger)}.outline-primary{outline-color:var(--color-primary)}.outline-secondary{outline-color:var(--color-secondary)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}@media (hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.peer-checked\:visible:is(:where(.peer):checked~*){visibility:visible}.peer-checked\:bg-primary:is(:where(.peer):checked~*){background-color:var(--color-primary)}.peer-focus\:outline-2:is(:where(.peer):focus~*){outline-style:var(--tw-outline-style);outline-width:2px}.peer-focus\:outline-offset-2:is(:where(.peer):focus~*){outline-offset:2px}.peer-focus\:outline-outline-strong:is(:where(.peer):focus~*){outline-color:var(--color-outline-strong)}.peer-focus\:peer-checked\:outline-primary:is(:where(.peer):focus~*):is(:where(.peer):checked~*){outline-color:var(--color-primary)}.peer-active\:outline-offset-0:is(:where(.peer):active~*){outline-offset:0px}.file\:mr-4::file-selector-button{margin-right:calc(var(--spacing) * 4)}.file\:border-none::file-selector-button{--tw-border-style:none;border-style:none}.file\:bg-surface-alt::file-selector-button{background-color:var(--color-surface-alt)}.file\:px-4::file-selector-button{padding-inline:calc(var(--spacing) * 4)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-on-surface-strong::file-selector-button{color:var(--color-on-surface-strong)}.before\:invisible:before{content:var(--tw-content);visibility:hidden}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:inset-0:before{content:var(--tw-content);inset:0}.before\:top-1\/2:before{content:var(--tw-content);top:50%}.before\:left-1\/2:before{content:var(--tw-content);left:50%}.before\:h-1\.5:before{content:var(--tw-content);height:calc(var(--spacing) * 1.5)}.before\:w-1\.5:before{content:var(--tw-content);width:calc(var(--spacing) * 1.5)}.before\:-translate-x-1\/2:before{content:var(--tw-content);--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.before\:-translate-y-1\/2:before{content:var(--tw-content);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:bg-on-primary:before{content:var(--tw-content);background-color:var(--color-on-primary)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-0:after{content:var(--tw-content);top:0}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-\[0\.0625rem\]:after{content:var(--tw-content);left:.0625rem}.after\:my-auto:after{content:var(--tw-content);margin-block:auto}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:bg-on-surface:after{content:var(--tw-content);background-color:var(--color-on-surface)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-5:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * 5);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:bg-on-primary:is(:where(.peer):checked~*):after{content:var(--tw-content);background-color:var(--color-on-primary)}.checked\:border-primary:checked{border-color:var(--color-primary)}.checked\:bg-primary:checked{background-color:var(--color-primary)}.checked\:before\:visible:checked:before{content:var(--tw-content);visibility:visible}.checked\:before\:bg-primary:checked:before{content:var(--tw-content);background-color:var(--color-primary)}@media (hover:hover){.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:bg-danger\/5:hover{background-color:#e400140d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-danger\/5:hover{background-color:color-mix(in oklab, var(--color-danger) 5%, transparent)}}.hover\:bg-info\/5:hover{background-color:#00a5ef0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-info\/5:hover{background-color:color-mix(in oklab, var(--color-info) 5%, transparent)}}.hover\:bg-primary\/5:hover{background-color:#4f39f60d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/5:hover{background-color:color-mix(in oklab, var(--color-primary) 5%, transparent)}}.hover\:bg-surface-alt:hover{background-color:var(--color-surface-alt)}.hover\:bg-surface-dark-alt\/5:hover{background-color:#1d293d0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface-dark-alt\/5:hover{background-color:color-mix(in oklab, var(--color-surface-dark-alt) 5%, transparent)}}.hover\:bg-surface\/60:hover{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface\/60:hover{background-color:color-mix(in oklab, var(--color-surface) 60%, transparent)}}.hover\:text-on-surface-strong:hover{color:var(--color-on-surface-strong)}.hover\:text-primary:hover{color:var(--color-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-75:hover{opacity:.75}.hover\:opacity-90:hover{opacity:.9}}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-offset-2:focus{outline-offset:2px}.focus\:outline-outline-strong:focus{outline-color:var(--color-outline-strong)}.focus\:outline-primary:focus,.checked\:focus\:outline-primary:checked:focus{outline-color:var(--color-primary)}.focus-visible\:bg-primary\/10:focus-visible{background-color:#4f39f61a}@supports (color:color-mix(in lab, red, red)){.focus-visible\:bg-primary\/10:focus-visible{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.focus-visible\:bg-surface-dark-alt\/10:focus-visible{background-color:#1d293d1a}@supports (color:color-mix(in lab, red, red)){.focus-visible\:bg-surface-dark-alt\/10:focus-visible{background-color:color-mix(in oklab, var(--color-surface-dark-alt) 10%, transparent)}}.focus-visible\:text-on-surface-strong:focus-visible{color:var(--color-on-surface-strong)}.focus-visible\:underline:focus-visible{text-decoration-line:underline}.focus-visible\:outline-hidden:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus-visible\:outline-hidden:focus-visible{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-danger:focus-visible{outline-color:var(--color-danger)}.focus-visible\:outline-info:focus-visible{outline-color:var(--color-info)}.focus-visible\:outline-outline:focus-visible{outline-color:var(--color-outline)}.focus-visible\:outline-primary:focus-visible{outline-color:var(--color-primary)}.focus-visible\:outline-secondary:focus-visible{outline-color:var(--color-secondary)}.focus-visible\:outline-success:focus-visible{outline-color:var(--color-success)}.focus-visible\:outline-warning:focus-visible{outline-color:var(--color-warning)}.active\:opacity-100:active{opacity:1}.active\:outline-offset-0:active{outline-offset:0px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-75:disabled{opacity:.75}.has-checked\:text-on-surface-strong:has(:checked){color:var(--color-on-surface-strong)}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-75:has(:disabled){opacity:.75}.has-\[\:checked\]\:border-primary:has(:checked){border-color:var(--color-primary)}.aria-\[current\=page\]\:bg-primary\/10[aria-current=page]{background-color:#4f39f61a}@supports (color:color-mix(in lab, red, red)){.aria-\[current\=page\]\:bg-primary\/10[aria-current=page]{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.aria-\[current\=page\]\:font-semibold[aria-current=page]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.aria-\[current\=page\]\:text-on-surface-strong[aria-current=page]{color:var(--color-on-surface-strong)}.aria-\[current\=page\]\:text-primary[aria-current=page]{color:var(--color-primary)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:top-\[unset\]{top:unset}.md\:right-0{right:0}.md\:bottom-0{bottom:0}.md\:left-\[unset\]{left:unset}.md\:ml-60{margin-left:calc(var(--spacing) * 60)}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-64{height:calc(var(--spacing) * 64)}.md\:max-w-sm{max-width:var(--container-sm)}.md\:translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:translate-x-24{--tw-translate-x:calc(var(--spacing) * 24);translate:var(--tw-translate-x) var(--tw-translate-y)}}@media (min-width:64rem){.lg\:static{position:static}.lg\:z-auto{z-index:auto}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:hidden{display:none}.lg\:w-64{width:calc(var(--spacing) * 64)}.lg\:shrink-0{flex-shrink:0}.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\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:self-start{align-self:flex-start}.lg\:overflow-visible{overflow:visible}.lg\:rounded-radius{border-radius:var(--radius-radius)}.lg\:border{border-style:var(--tw-border-style);border-width:1px}.lg\:p-3{padding:calc(var(--spacing) * 3)}}@media (min-width:80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-outline-dark:where([data-theme=dark],[data-theme=dark] *)>:not(:last-child)){border-color:var(--color-outline-dark)}.dark\:border-danger:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-danger)}.dark\:border-info:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-info)}.dark\:border-outline-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-outline-dark)}.dark\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-primary-dark)}.dark\:border-primary-dark\/40:where([data-theme=dark],[data-theme=dark] *){border-color:#7d87ff66}@supports (color:color-mix(in lab, red, red)){.dark\:border-primary-dark\/40:where([data-theme=dark],[data-theme=dark] *){border-color:color-mix(in oklab, var(--color-primary-dark) 40%, transparent)}}.dark\:border-secondary-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-secondary-dark)}.dark\:border-success:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-success)}.dark\:border-warning:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-warning)}.dark\:bg-danger:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-danger)}.dark\:bg-info:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-info)}.dark\:bg-outline-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-outline-dark)}.dark\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-primary-dark)}.dark\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *){background-color:#7d87ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-primary-dark) 10%, transparent)}}.dark\:bg-secondary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-secondary-dark)}.dark\:bg-success:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-success)}.dark\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark)}.dark\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark-alt)}.dark\:bg-surface-dark-alt\/40:where([data-theme=dark],[data-theme=dark] *){background-color:#1d293d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark-alt\/40:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark-alt) 40%, transparent)}}.dark\:bg-surface-dark-alt\/50:where([data-theme=dark],[data-theme=dark] *){background-color:#1d293d80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark-alt\/50:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark-alt) 50%, transparent)}}.dark\:bg-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){background-color:#0f172b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark) 40%, transparent)}}.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:#0f172bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark) 95%, transparent)}}.dark\:bg-warning:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-warning)}.dark\:text-danger:where([data-theme=dark],[data-theme=dark] *){color:var(--color-danger)}.dark\:text-on-danger:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-danger)}.dark\:text-on-info:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-info)}.dark\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-primary-dark)}.dark\:text-on-primary-dark\/90:where([data-theme=dark],[data-theme=dark] *){color:#020618e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-primary-dark\/90:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-primary-dark) 90%, transparent)}}.dark\:text-on-secondary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-secondary-dark)}.dark\:text-on-success:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-success)}.dark\:text-on-surface-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark)}.dark\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark-strong)}.dark\:text-on-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/40:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 40%, transparent)}}.dark\:text-on-surface-dark\/50:where([data-theme=dark],[data-theme=dark] *){color:#cad5e280}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/50:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 50%, transparent)}}.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:#cad5e299}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 60%, transparent)}}.dark\:text-on-surface-dark\/70:where([data-theme=dark],[data-theme=dark] *){color:#cad5e2b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/70:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 70%, transparent)}}.dark\:text-on-surface-dark\/80:where([data-theme=dark],[data-theme=dark] *){color:#cad5e2cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/80:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 80%, transparent)}}.dark\:text-on-warning:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-warning)}.dark\:text-outline-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-outline-dark)}.dark\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-primary-dark)}.dark\:text-secondary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-secondary-dark)}.dark\:text-warning:where([data-theme=dark],[data-theme=dark] *){color:var(--color-warning)}.dark\:peer-checked\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):checked~*){background-color:var(--color-primary-dark)}.dark\:peer-focus\:outline-outline-dark-strong:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):focus~*){outline-color:var(--color-outline-dark-strong)}.dark\:peer-focus\:peer-checked\:outline-primary-dark:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):focus~*):is(:where(.peer):checked~*){outline-color:var(--color-primary-dark)}.dark\:file\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{background-color:var(--color-surface-dark-alt)}.dark\:file\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{color:var(--color-on-surface-dark-strong)}.dark\:before\:bg-on-primary-dark:where([data-theme=dark],[data-theme=dark] *):before{content:var(--tw-content);background-color:var(--color-on-primary-dark)}.dark\:after\:bg-on-surface-dark:where([data-theme=dark],[data-theme=dark] *):after{content:var(--tw-content);background-color:var(--color-on-surface-dark)}.dark\:peer-checked\:after\:bg-on-primary-dark:where([data-theme=dark],[data-theme=dark] *):is(:where(.peer):checked~*):after{content:var(--tw-content);background-color:var(--color-on-primary-dark)}.dark\:checked\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked{border-color:var(--color-primary-dark)}.dark\:checked\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked{background-color:var(--color-primary-dark)}.dark\:checked\:before\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked:before{content:var(--tw-content);background-color:var(--color-primary-dark)}@media (hover:hover){.dark\:hover\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{border-color:var(--color-primary-dark)}.dark\:hover\:bg-primary-dark\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:#7d87ff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-primary-dark\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:color-mix(in oklab, var(--color-primary-dark) 5%, transparent)}}.dark\:hover\:bg-surface-alt\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:#f1f5f90d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-surface-alt\/5:where([data-theme=dark],[data-theme=dark] *):hover{background-color:color-mix(in oklab, var(--color-surface-alt) 5%, transparent)}}.dark\:hover\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark)}.dark\:hover\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark-alt)}.dark\:hover\:bg-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *):hover{background-color:#0f172b99}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *):hover{background-color:color-mix(in oklab, var(--color-surface-dark) 60%, transparent)}}.dark\:hover\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-on-surface-dark-strong)}.dark\:hover\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-primary-dark)}}.dark\:focus\:outline-outline-dark-strong:where([data-theme=dark],[data-theme=dark] *):focus{outline-color:var(--color-outline-dark-strong)}.dark\:checked\:focus\:outline-primary-dark:where([data-theme=dark],[data-theme=dark] *):checked:focus{outline-color:var(--color-primary-dark)}.dark\:focus-visible\:bg-surface-alt\/10:where([data-theme=dark],[data-theme=dark] *):focus-visible{background-color:#f1f5f91a}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:bg-surface-alt\/10:where([data-theme=dark],[data-theme=dark] *):focus-visible{background-color:color-mix(in oklab, var(--color-surface-alt) 10%, transparent)}}.dark\:focus-visible\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *):focus-visible{color:var(--color-on-surface-dark-strong)}.dark\:focus-visible\:outline-danger:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-danger)}.dark\:focus-visible\:outline-info:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-info)}.dark\:focus-visible\:outline-outline-dark:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-outline-dark)}.dark\:focus-visible\:outline-primary-dark:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-primary-dark)}.dark\:focus-visible\:outline-secondary-dark:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-secondary-dark)}.dark\:focus-visible\:outline-success:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-success)}.dark\:focus-visible\:outline-warning:where([data-theme=dark],[data-theme=dark] *):focus-visible{outline-color:var(--color-warning)}.dark\:has-checked\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *):has(:checked){color:var(--color-on-surface-dark-strong)}.dark\:has-\[\:checked\]\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):has(:checked){border-color:var(--color-primary-dark)}.dark\:aria-\[current\=page\]\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:#7d87ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:aria-\[current\=page\]\:bg-primary-dark\/10:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:color-mix(in oklab, var(--color-primary-dark) 10%, transparent)}}.dark\:aria-\[current\=page\]\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-on-surface-dark-strong)}.dark\:aria-\[current\=page\]\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-primary-dark)}}[x-cloak]{display:none!important}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-content{syntax:"*";inherits:false;initial-value:""} \ No newline at end of file diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index 0fa56a6..7db4ee5 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -82,6 +82,10 @@ class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong"> {{ t(key="admin-discounts", lang=lang | default(value='sk')) }} + + {{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }} + {{ t(key="admin-categories", lang=lang | default(value='sk')) }} diff --git a/assets/views/admin/catalog/discount_profile_form.html b/assets/views/admin/catalog/discount_profile_form.html new file mode 100644 index 0000000..f1313ea --- /dev/null +++ b/assets/views/admin/catalog/discount_profile_form.html @@ -0,0 +1,71 @@ +{% extends "admin/base.html" %} +{% import "macros/ui.html" as ui %} + +{% block title %}{% if profile %}{{ t(key="edit-profile", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-profile", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %} +{% block crumb %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock crumb %} + +{% block content %} +
+

+ {% if profile %}{{ t(key="edit-profile", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-profile", lang=lang | default(value='sk')) }}{% endif %} +

+ {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles", size="px-3 py-2 text-sm") }} +
+ +{% if profile %}{% set v_name = profile.name %}{% set v_percent = profile.percent %}{% set v_scope = profile.scope_type %} +{% else %}{% set v_name = "" %}{% set v_percent = "" %}{% set v_scope = "include" %}{% endif %} + +
+ {{ ui::csrf_field() }} + + {% if error %}{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}{% endif %} + +
+
+ + {{ ui::input(name="name", id="name", required=true, value=v_name) }} +
+
+ + {{ ui::input(name="percent", id="percent", required=true, value=v_percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100"') }} +
+
+ +
+ {{ t(key="scope", lang=lang | default(value='sk')) }} + + +
+ +
+ {{ t(key="products", lang=lang | default(value='sk')) }} +
+ {% if products | length > 0 %} +
+ {% for product in products %} + + {% endfor %} +
+ {% else %} +

{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}

+ {% endif %} +
+
+ +
+ {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }} + {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles") }} +
+
+{% endblock content %} diff --git a/assets/views/admin/catalog/discount_profiles.html b/assets/views/admin/catalog/discount_profiles.html new file mode 100644 index 0000000..95a3900 --- /dev/null +++ b/assets/views/admin/catalog/discount_profiles.html @@ -0,0 +1,58 @@ +{% extends "admin/base.html" %} +{% import "macros/ui.html" as ui %} + +{% block title %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock title %} +{% block crumb %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock crumb %} + +{% block content %} +
+
+

{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}

+

{{ t(key="admin-discount-profiles-desc", lang=lang | default(value='sk')) }}

+
+ {{ ui::button(label=t(key="new-profile", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/new") }} +
+ +
+ {% if profiles | length > 0 %} + + + + {{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="discount-percent", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="scope", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="products", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }} + + + + {% for profile in profiles %} + + + + + + + + {% endfor %} + +
{{ profile.name }}−{{ profile.percent }}% + {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %} + {{ profile.product_count }} +
+ {{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/" ~ profile.id ~ "/edit", size="px-3 py-1.5 text-xs") }} +
+ {{ ui::csrf_field() }} + {{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }} +
+
+
+ {% else %} +
+

{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}

+ {{ ui::button(label=t(key="new-profile", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/new") }} +
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/admin/customers/show.html b/assets/views/admin/customers/show.html index 455c4f8..5c73746 100644 --- a/assets/views/admin/customers/show.html +++ b/assets/views/admin/customers/show.html @@ -17,15 +17,40 @@
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
{% endif %} -

{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}

+ +
+

{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}

+ {% if profiles | length > 0 %} +
+ {{ ui::csrf_field() }} +
+ {% for profile in profiles %} + + {% endfor %} +
+ {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }} +
+ {% else %} +

+ {{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }} + {{ t(key="new-profile", lang=lang | default(value='sk')) }} +

+ {% endif %} +
-
+

{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}

+ +
{% if products | length > 0 %} {{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="public-price", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="automated-price", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="negotiated-price", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk')), align="text-right") }} @@ -42,6 +67,27 @@ {{ product.public_price }} {{ product.currency }} {% endif %} +
+ {% if product.auto_price %} +
{{ product.auto_price }} {{ product.currency }}
+ {% if product.collision %} +
{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}
+
+ {{ ui::csrf_field() }} + + {{ ui::button(label=t(key="resolve", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }} +
+ {% elif product.auto_profile_name %} +
{{ product.auto_profile_name }}
+ {% endif %} + {% else %} + + {% endif %} +
{{ ui::csrf_field() }} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 827b082..e4ef5da 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -37,6 +37,7 @@ mod m20260618_000004_account_ownership; mod m20260620_000001_add_totp_to_users; mod m20260621_000001_add_sale_price_to_products; mod m20260621_000002_account_product_prices; +mod m20260621_000003_discount_profiles; pub struct Migrator; #[async_trait::async_trait] @@ -78,6 +79,7 @@ impl MigratorTrait for Migrator { Box::new(m20260620_000001_add_totp_to_users::Migration), Box::new(m20260621_000001_add_sale_price_to_products::Migration), Box::new(m20260621_000002_account_product_prices::Migration), + Box::new(m20260621_000003_discount_profiles::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260621_000003_discount_profiles.rs b/migration/src/m20260621_000003_discount_profiles.rs new file mode 100644 index 0000000..6643bde --- /dev/null +++ b/migration/src/m20260621_000003_discount_profiles.rs @@ -0,0 +1,91 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + // A reusable, named discount layer: a percentage (basis points, 5% = 500) + // over a product scope. `scope_type` is 'include' (covers the listed + // products) or 'all_except' (covers everything but the listed products). + create_table( + m, + "discount_profiles", + &[ + ("id", ColType::PkAuto), + ("name", ColType::String), + ("percent_bp", ColType::Integer), + ("scope_type", ColType::StringWithDefault("include".to_string())), + ], + &[], + ) + .await?; + + // Which products the scope lists (meaning depends on scope_type). + create_table( + m, + "discount_profile_products", + &[("id", ColType::PkAuto)], + &[("discount_profile", ""), ("product", "")], + ) + .await?; + m.create_index( + Index::create() + .name("idx_discount_profile_products_unique") + .table(Alias::new("discount_profile_products")) + .col(Alias::new("discount_profile_id")) + .col(Alias::new("product_id")) + .unique() + .to_owned(), + ) + .await?; + + // Which profiles a business account has (mixable). + create_table( + m, + "account_discount_profiles", + &[("id", ColType::PkAuto)], + &[("user", ""), ("discount_profile", "")], + ) + .await?; + m.create_index( + Index::create() + .name("idx_account_discount_profiles_unique") + .table(Alias::new("account_discount_profiles")) + .col(Alias::new("user_id")) + .col(Alias::new("discount_profile_id")) + .unique() + .to_owned(), + ) + .await?; + + // The admin's chosen winning profile when two assigned profiles cover the + // same product for an account (collision resolution). + create_table( + m, + "account_product_resolutions", + &[("id", ColType::PkAuto)], + &[("user", ""), ("product", ""), ("discount_profile", "")], + ) + .await?; + m.create_index( + Index::create() + .name("idx_account_product_resolutions_unique") + .table(Alias::new("account_product_resolutions")) + .col(Alias::new("user_id")) + .col(Alias::new("product_id")) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "account_product_resolutions").await?; + drop_table(m, "account_discount_profiles").await?; + drop_table(m, "discount_profile_products").await?; + drop_table(m, "discount_profiles").await + } +} diff --git a/src/app.rs b/src/app.rs index 223984a..ca07554 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,9 +17,9 @@ use std::{path::Path, sync::Arc}; #[allow(unused_imports)] use crate::{ controllers::{ - account, admin_categories, admin_customers, admin_dashboard, admin_discounts, admin_form, - admin_orders, admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, - media, oauth2, + account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles, + admin_discounts, admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages, + cart, checkout, home, i18n, media, oauth2, shop, }, initializers, @@ -106,6 +106,7 @@ impl Hooks for App { .add_route(admin_dashboard::routes()) .add_route(admin_products::routes()) .add_route(admin_discounts::routes()) + .add_route(admin_discount_profiles::routes()) .add_route(admin_categories::routes()) .add_route(admin_orders::routes()) .add_route(admin_customers::routes()) diff --git a/src/controllers/admin_customers.rs b/src/controllers/admin_customers.rs index 0b7d78e..a9f0ca3 100644 --- a/src/controllers/admin_customers.rs +++ b/src/controllers/admin_customers.rs @@ -1,24 +1,31 @@ -//! Admin management of business (company) accounts and their negotiated prices. +//! Admin management of business (company) accounts and their pricing. //! -//! Phase 1: list company accounts and, per account, set/clear a manually -//! negotiated price per product ("personal agreement"). The effective price the -//! business pays is always resolved by [`crate::shared::pricing`] (lowest of the -//! public price and the negotiated price), shown here for reference. +//! Per company the admin can: assign reusable discount profiles (the automated +//! layer), resolve per-product collisions when two assigned profiles cover the +//! same product, and set a manually negotiated price per product. The effective +//! price the business pays is always resolved by [`crate::shared::pricing`] +//! (lowest of public / automated / negotiated), shown here for reference. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; -use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, + QueryOrder, Set, TransactionTrait, +}; use serde::Deserialize; use serde_json::json; use crate::{ controllers::i18n::current_lang, - models::{account_product_prices, products, _entities::users}, + models::{ + account_discount_profiles, account_product_prices, account_product_resolutions, + discount_profiles, products, _entities::users, + }, shared::{ guard, - money::{format_price, parse_price_to_cents}, + money::{format_bp, format_price, parse_price_to_cents}, pricing, }, }; @@ -30,18 +37,32 @@ struct PriceForm { price: String, } +#[derive(Debug, Deserialize)] +struct ResolutionForm { + profile_id: i32, +} + async fn company_by_id(ctx: &AppContext, id: i32) -> Result { let user = users::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; - // Negotiated pricing only applies to company accounts. if user.account_type != COMPANY { return Err(Error::NotFound); } Ok(user) } +async fn assigned_profile_ids(ctx: &AppContext, user_id: i32) -> Result> { + Ok(account_discount_profiles::Entity::find() + .filter(account_discount_profiles::Column::UserId.eq(user_id)) + .all(&ctx.db) + .await? + .into_iter() + .map(|a| a.discount_profile_id) + .collect()) +} + #[debug_handler] async fn index( auth: auth::JWT, @@ -89,27 +110,58 @@ async fn show( guard::current_admin(auth, &ctx).await?; let company = company_by_id(&ctx, id).await?; + // All profiles (for the assignment section + name lookup) and which are + // assigned to this company. + let all_profiles = discount_profiles::Entity::find() + .order_by_asc(discount_profiles::Column::Name) + .all(&ctx.db) + .await?; + let assigned = assigned_profile_ids(&ctx, company.id).await?; + let profile_name: HashMap = + all_profiles.iter().map(|p| (p.id, p.name.clone())).collect(); + let profiles_json: Vec = all_profiles + .iter() + .map(|p| { + json!({ + "id": p.id, + "name": p.name, + "percent": format_bp(p.percent_bp), + "scope_type": p.scope_type, + "assigned": assigned.contains(&p.id), + }) + }) + .collect(); + let list = products::Entity::find() .order_by_asc(products::Column::Name) .all(&ctx.db) .await?; - let priced = pricing::price_many(&ctx, &list, Some(&company)).await?; - let manual = account_product_prices::Model::map_for_user(&ctx.db, company.id).await?; + let details = pricing::detail_many(&ctx, &list, Some(&company)).await?; let rows: Vec = list .iter() - .zip(priced.iter()) - .map(|(product, priced)| { + .zip(details.iter()) + .map(|(product, d)| { + let covering: Vec = d + .covering_profile_ids + .iter() + .map(|pid| json!({ "id": pid, "name": profile_name.get(pid) })) + .collect(); json!({ "product_id": product.id, "name": product.name, "currency": product.currency, - "regular_price": format_price(product.price_cents), - "public_price": format_price(product.effective_price_cents()), + "regular_price": format_price(d.regular_cents), + "public_price": format_price(d.public_cents), "on_public_sale": product.on_sale(), - "manual_price": manual.get(&product.id).copied().map(format_price), - "effective_price": format_price(priced.price_cents), - "is_business": priced.is_business, + "manual_price": d.manual_cents.map(format_price), + "auto_price": d.auto_cents.map(format_price), + "auto_profile_name": d.auto_profile_id.and_then(|pid| profile_name.get(&pid)), + "auto_profile_id": d.auto_profile_id, + "collision": d.collision, + "covering": covering, + "effective_price": format_price(d.price_cents), + "is_business": d.is_business, }) }) .collect(); @@ -119,6 +171,7 @@ async fn show( "admin/customers/show.html", json!({ "customer": { "id": company.id, "name": company.name, "email": company.email }, + "profiles": profiles_json, "products": rows, "error": params.get("error"), "lang": current_lang(&jar), @@ -137,7 +190,6 @@ async fn set_price( let company = company_by_id(&ctx, id).await?; let entered = form.price.trim().to_string(); - // An empty value clears the negotiated price (same as the Remove action). if entered.is_empty() { account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?; return format::redirect(&format!("/admin/customers/{id}")); @@ -145,7 +197,11 @@ async fn set_price( let cents = match parse_price_to_cents(&entered) { Ok(cents) if cents > 0 => cents, - _ => return format::redirect(&format!("/admin/customers/{id}?error=discount-must-be-positive")), + _ => { + return format::redirect(&format!( + "/admin/customers/{id}?error=discount-must-be-positive" + )) + } }; account_product_prices::Model::upsert(&ctx.db, company.id, product_id, cents).await?; format::redirect(&format!("/admin/customers/{id}")) @@ -163,13 +219,82 @@ async fn remove_price( format::redirect(&format!("/admin/customers/{id}")) } +/// Replace the company's assigned profiles with the submitted set of checkboxes +/// (`profile_ids`, a repeated field axum `Form` can't collect, parsed directly). +#[debug_handler] +async fn sync_profiles( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + body: String, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let company = company_by_id(&ctx, id).await?; + + let profile_ids: Vec = form_urlencoded::parse(body.as_bytes()) + .filter(|(k, _)| k == "profile_ids") + .filter_map(|(_, v)| v.parse::().ok()) + .collect(); + + let txn = ctx.db.begin().await?; + account_discount_profiles::Entity::delete_many() + .filter(account_discount_profiles::Column::UserId.eq(company.id)) + .exec(&txn) + .await?; + for profile_id in profile_ids { + account_discount_profiles::ActiveModel { + user_id: Set(company.id), + discount_profile_id: Set(profile_id), + ..Default::default() + } + .insert(&txn) + .await?; + } + txn.commit().await?; + format::redirect(&format!("/admin/customers/{id}")) +} + +/// Record the admin's chosen winning profile for a colliding product. +#[debug_handler] +async fn set_resolution( + auth: auth::JWT, + Path((id, product_id)): Path<(i32, i32)>, + State(ctx): State, + Form(form): Form, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let company = company_by_id(&ctx, id).await?; + + let existing = account_product_resolutions::Entity::find() + .filter(account_product_resolutions::Column::UserId.eq(company.id)) + .filter(account_product_resolutions::Column::ProductId.eq(product_id)) + .one(&ctx.db) + .await?; + let mut active = match existing { + Some(row) => row.into_active_model(), + None => account_product_resolutions::ActiveModel { + user_id: Set(company.id), + product_id: Set(product_id), + ..Default::default() + }, + }; + active.discount_profile_id = Set(form.profile_id); + active.save(&ctx.db).await?; + format::redirect(&format!("/admin/customers/{id}")) +} + pub fn routes() -> Routes { Routes::new() .add("/admin/customers", get(index)) .add("/admin/customers/{id}", get(show)) + .add("/admin/customers/{id}/profiles", post(sync_profiles)) .add("/admin/customers/{id}/prices/{product_id}", post(set_price)) .add( "/admin/customers/{id}/prices/{product_id}/remove", post(remove_price), ) + .add( + "/admin/customers/{id}/resolutions/{product_id}", + post(set_resolution), + ) } diff --git a/src/controllers/admin_discount_profiles.rs b/src/controllers/admin_discount_profiles.rs new file mode 100644 index 0000000..e44b17e --- /dev/null +++ b/src/controllers/admin_discount_profiles.rs @@ -0,0 +1,298 @@ +//! Admin CRUD for reusable discount profiles (a named percentage over a product +//! scope). Profiles are assigned to business accounts on the customer page; here +//! the admin only defines them. + +use std::collections::HashSet; + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait, + QueryFilter, QueryOrder, Set, TransactionTrait, +}; +use serde_json::json; + +use crate::{ + controllers::i18n::current_lang, + models::{discount_profile_products, discount_profiles, products}, + shared::{ + guard, + money::{format_bp, parse_percent, percent_to_bp}, + }, +}; + +/// Scalar + repeated fields parsed from the profile form. `product_ids` is a +/// repeated checkbox field, which `serde_urlencoded` (axum `Form`) can't collect, +/// so the body is parsed directly. +struct ProfileInput { + name: String, + percent: String, + scope_type: String, + product_ids: Vec, +} + +fn parse_profile_form(body: &str) -> ProfileInput { + let mut name = String::new(); + let mut percent = String::new(); + let mut scope_type = discount_profiles::SCOPE_INCLUDE.to_string(); + let mut product_ids = Vec::new(); + for (key, value) in form_urlencoded::parse(body.as_bytes()) { + match key.as_ref() { + "name" => name = value.into_owned(), + "percent" => percent = value.into_owned(), + "scope_type" => scope_type = value.into_owned(), + "product_ids" => { + if let Ok(id) = value.parse::() { + product_ids.push(id); + } + } + _ => {} + } + } + ProfileInput { + name, + percent, + scope_type, + product_ids, + } +} + +async fn profile_by_id(ctx: &AppContext, id: i32) -> Result { + discount_profiles::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +async fn index( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let profiles = discount_profiles::Entity::find() + .order_by_asc(discount_profiles::Column::Name) + .all(&ctx.db) + .await?; + + let mut rows = Vec::with_capacity(profiles.len()); + for profile in &profiles { + let count = discount_profile_products::Entity::find() + .filter(discount_profile_products::Column::DiscountProfileId.eq(profile.id)) + .count(&ctx.db) + .await?; + rows.push(json!({ + "id": profile.id, + "name": profile.name, + "percent": format_bp(profile.percent_bp), + "scope_type": profile.scope_type, + "product_count": count, + })); + } + + format::view( + &v, + "admin/catalog/discount_profiles.html", + json!({ "profiles": rows, "lang": current_lang(&jar) }), + ) +} + +/// Render the create/edit form. `profile` is null on create. +async fn render_form( + ctx: &AppContext, + v: &TeraView, + jar: &CookieJar, + profile: Option<&discount_profiles::Model>, + selected: &HashSet, + error: Option<&str>, +) -> Result { + let all_products = products::Entity::find() + .order_by_asc(products::Column::Name) + .all(&ctx.db) + .await?; + let product_rows: Vec = all_products + .iter() + .map(|p| json!({ "id": p.id, "name": p.name, "selected": selected.contains(&p.id) })) + .collect(); + + let profile_json = match profile { + Some(p) => json!({ + "id": p.id, + "name": p.name, + "percent": format_bp(p.percent_bp), + "scope_type": p.scope_type, + }), + None => serde_json::Value::Null, + }; + + format::view( + v, + "admin/catalog/discount_profile_form.html", + json!({ + "profile": profile_json, + "products": product_rows, + "error": error, + "lang": current_lang(jar), + }), + ) +} + +#[debug_handler] +async fn new( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + render_form(&ctx, &v, &jar, None, &HashSet::new(), None).await +} + +#[debug_handler] +async fn edit( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + Path(id): Path, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let profile = profile_by_id(&ctx, id).await?; + let selected = member_ids(&ctx, id).await?; + render_form(&ctx, &v, &jar, Some(&profile), &selected, None).await +} + +async fn member_ids(ctx: &AppContext, profile_id: i32) -> Result> { + Ok(discount_profile_products::Entity::find() + .filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id)) + .all(&ctx.db) + .await? + .into_iter() + .map(|r| r.product_id) + .collect()) +} + +/// Validate the parsed form into `(name, percent_bp, scope_type)`, or an error key. +fn validate(input: &ProfileInput) -> std::result::Result<(String, i32, String), &'static str> { + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("profile-name-required"); + } + let pct = parse_percent(&input.percent).ok_or("discount-invalid")?; + if pct <= 0.0 || pct >= 100.0 { + return Err("discount-percent-range"); + } + let scope = if input.scope_type == discount_profiles::SCOPE_ALL_EXCEPT { + discount_profiles::SCOPE_ALL_EXCEPT + } else { + discount_profiles::SCOPE_INCLUDE + }; + Ok((name, percent_to_bp(pct), scope.to_string())) +} + +/// Replace a profile's product membership with `product_ids`. +async fn sync_membership( + ctx: &AppContext, + profile_id: i32, + product_ids: &[i32], +) -> Result<()> { + let txn = ctx.db.begin().await?; + discount_profile_products::Entity::delete_many() + .filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id)) + .exec(&txn) + .await?; + for product_id in product_ids { + discount_profile_products::ActiveModel { + discount_profile_id: Set(profile_id), + product_id: Set(*product_id), + ..Default::default() + } + .insert(&txn) + .await?; + } + txn.commit().await?; + Ok(()) +} + +#[debug_handler] +async fn create( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, + body: String, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let input = parse_profile_form(&body); + let (name, percent_bp, scope_type) = match validate(&input) { + Ok(values) => values, + Err(key) => { + let selected: HashSet = input.product_ids.iter().copied().collect(); + return render_form(&ctx, &v, &jar, None, &selected, Some(key)).await; + } + }; + + let profile = discount_profiles::ActiveModel { + name: Set(name), + percent_bp: Set(percent_bp), + scope_type: Set(scope_type), + ..Default::default() + } + .insert(&ctx.db) + .await?; + sync_membership(&ctx, profile.id, &input.product_ids).await?; + format::redirect("/admin/catalog/discount-profiles") +} + +#[debug_handler] +async fn update( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + Path(id): Path, + State(ctx): State, + body: String, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let profile = profile_by_id(&ctx, id).await?; + let input = parse_profile_form(&body); + let (name, percent_bp, scope_type) = match validate(&input) { + Ok(values) => values, + Err(key) => { + let selected: HashSet = input.product_ids.iter().copied().collect(); + return render_form(&ctx, &v, &jar, Some(&profile), &selected, Some(key)).await; + } + }; + + let mut active = profile.into_active_model(); + active.name = Set(name); + active.percent_bp = Set(percent_bp); + active.scope_type = Set(scope_type); + active.update(&ctx.db).await?; + sync_membership(&ctx, id, &input.product_ids).await?; + format::redirect("/admin/catalog/discount-profiles") +} + +#[debug_handler] +async fn delete( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + // FK cascades remove membership, assignments and resolutions. + profile_by_id(&ctx, id).await?.delete(&ctx.db).await?; + format::redirect("/admin/catalog/discount-profiles") +} + +pub fn routes() -> Routes { + Routes::new() + .add("/admin/catalog/discount-profiles", get(index)) + .add("/admin/catalog/discount-profiles/new", get(new)) + .add("/admin/catalog/discount-profiles", post(create)) + .add("/admin/catalog/discount-profiles/{id}/edit", get(edit)) + .add("/admin/catalog/discount-profiles/{id}", post(update)) + .add("/admin/catalog/discount-profiles/{id}/delete", post(delete)) +} diff --git a/src/controllers/admin_discounts.rs b/src/controllers/admin_discounts.rs index 2c149fa..867ddc9 100644 --- a/src/controllers/admin_discounts.rs +++ b/src/controllers/admin_discounts.rs @@ -16,7 +16,7 @@ use crate::{ models::products, shared::{ guard, - money::{format_price, parse_price_to_cents}, + money::{format_price, parse_percent, parse_price_to_cents}, }, }; @@ -29,12 +29,6 @@ struct DiscountForm { percent: Option, } -/// Parse a percentage typed as "20", "20.5" or "20,5" into an `f64`. -fn parse_percent(value: &str) -> Option { - let parsed: f64 = value.trim().replace(',', ".").parse().ok()?; - parsed.is_finite().then_some(parsed) -} - /// Resolve a percentage off the regular price into a fixed sale price in cents. /// Rounds the discount amount to the nearest cent. fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 { diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index e86bd55..7de95e0 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -5,6 +5,7 @@ pub mod oauth2; pub mod admin_categories; pub mod admin_customers; pub mod admin_dashboard; +pub mod admin_discount_profiles; pub mod admin_discounts; pub mod admin_form; pub mod admin_orders; diff --git a/src/models/_entities/account_discount_profiles.rs b/src/models/_entities/account_discount_profiles.rs new file mode 100644 index 0000000..a7ed624 --- /dev/null +++ b/src/models/_entities/account_discount_profiles.rs @@ -0,0 +1,48 @@ +//! `SeaORM` Entity assigning a discount profile to a business account. +//! Hand-written to match the `account_discount_profiles` migration. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "account_discount_profiles")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub discount_profile_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Users, + #[sea_orm( + belongs_to = "super::discount_profiles::Entity", + from = "Column::DiscountProfileId", + to = "super::discount_profiles::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + DiscountProfiles, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DiscountProfiles.def() + } +} diff --git a/src/models/_entities/account_product_resolutions.rs b/src/models/_entities/account_product_resolutions.rs new file mode 100644 index 0000000..96fd69d --- /dev/null +++ b/src/models/_entities/account_product_resolutions.rs @@ -0,0 +1,64 @@ +//! `SeaORM` Entity for an account's chosen profile when two assigned profiles +//! cover one product. Hand-written to match the `account_product_resolutions` +//! migration. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "account_product_resolutions")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub product_id: i32, + pub discount_profile_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Users, + #[sea_orm( + belongs_to = "super::products::Entity", + from = "Column::ProductId", + to = "super::products::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Products, + #[sea_orm( + belongs_to = "super::discount_profiles::Entity", + from = "Column::DiscountProfileId", + to = "super::discount_profiles::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + DiscountProfiles, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Products.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DiscountProfiles.def() + } +} diff --git a/src/models/_entities/discount_profile_products.rs b/src/models/_entities/discount_profile_products.rs new file mode 100644 index 0000000..6338f63 --- /dev/null +++ b/src/models/_entities/discount_profile_products.rs @@ -0,0 +1,48 @@ +//! `SeaORM` Entity for a discount profile's product membership. Hand-written to +//! match the `discount_profile_products` migration. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "discount_profile_products")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub discount_profile_id: i32, + pub product_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::discount_profiles::Entity", + from = "Column::DiscountProfileId", + to = "super::discount_profiles::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + DiscountProfiles, + #[sea_orm( + belongs_to = "super::products::Entity", + from = "Column::ProductId", + to = "super::products::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Products, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DiscountProfiles.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Products.def() + } +} diff --git a/src/models/_entities/discount_profiles.rs b/src/models/_entities/discount_profiles.rs new file mode 100644 index 0000000..776d04d --- /dev/null +++ b/src/models/_entities/discount_profiles.rs @@ -0,0 +1,39 @@ +//! `SeaORM` Entity for reusable discount profiles. Hand-written to match the +//! `discount_profiles` migration. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "discount_profiles")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + /// Discount in basis points (5% = 500). + pub percent_bp: i32, + /// "include" (covers listed products) or "all_except" (covers all but them). + pub scope_type: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::discount_profile_products::Entity")] + DiscountProfileProducts, + #[sea_orm(has_many = "super::account_discount_profiles::Entity")] + AccountDiscountProfiles, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DiscountProfileProducts.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AccountDiscountProfiles.def() + } +} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index a713c7b..897462e 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -2,9 +2,13 @@ pub mod prelude; +pub mod account_discount_profiles; pub mod account_product_prices; +pub mod account_product_resolutions; pub mod audit_logs; pub mod categories; +pub mod discount_profile_products; +pub mod discount_profiles; pub mod customer_profiles; pub mod o_auth2_sessions; pub mod order_items; diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index 38da3a5..db2d74f 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -1,9 +1,13 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 +pub use super::account_discount_profiles::Entity as AccountDiscountProfiles; pub use super::account_product_prices::Entity as AccountProductPrices; +pub use super::account_product_resolutions::Entity as AccountProductResolutions; pub use super::audit_logs::Entity as AuditLogs; pub use super::categories::Entity as Categories; pub use super::customer_profiles::Entity as CustomerProfiles; +pub use super::discount_profile_products::Entity as DiscountProfileProducts; +pub use super::discount_profiles::Entity as DiscountProfiles; pub use super::o_auth2_sessions::Entity as OAuth2Sessions; pub use super::order_items::Entity as OrderItems; pub use super::orders::Entity as Orders; diff --git a/src/models/account_discount_profiles.rs b/src/models/account_discount_profiles.rs new file mode 100644 index 0000000..9945819 --- /dev/null +++ b/src/models/account_discount_profiles.rs @@ -0,0 +1,16 @@ +//! Assignment of a discount profile to a business account. + +pub use crate::models::_entities::account_discount_profiles::{ActiveModel, Column, Entity, Model}; +use sea_orm::entity::prelude::*; + +pub type AccountDiscountProfiles = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + Ok(self) + } +} diff --git a/src/models/account_product_resolutions.rs b/src/models/account_product_resolutions.rs new file mode 100644 index 0000000..654ef9b --- /dev/null +++ b/src/models/account_product_resolutions.rs @@ -0,0 +1,19 @@ +//! The chosen winning profile for an account+product when assigned profiles +//! collide on that product. + +pub use crate::models::_entities::account_product_resolutions::{ + ActiveModel, Column, Entity, Model, +}; +use sea_orm::entity::prelude::*; + +pub type AccountProductResolutions = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + Ok(self) + } +} diff --git a/src/models/discount_profile_products.rs b/src/models/discount_profile_products.rs new file mode 100644 index 0000000..b6cae33 --- /dev/null +++ b/src/models/discount_profile_products.rs @@ -0,0 +1,17 @@ +//! A discount profile's product membership (meaning depends on the profile's +//! scope: included products, or excluded ones). + +pub use crate::models::_entities::discount_profile_products::{ActiveModel, Column, Entity, Model}; +use sea_orm::entity::prelude::*; + +pub type DiscountProfileProducts = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + Ok(self) + } +} diff --git a/src/models/discount_profiles.rs b/src/models/discount_profiles.rs new file mode 100644 index 0000000..8609cee --- /dev/null +++ b/src/models/discount_profiles.rs @@ -0,0 +1,42 @@ +//! Reusable discount profiles: a named percentage over a product scope, mixable +//! across business accounts. + +pub use crate::models::_entities::discount_profiles::{ActiveModel, Column, Entity, Model}; +use sea_orm::entity::prelude::*; + +pub type DiscountProfiles = Entity; + +/// Scope value: the profile covers exactly the listed products. +pub const SCOPE_INCLUDE: &str = "include"; +/// Scope value: the profile covers every product except the listed ones. +pub const SCOPE_ALL_EXCEPT: &str = "all_except"; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +impl Model { + /// A profile covers `product_id` when its scope lists the product (include) + /// or does not list it (all_except). `membership` is the profile's product + /// id set. + #[must_use] + pub fn covers(&self, product_id: i32, membership: &std::collections::HashSet) -> bool { + let listed = membership.contains(&product_id); + match self.scope_type.as_str() { + SCOPE_ALL_EXCEPT => !listed, + _ => listed, + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index e3faff1..d3421fb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -6,9 +6,13 @@ pub mod _entities; +pub mod account_discount_profiles; pub mod account_product_prices; +pub mod account_product_resolutions; pub mod audit_logs; pub mod categories; +pub mod discount_profile_products; +pub mod discount_profiles; pub mod customer_profiles; pub mod o_auth2_sessions; pub mod order_items; diff --git a/src/shared/money.rs b/src/shared/money.rs index 826b358..0fed13e 100644 --- a/src/shared/money.rs +++ b/src/shared/money.rs @@ -34,3 +34,41 @@ pub fn parse_price_to_cents(value: &str) -> Result { pub fn format_price(cents: i64) -> String { format!("{}.{:02}", cents / 100, (cents % 100).abs()) } + +/// Parse a percentage typed as "20", "20.5" or "20,5" into an `f64`. Returns +/// `None` for anything non-numeric or non-finite. +#[must_use] +pub fn parse_percent(value: &str) -> Option { + let parsed: f64 = value.trim().replace(',', ".").parse().ok()?; + parsed.is_finite().then_some(parsed) +} + +/// Convert a percentage to basis points (5% -> 500), rounded to the nearest bp. +#[must_use] +pub fn percent_to_bp(percent: f64) -> i32 { + (percent * 100.0).round() as i32 +} + +/// Render basis points as a human percentage string, e.g. `550` -> `"5.5"`, +/// `500` -> `"5"`. +#[must_use] +pub fn format_bp(bp: i32) -> String { + let whole = bp / 100; + let frac = (bp % 100).abs(); + if frac == 0 { + whole.to_string() + } else if frac % 10 == 0 { + format!("{whole}.{}", frac / 10) + } else { + format!("{whole}.{frac:02}") + } +} + +/// Apply a basis-point discount to a price in minor units, rounding the discount +/// amount to the nearest cent. Never returns more than `cents`. +#[must_use] +pub fn apply_discount_bp(cents: i64, percent_bp: i32) -> i64 { + let bp = percent_bp.max(0) as i128; + let discount = (cents as i128 * bp + 5000) / 10000; // round half up + (cents - discount as i64).max(0) +} diff --git a/src/shared/pricing.rs b/src/shared/pricing.rs index 763397c..bc28a45 100644 --- a/src/shared/pricing.rs +++ b/src/shared/pricing.rs @@ -4,18 +4,25 @@ //! Everyone sees the public price — the lower of the regular price and any //! public sale ([`products::Model::effective_price_cents`]). A logged-in //! **company** account additionally gets their business price: the lowest of the -//! public price and any admin-set negotiated price. (Phase 2 will add automated -//! discount profiles as a further input to the same "lowest wins" rule.) +//! public price, any admin-set negotiated price, and the price from their +//! assigned automated discount profiles. **Lowest wins.** + +use std::collections::{HashMap, HashSet}; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use crate::models::{account_product_prices, products, users}; +use crate::models::{ + account_discount_profiles, account_product_prices, account_product_resolutions, + discount_profile_products, discount_profiles, products, users, +}; +use crate::shared::money::apply_discount_bp; /// `account_type` value that unlocks business pricing. const COMPANY: &str = "company"; -/// The resolved price for one product and one viewer. +/// The resolved price for one product and one viewer (the slim shape templates +/// and the cart use). #[derive(Debug, Clone, Copy)] pub struct PricedProduct { /// What the viewer pays, in minor units. @@ -34,26 +41,57 @@ impl PricedProduct { } } -/// Is this viewer a business (company) account? -fn is_company(user: Option<&users::Model>) -> bool { - matches!(user, Some(u) if u.account_type == COMPANY) +/// Full breakdown for one product and one viewer, used by the admin company page +/// (which needs to show each layer and any collision). The storefront only needs +/// [`PriceDetail::priced`]. +#[derive(Debug, Clone)] +pub struct PriceDetail { + pub regular_cents: i64, + pub public_cents: i64, + pub manual_cents: Option, + pub auto_cents: Option, + /// The profile that produced `auto_cents` (the resolved/only/biggest one). + pub auto_profile_id: Option, + /// Every assigned profile that covers this product. + pub covering_profile_ids: Vec, + /// True when more than one profile covers the product and the admin has not + /// resolved which wins (a fallback was used). + pub collision: bool, + pub price_cents: i64, + pub is_business: bool, } -/// The public (non-business) price for a product. -fn public_priced(product: &products::Model) -> PricedProduct { - PricedProduct { - price_cents: product.effective_price_cents(), - regular_cents: product.price_cents, - is_business: false, +impl PriceDetail { + fn public_only(regular_cents: i64, public_cents: i64) -> Self { + Self { + regular_cents, + public_cents, + manual_cents: None, + auto_cents: None, + auto_profile_id: None, + covering_profile_ids: Vec::new(), + collision: false, + price_cents: public_cents, + is_business: false, + } + } + + #[must_use] + pub fn priced(&self) -> PricedProduct { + PricedProduct { + price_cents: self.price_cents, + regular_cents: self.regular_cents, + is_business: self.is_business, + } } } -/// Combine the public price with the business layers (Phase 1: the manual -/// negotiated price), lowest wins. Pure so it can be unit-tested in isolation. -fn combine(regular_cents: i64, public_cents: i64, manual: Option) -> PricedProduct { - match manual { - Some(m) if m <= public_cents => PricedProduct { - price_cents: m, +/// The "lowest wins" decision: pick the business price only when it is at or +/// below the public price. Pure, so it is unit-tested directly. +fn decide(regular_cents: i64, public_cents: i64, business: Option) -> PricedProduct { + match business { + Some(b) if b <= public_cents => PricedProduct { + price_cents: b, regular_cents, is_business: true, }, @@ -65,8 +103,141 @@ fn combine(regular_cents: i64, public_cents: i64, manual: Option) -> Priced } } -fn resolve(product: &products::Model, manual: Option) -> PricedProduct { - combine(product.price_cents, product.effective_price_cents(), manual) +/// Is this viewer a business (company) account? +fn is_company(user: Option<&users::Model>) -> bool { + matches!(user, Some(u) if u.account_type == COMPANY) +} + +/// Everything needed to resolve every product's business price for one account, +/// loaded once so listing pages and the cart avoid N+1 queries. +struct B2bContext { + manual: HashMap, + profiles: Vec, + membership: HashMap>, + resolutions: HashMap, +} + +async fn load_b2b(ctx: &AppContext, user_id: i32) -> Result { + let manual = account_product_prices::Model::map_for_user(&ctx.db, user_id).await?; + + let assigns = account_discount_profiles::Entity::find() + .filter(account_discount_profiles::Column::UserId.eq(user_id)) + .all(&ctx.db) + .await?; + let profile_ids: Vec = assigns.iter().map(|a| a.discount_profile_id).collect(); + + let (profiles, membership) = if profile_ids.is_empty() { + (Vec::new(), HashMap::new()) + } else { + let profiles = discount_profiles::Entity::find() + .filter(discount_profiles::Column::Id.is_in(profile_ids.clone())) + .all(&ctx.db) + .await?; + let rows = discount_profile_products::Entity::find() + .filter(discount_profile_products::Column::DiscountProfileId.is_in(profile_ids)) + .all(&ctx.db) + .await?; + let mut membership: HashMap> = HashMap::new(); + for row in rows { + membership + .entry(row.discount_profile_id) + .or_default() + .insert(row.product_id); + } + (profiles, membership) + }; + + let resolutions = account_product_resolutions::Entity::find() + .filter(account_product_resolutions::Column::UserId.eq(user_id)) + .all(&ctx.db) + .await? + .into_iter() + .map(|r| (r.product_id, r.discount_profile_id)) + .collect(); + + Ok(B2bContext { + manual, + profiles, + membership, + resolutions, + }) +} + +/// Resolve one product's full price breakdown for `b2b` (None = public viewer). +fn detail_for(product: &products::Model, b2b: Option<&B2bContext>) -> PriceDetail { + let regular = product.price_cents; + let public = product.effective_price_cents(); + let Some(b2b) = b2b else { + return PriceDetail::public_only(regular, public); + }; + + let manual = b2b.manual.get(&product.id).copied(); + + // Which assigned profiles cover this product. + let empty = HashSet::new(); + let covering: Vec<&discount_profiles::Model> = b2b + .profiles + .iter() + .filter(|p| p.covers(product.id, b2b.membership.get(&p.id).unwrap_or(&empty))) + .collect(); + + let mut auto_cents = None; + let mut auto_profile_id = None; + let mut collision = false; + if !covering.is_empty() { + let chosen = if covering.len() == 1 { + covering[0] + } else { + // Two+ profiles collide: honour the admin's resolution, else fall + // back to the biggest discount and flag it for resolving. + match b2b + .resolutions + .get(&product.id) + .and_then(|rid| covering.iter().find(|p| p.id == *rid).copied()) + { + Some(resolved) => resolved, + None => { + collision = true; + covering + .iter() + .max_by_key(|p| p.percent_bp) + .copied() + .expect("covering is non-empty") + } + } + }; + auto_profile_id = Some(chosen.id); + auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp)); + } + + let business = [manual, auto_cents].into_iter().flatten().min(); + let priced = decide(regular, public, business); + + PriceDetail { + regular_cents: regular, + public_cents: public, + manual_cents: manual, + auto_cents, + auto_profile_id, + covering_profile_ids: covering.iter().map(|p| p.id).collect(), + collision, + price_cents: priced.price_cents, + is_business: priced.is_business, + } +} + +/// Full breakdowns for many products for `user`, batching per-account lookups. +pub async fn detail_many( + ctx: &AppContext, + list: &[products::Model], + user: Option<&users::Model>, +) -> Result> { + let b2b = if is_company(user) { + Some(load_b2b(ctx, user.expect("is_company implies Some").id).await?) + } else { + None + }; + Ok(list.iter().map(|p| detail_for(p, b2b.as_ref())).collect()) } /// Price one product for `user` (`None` = anonymous/public). @@ -75,17 +246,8 @@ pub async fn price_for( product: &products::Model, user: Option<&users::Model>, ) -> Result { - if !is_company(user) { - return Ok(public_priced(product)); - } - let user = user.expect("is_company implies Some"); - let manual = account_product_prices::Entity::find() - .filter(account_product_prices::Column::UserId.eq(user.id)) - .filter(account_product_prices::Column::ProductId.eq(product.id)) - .one(&ctx.db) - .await? - .map(|row| row.price_cents); - Ok(resolve(product, manual)) + let detail = detail_many(ctx, std::slice::from_ref(product), user).await?; + Ok(detail[0].priced()) } /// Price many products for `user`, batching the per-account lookups to avoid @@ -95,55 +257,53 @@ pub async fn price_many( list: &[products::Model], user: Option<&users::Model>, ) -> Result> { - if !is_company(user) { - return Ok(list.iter().map(public_priced).collect()); - } - let user = user.expect("is_company implies Some"); - let manual = account_product_prices::Model::map_for_user(&ctx.db, user.id).await?; - Ok(list + Ok(detail_many(ctx, list, user) + .await? .iter() - .map(|product| resolve(product, manual.get(&product.id).copied())) + .map(PriceDetail::priced) .collect()) } #[cfg(test)] mod tests { - use super::combine; + use super::decide; + use crate::shared::money::apply_discount_bp; - // regular 100.00, no public sale, no negotiated price. #[test] fn public_only() { - let p = combine(10000, 10000, None); + let p = decide(10000, 10000, None); assert_eq!(p.price_cents, 10000); assert!(!p.is_reduced()); assert!(!p.is_business); } - // A negotiated price below the public price wins and is flagged business. #[test] - fn negotiated_lower_wins() { - let p = combine(10000, 10000, Some(9000)); + fn business_lower_wins() { + let p = decide(10000, 10000, Some(9000)); assert_eq!(p.price_cents, 9000); - assert!(p.is_reduced()); assert!(p.is_business); } - // A public sale below the negotiated price wins (lowest wins); not business. #[test] - fn public_sale_beats_negotiated() { - // regular 100, public sale 80, negotiated 90 -> pay 80. - let p = combine(10000, 8000, Some(9000)); + fn public_sale_beats_business() { + // regular 100, public sale 80, business best 90 -> pay 80, not business. + let p = decide(10000, 8000, Some(9000)); assert_eq!(p.price_cents, 8000); - assert!(p.is_reduced()); assert!(!p.is_business); } - // A negotiated price equal to the public price is still treated as theirs. #[test] - fn negotiated_equal_is_business() { - let p = combine(10000, 10000, Some(10000)); + fn business_equal_is_business() { + let p = decide(10000, 10000, Some(10000)); assert_eq!(p.price_cents, 10000); - assert!(!p.is_reduced()); assert!(p.is_business); } + + #[test] + fn discount_bp_rounds_half_up() { + assert_eq!(apply_discount_bp(10000, 500), 9500); // 5% + assert_eq!(apply_discount_bp(10000, 1500), 8500); // 15% + assert_eq!(apply_discount_bp(999, 500), 949); // 49.95 -> 50 off + assert_eq!(apply_discount_bp(10000, 0), 10000); + } }