From f0a6f97609a1474f1c5ef4335575d4d6cdfaa8e5 Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 16 Jun 2026 19:27:29 +0200 Subject: [PATCH] more webshop --- assets/i18n/en/main.ftl | 20 +++ assets/i18n/sk/main.ftl | 20 +++ assets/static/css/app.css | 2 +- assets/views/admin/base.html | 4 + assets/views/admin/orders/show.html | 11 ++ assets/views/admin/shipping/index.html | 37 +++++ assets/views/shop/checkout.html | 114 +++++++++++-- assets/views/shop/order_confirmed.html | 43 +++-- config/development.yaml | 6 + migration/src/lib.rs | 4 + .../src/m20260616_150755_shipping_methods.rs | 30 ++++ ...16_150812_add_shipping_fields_to_orders.rs | 28 ++++ src/app.rs | 1 + src/controllers/catalog.rs | 2 +- src/controllers/orders.rs | 151 +++++++++++++++++- src/initializers/mod.rs | 1 + src/initializers/shipping_seeder.rs | 48 ++++++ src/models/_entities/mod.rs | 1 + src/models/_entities/orders.rs | 6 + src/models/_entities/prelude.rs | 1 + src/models/_entities/shipping_methods.rs | 23 +++ src/models/mod.rs | 1 + src/models/shipping_methods.rs | 28 ++++ tests/models/mod.rs | 3 +- tests/models/shipping_methods.rs | 31 ++++ 25 files changed, 583 insertions(+), 33 deletions(-) create mode 100644 assets/views/admin/shipping/index.html create mode 100644 migration/src/m20260616_150755_shipping_methods.rs create mode 100644 migration/src/m20260616_150812_add_shipping_fields_to_orders.rs create mode 100644 src/initializers/shipping_seeder.rs create mode 100644 src/models/_entities/shipping_methods.rs create mode 100644 src/models/shipping_methods.rs create mode 100644 tests/models/shipping_methods.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 015df91..71c06c4 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -239,3 +239,23 @@ order-status-paid = Paid order-status-shipped = Shipped order-status-cancelled = Cancelled order-update-status = Update status + +# --- eshop: shipping & payment --- +checkout-carrier = Delivery +checkout-payment = Payment method +checkout-subtotal = Subtotal +checkout-shipping-cost = Shipping +checkout-pick-point = Choose pickup point +checkout-chosen-point = Chosen point +checkout-pickup-point = Pickup point +payment-cod = Cash on delivery +payment-bank = Bank transfer +payment-bank-instructions = Please transfer the amount to our account: +payment-cod-note = You will pay for the goods on delivery. +payment-bank-note = We will ship once the payment arrives. +bank-account-name = Account holder +bank-variable-symbol = Variable symbol +bank-amount = Amount +admin-shipping = Shipping +admin-shipping-desc = set carrier prices and availability. +shipping-enabled = Active diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 07908b0..f471244 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -239,3 +239,23 @@ order-status-paid = Zaplatené order-status-shipped = Odoslané order-status-cancelled = Zrušené order-update-status = Zmeniť stav + +# --- eshop: shipping & payment --- +checkout-carrier = Doprava +checkout-payment = Spôsob platby +checkout-subtotal = Medzisúčet +checkout-shipping-cost = Doprava +checkout-pick-point = Vybrať výdajné miesto +checkout-chosen-point = Vybrané miesto +checkout-pickup-point = Výdajné miesto +payment-cod = Dobierka (platba pri prevzatí) +payment-bank = Bankový prevod +payment-bank-instructions = Sumu uhraďte prevodom na náš účet: +payment-cod-note = Za tovar zaplatíte pri jeho prevzatí. +payment-bank-note = Po prijatí platby objednávku odošleme. +bank-account-name = Príjemca +bank-variable-symbol = Variabilný symbol +bank-amount = Suma +admin-shipping = Doprava +admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov. +shipping-enabled = Aktívne diff --git a/assets/static/css/app.css b/assets/static/css/app.css index df8063d..ebde2f9 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-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-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-outline-style:solid}}}@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-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--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-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-relaxed:1.625;--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-outline:var(--color-slate-300);--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-outline-dark:var(--color-slate-700);--color-info:var(--color-sky-500);--color-success:var(--color-green-600);--color-warning:var(--color-amber-500);--color-danger:var(--color-red-600);--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{.collapse{visibility:collapse}.visible{visibility:visible}.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-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.left-0{left:0}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.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-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-auto{margin-top:auto}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.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-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-full{width:100%;height:100%}.h-16{height:calc(var(--spacing) * 16)}.h-fit{height:fit-content}.min-h-screen{min-height:100vh}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-56{width:calc(var(--spacing) * 56)}.w-60{width:calc(var(--spacing) * 60)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.min-w-4{min-width:calc(var(--spacing) * 4)}.flex-1{flex:1}.-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)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.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)))}: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)}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-radius{border-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-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-outline{border-color:var(--color-outline)}.border-primary{border-color:var(--color-primary)}.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\/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-primary{background-color:var(--color-primary)}.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\/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)}}.object-cover{object-fit:cover}.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-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-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.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)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-pre-line{white-space:pre-line}.text-danger{color:var(--color-danger)}.text-info{color:var(--color-info)}.text-on-primary{color:var(--color-on-primary)}.text-on-surface{color:var(--color-on-surface)}.text-on-surface-strong{color:var(--color-on-surface-strong)}.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-primary{color:var(--color-primary)}.text-success{color:var(--color-success)}.text-warning{color:var(--color-warning)}.uppercase{text-transform:uppercase}.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,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.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)}.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-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}@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)}}.file\:mr-3::file-selector-button{margin-right:calc(var(--spacing) * 3)}.file\:rounded-radius::file-selector-button{border-radius:var(--radius-radius)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-primary::file-selector-button{background-color:var(--color-primary)}.file\:px-3::file-selector-button{padding-inline:calc(var(--spacing) * 3)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-on-primary::file-selector-button{color:var(--color-on-primary)}@media (hover:hover){.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:bg-danger\/10:hover{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-danger\/10:hover{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:bg-surface-alt:hover{background-color:var(--color-surface-alt)}.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\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-primary:focus{--tw-ring-color:var(--color-primary)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-surface-alt:focus{--tw-ring-offset-color:var(--color-surface-alt)}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-primary:focus{outline-color:var(--color-primary)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.aria-\[current\=page\]\:bg-primary[aria-current=page]{background-color:var(--color-primary)}.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-primary[aria-current=page]{color:var(--color-on-primary)}.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\:ml-60{margin-left:calc(var(--spacing) * 60)}.md\:flex{display:flex}.md\:hidden{display:none}.md\:translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.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))}}:where(.dark\:divide-outline-dark:where([data-theme=dark],[data-theme=dark] *)>:not(:last-child)),.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\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-primary-dark)}.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\/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\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-primary-dark)}.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\/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-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-primary-dark)}.dark\:file\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{background-color:var(--color-primary-dark)}.dark\:file\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{color:var(--color-on-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-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\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-primary-dark)}}.dark\:focus\:ring-primary-dark:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-color:var(--color-primary-dark)}.dark\:focus\:ring-offset-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-offset-color:var(--color-surface-dark-alt)}.dark\:aria-\[current\=page\]\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:var(--color-primary-dark)}.dark\:aria-\[current\=page\]\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-on-primary-dark)}.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-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-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-outline-style{syntax:"*";inherits:false;initial-value:solid} \ 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-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-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-outline-style:solid}}}@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-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--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-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-relaxed:1.625;--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-outline:var(--color-slate-300);--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-outline-dark:var(--color-slate-700);--color-info:var(--color-sky-500);--color-success:var(--color-green-600);--color-warning:var(--color-amber-500);--color-danger:var(--color-red-600);--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{.collapse{visibility:collapse}.visible{visibility:visible}.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-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.left-0{left:0}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.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-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-auto{margin-top:auto}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.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-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-full{width:100%;height:100%}.h-16{height:calc(var(--spacing) * 16)}.h-fit{height:fit-content}.min-h-screen{min-height:100vh}.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-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-40{min-width:calc(var(--spacing) * 40)}.flex-1{flex:1}.-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)}.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}.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-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.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)}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-radius{border-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-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-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)}}.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\/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-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-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\/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)}}.object-cover{object-fit:cover}.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-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-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.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)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-pre-line{white-space:pre-line}.text-danger{color:var(--color-danger)}.text-info{color:var(--color-info)}.text-on-primary{color:var(--color-on-primary)}.text-on-surface{color:var(--color-on-surface)}.text-on-surface-strong{color:var(--color-on-surface-strong)}.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-primary{color:var(--color-primary)}.text-success{color:var(--color-success)}.text-warning{color:var(--color-warning)}.uppercase{text-transform:uppercase}.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,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.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)}.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-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}@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)}}.file\:mr-3::file-selector-button{margin-right:calc(var(--spacing) * 3)}.file\:rounded-radius::file-selector-button{border-radius:var(--radius-radius)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-primary::file-selector-button{background-color:var(--color-primary)}.file\:px-3::file-selector-button{padding-inline:calc(var(--spacing) * 3)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-on-primary::file-selector-button{color:var(--color-on-primary)}@media (hover:hover){.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:bg-danger\/10:hover{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-danger\/10:hover{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:bg-surface-alt:hover{background-color:var(--color-surface-alt)}.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\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-primary:focus{--tw-ring-color:var(--color-primary)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-surface-alt:focus{--tw-ring-offset-color:var(--color-surface-alt)}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-primary:focus{outline-color:var(--color-primary)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.has-\[\:checked\]\:border-primary:has(:checked){border-color:var(--color-primary)}.aria-\[current\=page\]\:bg-primary[aria-current=page]{background-color:var(--color-primary)}.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-primary[aria-current=page]{color:var(--color-on-primary)}.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\:ml-60{margin-left:calc(var(--spacing) * 60)}.md\:flex{display:flex}.md\:hidden{display:none}.md\:translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.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))}}:where(.dark\:divide-outline-dark:where([data-theme=dark],[data-theme=dark] *)>:not(:last-child)),.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\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-primary-dark)}.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\/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\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-primary-dark)}.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\/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-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-primary-dark)}.dark\:file\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{background-color:var(--color-primary-dark)}.dark\:file\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{color:var(--color-on-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-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\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-primary-dark)}}.dark\:focus\:ring-primary-dark:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-color:var(--color-primary-dark)}.dark\:focus\:ring-offset-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-offset-color:var(--color-surface-dark-alt)}.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:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:var(--color-primary-dark)}.dark\:aria-\[current\=page\]\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-on-primary-dark)}.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-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-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-outline-style{syntax:"*";inherits:false;initial-value:solid} \ No newline at end of file diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index d2b4d94..6910cd9 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -79,6 +79,10 @@ class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark"> {{ t(key="admin-orders", lang=lang | default(value='sk')) }} + + {{ t(key="admin-shipping", lang=lang | default(value='sk')) }} +
diff --git a/assets/views/admin/orders/show.html b/assets/views/admin/orders/show.html index a59d235..58b0e2f 100644 --- a/assets/views/admin/orders/show.html +++ b/assets/views/admin/orders/show.html @@ -50,6 +50,17 @@

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

{{ order.address }}
{{ order.zip }} {{ order.city }}
{{ order.country }}

+
+

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

+

{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}

+ {% if order.pickup_point_name %}

{{ order.pickup_point_name }}

{% endif %} +
+
+

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

+

+ {% if order.payment_method == "bank_transfer" %}{{ t(key="payment-bank", lang=lang | default(value='sk')) }} · VS {{ order.variable_symbol }}{% else %}{{ t(key="payment-cod", lang=lang | default(value='sk')) }}{% endif %} +

+
{% if order.note %}

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

diff --git a/assets/views/admin/shipping/index.html b/assets/views/admin/shipping/index.html new file mode 100644 index 0000000..97d7dd1 --- /dev/null +++ b/assets/views/admin/shipping/index.html @@ -0,0 +1,37 @@ +{% extends "admin/base.html" %} + +{% block title %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock title %} +{% block crumb %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock crumb %} + +{% block content %} +
+

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

+

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

+
+ +
+ {% for method in methods %} +
+
+

{{ method.name }}

+

{{ method.code }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}

+
+
+ + +
+ + +
+ {% endfor %} +
+{% endblock content %} diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout.html index 069ae2f..e718923 100644 --- a/assets/views/shop/checkout.html +++ b/assets/views/shop/checkout.html @@ -3,10 +3,34 @@ {% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %} {% block content %} +{% if packeta_api_key %}{% endif %} +

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

-
-
+ + +
+
{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}
@@ -21,6 +45,7 @@
+
{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}
@@ -41,23 +66,70 @@
-
-
- - + + + +
+ {{ t(key="checkout-carrier", lang=lang | default(value='sk')) }} + {% for m in shipping_methods %} + + {% endfor %} + + +
+ + + {% if packeta_api_key %} + +

+ {{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}: +

+ {% else %} + + + {% endif %}
- - + +
+ {{ t(key="checkout-payment", lang=lang | default(value='sk')) }} + + +
+
+ + +
+
+ + -
+ {% endblock content %} diff --git a/assets/views/shop/order_confirmed.html b/assets/views/shop/order_confirmed.html index 4bb12bc..36137de 100644 --- a/assets/views/shop/order_confirmed.html +++ b/assets/views/shop/order_confirmed.html @@ -3,18 +3,18 @@ {% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %} {% block content %} -
-
- - - -
-
-

{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}

+
+
+
+ + + +
+

{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}

{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}

-
+
{{ t(key="order-number", lang=lang | default(value='sk')) }} {{ order.order_number }} @@ -27,12 +27,35 @@ {% endfor %} +
+
{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}{{ order.subtotal }} {{ order.currency }}
+
{{ order.carrier_name }}{{ order.shipping }} {{ order.currency }}
+ {% if order.pickup_point_name %}
{{ order.pickup_point_name }}
{% endif %} +
{{ t(key="order-total", lang=lang | default(value='sk')) }} {{ order.total }} {{ order.currency }}
- {{ t(key="cart-continue", lang=lang | default(value='sk')) }} + {% if order.payment_method == "bank_transfer" %} +
+

{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}

+
+ {{ t(key="bank-account-name", lang=lang | default(value='sk')) }}{{ order.bank_account_name }} + IBAN{{ order.bank_iban }} + {{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}{{ order.variable_symbol }} + {{ t(key="bank-amount", lang=lang | default(value='sk')) }}{{ order.total }} {{ order.currency }} +
+
+ {% else %} +
+ {{ t(key="payment-cod-note", lang=lang | default(value='sk')) }} +
+ {% endif %} + +
{% endblock content %} diff --git a/config/development.yaml b/config/development.yaml index 8bdbe08..11fb1c8 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -105,3 +105,9 @@ auth: settings: admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }} uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }} + # Packeta (Zásilkovna) web API key for the pickup-point picker widget. + # Empty falls back to a plain text field for the pickup point. + packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }} + # Bank-transfer payment details shown on the order confirmation. + bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }} + bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index b65219b..ec2e2cf 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -24,6 +24,8 @@ mod m20260616_130610_orders; mod m20260616_130628_order_items; mod m20260616_131000_drop_audio_tables; mod m20260616_132000_drop_blog_and_pages; +mod m20260616_150755_shipping_methods; +mod m20260616_150812_add_shipping_fields_to_orders; pub struct Migrator; #[async_trait::async_trait] @@ -52,6 +54,8 @@ impl MigratorTrait for Migrator { Box::new(m20260616_130628_order_items::Migration), Box::new(m20260616_131000_drop_audio_tables::Migration), Box::new(m20260616_132000_drop_blog_and_pages::Migration), + Box::new(m20260616_150755_shipping_methods::Migration), + Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260616_150755_shipping_methods.rs b/migration/src/m20260616_150755_shipping_methods.rs new file mode 100644 index 0000000..0fa57ed --- /dev/null +++ b/migration/src/m20260616_150755_shipping_methods.rs @@ -0,0 +1,30 @@ +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> { + create_table(m, "shipping_methods", + &[ + + ("id", ColType::PkAuto), + + ("code", ColType::StringUniq), + ("name", ColType::String), + ("price_cents", ColType::BigIntegerWithDefault(0)), + ("requires_pickup_point", ColType::BooleanWithDefault(false)), + ("enabled", ColType::BooleanWithDefault(true)), + ("position", ColType::IntegerWithDefault(0)), + ], + &[ + ] + ).await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "shipping_methods").await + } +} diff --git a/migration/src/m20260616_150812_add_shipping_fields_to_orders.rs b/migration/src/m20260616_150812_add_shipping_fields_to_orders.rs new file mode 100644 index 0000000..be50bc5 --- /dev/null +++ b/migration/src/m20260616_150812_add_shipping_fields_to_orders.rs @@ -0,0 +1,28 @@ +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> { + add_column(m, "orders", "payment_method", ColType::StringNull).await?; + add_column(m, "orders", "carrier_code", ColType::StringNull).await?; + add_column(m, "orders", "carrier_name", ColType::StringNull).await?; + add_column(m, "orders", "shipping_cents", ColType::BigIntegerWithDefault(0)).await?; + add_column(m, "orders", "pickup_point_id", ColType::StringNull).await?; + add_column(m, "orders", "pickup_point_name", ColType::StringNull).await?; + Ok(()) + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + remove_column(m, "orders", "payment_method").await?; + remove_column(m, "orders", "carrier_code").await?; + remove_column(m, "orders", "carrier_name").await?; + remove_column(m, "orders", "shipping_cents").await?; + remove_column(m, "orders", "pickup_point_id").await?; + remove_column(m, "orders", "pickup_point_name").await?; + Ok(()) + } +} diff --git a/src/app.rs b/src/app.rs index 138b5b0..8084335 100644 --- a/src/app.rs +++ b/src/app.rs @@ -53,6 +53,7 @@ impl Hooks for App { Ok(vec![ Box::new(initializers::view_engine::ViewEngineInitializer), Box::new(initializers::admin_seeder::AdminSeeder), + Box::new(initializers::shipping_seeder::ShippingSeeder), ]) } diff --git a/src/controllers/catalog.rs b/src/controllers/catalog.rs index c5a7d30..7be6273 100644 --- a/src/controllers/catalog.rs +++ b/src/controllers/catalog.rs @@ -53,7 +53,7 @@ fn normalize_empty(value: Option) -> Option { /// Parse a price typed in major units ("12", "12.5", "12.34") into integer /// minor units (cents). Rejects negatives and more than two decimals. -fn parse_price_to_cents(value: &str) -> Result { +pub(crate) fn parse_price_to_cents(value: &str) -> Result { let value = value.trim().replace(',', "."); let invalid = || Error::BadRequest("invalid price".to_string()); let (whole, frac) = match value.split_once('.') { diff --git a/src/controllers/orders.rs b/src/controllers/orders.rs index 8dfe119..9ff388b 100644 --- a/src/controllers/orders.rs +++ b/src/controllers/orders.rs @@ -5,7 +5,7 @@ use crate::{ catalog::format_price, i18n::current_lang, }, - models::_entities::{order_items, orders, products}, + models::_entities::{order_items, orders, products, shipping_methods}, }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use loco_rs::prelude::*; @@ -18,6 +18,7 @@ use time::Duration as TimeDuration; use uuid::Uuid; const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"]; +const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"]; #[derive(Debug, Deserialize)] struct CheckoutForm { @@ -28,6 +29,26 @@ struct CheckoutForm { zip: String, country: String, note: Option, + payment_method: String, + carrier_code: String, + pickup_point_id: Option, + pickup_point_name: Option, +} + +fn setting<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> { + ctx.config + .settings + .as_ref() + .and_then(|settings| settings.get(key)) + .and_then(|value| value.as_str()) +} + +async fn enabled_shipping_methods(ctx: &AppContext) -> Result> { + Ok(shipping_methods::Entity::find() + .filter(shipping_methods::Column::Enabled.eq(true)) + .order_by_asc(shipping_methods::Column::Position) + .all(&ctx.db) + .await?) } #[derive(Debug, Deserialize)] @@ -59,7 +80,7 @@ async fn checkout_page( ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { - let (lines, _valid, total) = resolve_cart(&ctx, &jar).await?; + let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; if lines.is_empty() { return format::redirect("/cart"); } @@ -69,13 +90,30 @@ async fn checkout_page( .unwrap_or("EUR") .to_string(); + let methods: Vec = enabled_shipping_methods(&ctx) + .await? + .iter() + .map(|m| { + json!({ + "code": m.code, + "name": m.name, + "price_cents": m.price_cents, + "price": format_price(m.price_cents), + "requires_pickup_point": m.requires_pickup_point, + }) + }) + .collect(); + format::view( &v, "shop/checkout.html", json!({ "items": lines, - "total": format_price(total), + "subtotal": format_price(subtotal), + "subtotal_cents": subtotal, "currency": currency, + "shipping_methods": methods, + "packeta_api_key": setting(&ctx, "packeta_api_key").unwrap_or(""), "lang": current_lang(&jar), }), ) @@ -94,11 +132,35 @@ async fn place_order( let email = trimmed(&form.email) .ok_or_else(|| Error::BadRequest("email is required".to_string()))?; + if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) { + return Err(Error::BadRequest("invalid payment method".to_string())); + } + + // Resolve the chosen carrier from the enabled methods (price is taken from + // the DB, never the form, so the customer can't pick their own fee). + let method = shipping_methods::Entity::find() + .filter(shipping_methods::Column::Code.eq(&form.carrier_code)) + .filter(shipping_methods::Column::Enabled.eq(true)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?; + + let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point { + let id = form + .pickup_point_id + .as_deref() + .and_then(trimmed) + .ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?; + (Some(id), form.pickup_point_name.as_deref().and_then(trimmed)) + } else { + (None, None) + }; + let txn = ctx.db.begin().await?; // Snapshot prices/names and decrement stock atomically. Re-checking stock // inside the transaction guards against it selling out between cart and pay. - let mut total: i64 = 0; + let mut subtotal: i64 = 0; let mut currency = "EUR".to_string(); let mut snapshots = Vec::new(); for (product_id, qty) in &valid { @@ -115,7 +177,7 @@ async fn place_order( } currency = product.currency.clone(); let line_total = product.price_cents * i64::from(*qty); - total += line_total; + subtotal += line_total; let mut active = product.clone().into_active_model(); active.stock = Set(product.stock - *qty); @@ -129,13 +191,19 @@ async fn place_order( email: Set(email), customer_name: Set(trimmed(&form.customer_name)), status: Set("pending".to_string()), - total_cents: Set(total), + total_cents: Set(subtotal + method.price_cents), currency: Set(currency), address: Set(trimmed(&form.address)), city: Set(trimmed(&form.city)), zip: Set(trimmed(&form.zip)), country: Set(trimmed(&form.country)), note: Set(form.note.as_deref().and_then(trimmed)), + payment_method: Set(Some(form.payment_method.clone())), + carrier_code: Set(Some(method.code.clone())), + carrier_name: Set(Some(method.name.clone())), + shipping_cents: Set(method.price_cents), + pickup_point_id: Set(pickup_point_id), + pickup_point_name: Set(pickup_point_name), ..Default::default() } .insert(&txn) @@ -186,6 +254,8 @@ async fn order_with_items( "email": order.email, "customer_name": order.customer_name, "status": order.status, + "subtotal": format_price(order.total_cents - order.shipping_cents), + "shipping": format_price(order.shipping_cents), "total": format_price(order.total_cents), "currency": order.currency, "address": order.address, @@ -193,6 +263,13 @@ async fn order_with_items( "zip": order.zip, "country": order.country, "note": order.note, + "payment_method": order.payment_method, + "carrier_name": order.carrier_name, + "pickup_point_name": order.pickup_point_name, + // Numeric, sequential order id doubles as the bank variable symbol. + "variable_symbol": order.id, + "bank_iban": setting(ctx, "bank_iban").unwrap_or(""), + "bank_account_name": setting(ctx, "bank_account_name").unwrap_or(""), "created_at": order.created_at.to_rfc3339(), }); Ok((order_json, items_json)) @@ -283,6 +360,66 @@ async fn admin_order_show( ) } +#[debug_handler] +async fn admin_shipping( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + let methods = shipping_methods::Entity::find() + .order_by_asc(shipping_methods::Column::Position) + .all(&ctx.db) + .await?; + let rows: Vec = methods + .iter() + .map(|m| { + json!({ + "id": m.id, + "code": m.code, + "name": m.name, + "price": format_price(m.price_cents), + "requires_pickup_point": m.requires_pickup_point, + "enabled": m.enabled, + }) + }) + .collect(); + format::view( + &v, + "admin/shipping/index.html", + json!({ "methods": rows, "lang": current_lang(&jar) }), + ) +} + +#[derive(Debug, Deserialize)] +struct ShippingForm { + price: String, + enabled: Option, +} + +#[debug_handler] +async fn admin_shipping_update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + admin::current_admin(auth, &ctx).await?; + let method = shipping_methods::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + let mut active = method.into_active_model(); + active.price_cents = Set(crate::controllers::catalog::parse_price_to_cents(&form.price)?); + active.enabled = Set(matches!( + form.enabled.as_deref(), + Some("on" | "true" | "1") + )); + active.update(&ctx.db).await?; + format::redirect("/admin/shipping") +} + #[debug_handler] async fn admin_order_status( auth: auth::JWT, @@ -313,4 +450,6 @@ pub fn routes() -> Routes { .add("/admin/orders", get(admin_orders)) .add("/admin/orders/{id}", get(admin_order_show)) .add("/admin/orders/{id}/status", post(admin_order_status)) + .add("/admin/shipping", get(admin_shipping)) + .add("/admin/shipping/{id}", post(admin_shipping_update)) } diff --git a/src/initializers/mod.rs b/src/initializers/mod.rs index a5780a7..0f2a40c 100644 --- a/src/initializers/mod.rs +++ b/src/initializers/mod.rs @@ -1,2 +1,3 @@ pub mod admin_seeder; +pub mod shipping_seeder; pub mod view_engine; diff --git a/src/initializers/shipping_seeder.rs b/src/initializers/shipping_seeder.rs new file mode 100644 index 0000000..83ff8a1 --- /dev/null +++ b/src/initializers/shipping_seeder.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use crate::models::_entities::shipping_methods; + +/// (code, display name, price in cents, requires a pickup point) +const CARRIERS: [(&str, &str, i64, bool); 3] = [ + ("packeta", "Packeta", 300, true), + ("dpd", "DPD", 450, false), + ("dhl", "DHL", 500, false), +]; + +pub struct ShippingSeeder; + +#[async_trait] +impl Initializer for ShippingSeeder { + fn name(&self) -> String { + "shipping-seeder".to_string() + } + + async fn before_run(&self, ctx: &AppContext) -> Result<()> { + for (position, (code, name, price_cents, requires_pickup_point)) in + CARRIERS.iter().enumerate() + { + let exists = shipping_methods::Entity::find() + .filter(shipping_methods::Column::Code.eq(*code)) + .one(&ctx.db) + .await? + .is_some(); + if exists { + continue; + } + shipping_methods::ActiveModel { + code: Set((*code).to_string()), + name: Set((*name).to_string()), + price_cents: Set(*price_cents), + requires_pickup_point: Set(*requires_pickup_point), + enabled: Set(true), + position: Set(position as i32), + ..Default::default() + } + .insert(&ctx.db) + .await?; + } + Ok(()) + } +} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index 04ca0ab..91dce73 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -10,4 +10,5 @@ pub mod product_images; pub mod product_product_tags; pub mod product_tags; pub mod products; +pub mod shipping_methods; pub mod users; diff --git a/src/models/_entities/orders.rs b/src/models/_entities/orders.rs index adc4ed3..b52e55a 100644 --- a/src/models/_entities/orders.rs +++ b/src/models/_entities/orders.rs @@ -23,6 +23,12 @@ pub struct Model { pub country: Option, #[sea_orm(column_type = "Text", nullable)] pub note: Option, + pub payment_method: Option, + pub carrier_code: Option, + pub carrier_name: Option, + pub shipping_cents: i64, + pub pickup_point_id: Option, + pub pickup_point_name: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index 00f718a..393605e 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -8,4 +8,5 @@ pub use super::product_images::Entity as ProductImages; pub use super::product_product_tags::Entity as ProductProductTags; pub use super::product_tags::Entity as ProductTags; pub use super::products::Entity as Products; +pub use super::shipping_methods::Entity as ShippingMethods; pub use super::users::Entity as Users; diff --git a/src/models/_entities/shipping_methods.rs b/src/models/_entities/shipping_methods.rs new file mode 100644 index 0000000..9f4f904 --- /dev/null +++ b/src/models/_entities/shipping_methods.rs @@ -0,0 +1,23 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "shipping_methods")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub code: String, + pub name: String, + pub price_cents: i64, + pub requires_pickup_point: bool, + pub enabled: bool, + pub position: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/src/models/mod.rs b/src/models/mod.rs index 3fcd45a..9c1e287 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -8,3 +8,4 @@ pub mod product_tags; pub mod product_product_tags; pub mod orders; pub mod order_items; +pub mod shipping_methods; diff --git a/src/models/shipping_methods.rs b/src/models/shipping_methods.rs new file mode 100644 index 0000000..3b95d29 --- /dev/null +++ b/src/models/shipping_methods.rs @@ -0,0 +1,28 @@ +use sea_orm::entity::prelude::*; +pub use super::_entities::shipping_methods::{ActiveModel, Model, Entity}; +pub type ShippingMethods = Entity; + +#[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) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/tests/models/mod.rs b/tests/models/mod.rs index 3fa7d4b..7f0d467 100644 --- a/tests/models/mod.rs +++ b/tests/models/mod.rs @@ -5,4 +5,5 @@ mod products; mod product_images; mod product_tags; mod orders; -mod order_items; \ No newline at end of file +mod order_items; +mod shipping_methods; \ No newline at end of file diff --git a/tests/models/shipping_methods.rs b/tests/models/shipping_methods.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/shipping_methods.rs @@ -0,0 +1,31 @@ +use gitara_web::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +}