Compare commits
20 Commits
3c51273cc3
...
0e7d53b582
Author | SHA1 | Date |
---|---|---|
|
0e7d53b582 | |
|
96edd50450 | |
|
dd590e1530 | |
|
3133a9aa8c | |
|
a52ab42c61 | |
|
77e70c3536 | |
|
4d9a7c3e12 | |
|
6d827365ac | |
|
f9c32fd7dc | |
|
998cc778e6 | |
|
076dc76ca4 | |
|
5f260455cb | |
|
bf33858e41 | |
|
6ed489835f | |
|
febc77032b | |
|
65bfe3077f | |
|
f8f1580fc7 | |
|
aca7ae8ecf | |
|
03aa9f8b94 | |
|
2d514e9258 |
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,32 @@
|
|||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient x1="8.042%" y1="0%" x2="65.682%" y2="23.865%" id="a">
|
||||
<stop stop-color="hsl(228, 100%, 84%)" stop-opacity="0" offset="0%"/>
|
||||
<stop stop-color="hsl(228, 100%, 84%)" stop-opacity=".631" offset="63.146%"/>
|
||||
<stop stop-color="hsl(228, 100%, 84%)" offset="100%"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(1 1)">
|
||||
<path d="M36 18c0-9.94-8.06-18-18-18" id="Oval-2" stroke="url(#a)" stroke-width="2">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 18 18"
|
||||
to="360 18 18"
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite" />
|
||||
</path>
|
||||
<circle fill="#fff" cx="36" cy="18" r="1">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 18 18"
|
||||
to="360 18 18"
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -1 +0,0 @@
|
|||
hello from the resource file!
|
|
@ -548,30 +548,26 @@ video {
|
|||
bottom: 0px;
|
||||
}
|
||||
|
||||
.z-0 {
|
||||
z-index: 0;
|
||||
.end-0 {
|
||||
inset-inline-end: 0px;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
.top-0 {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.z-20 {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.z-40 {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.col-start-2 {
|
||||
grid-column-start: 2;
|
||||
}
|
||||
|
||||
.row-span-full {
|
||||
grid-row: 1 / -1;
|
||||
}
|
||||
|
||||
.row-span-2 {
|
||||
grid-row: span 2 / span 2;
|
||||
}
|
||||
|
@ -580,26 +576,8 @@ video {
|
|||
grid-row-start: 1;
|
||||
}
|
||||
|
||||
.m-8 {
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.m-4 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.m-6 {
|
||||
margin: 1.5rem;
|
||||
}
|
||||
|
||||
.my-10 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
.m-3 {
|
||||
margin: 0.75rem;
|
||||
}
|
||||
|
||||
.my-7 {
|
||||
|
@ -607,14 +585,47 @@ video {
|
|||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.my-3 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.mx-3 {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.mx-9 {
|
||||
margin-left: 2.25rem;
|
||||
margin-right: 2.25rem;
|
||||
}
|
||||
|
||||
.my-4 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.my-5 {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.-mt-20 {
|
||||
margin-top: -5rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-20 {
|
||||
margin-left: 5rem;
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
@ -631,20 +642,36 @@ video {
|
|||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.ml-\[200px\] {
|
||||
margin-left: 200px;
|
||||
.mt-3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.ml-\[100px\] {
|
||||
margin-left: 100px;
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ml-\[70px\] {
|
||||
margin-left: 70px;
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-20 {
|
||||
margin-left: 5rem;
|
||||
.ml-16 {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
.mb-10 {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mt-5 {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
|
@ -671,6 +698,10 @@ video {
|
|||
height: 3rem;
|
||||
}
|
||||
|
||||
.h-14 {
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.h-20 {
|
||||
height: 5rem;
|
||||
}
|
||||
|
@ -687,34 +718,42 @@ video {
|
|||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.w-11\/12 {
|
||||
width: 91.666667%;
|
||||
}
|
||||
|
||||
.w-14 {
|
||||
width: 3.5rem;
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-8 {
|
||||
width: 2rem;
|
||||
}
|
||||
|
@ -731,14 +770,6 @@ video {
|
|||
width: 100vw;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
@ -757,12 +788,28 @@ video {
|
|||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-\[1fr_100px\] {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
|
||||
.grid-cols-\[auto_1fr\] {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.grid-cols-\[repeat\(4\2c _auto\)\] {
|
||||
grid-template-columns: repeat(4, auto);
|
||||
}
|
||||
|
||||
.grid-cols-\[1fr_100px\] {
|
||||
grid-template-columns: 1fr 100px;
|
||||
.grid-cols-\[1fr_auto\] {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.grid-cols-\[1fr_50px\] {
|
||||
grid-template-columns: 1fr 50px;
|
||||
}
|
||||
|
||||
.grid-cols-\[1fr_70px\] {
|
||||
grid-template-columns: 1fr 70px;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
|
@ -789,6 +836,10 @@ video {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-items-center {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.gap-x-5 {
|
||||
-moz-column-gap: 1.25rem;
|
||||
column-gap: 1.25rem;
|
||||
|
@ -798,18 +849,26 @@ video {
|
|||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-y-3 {
|
||||
row-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-y-reverse: 0;
|
||||
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
||||
}
|
||||
|
||||
.self-center {
|
||||
align-self: center;
|
||||
.place-self-center {
|
||||
place-self: center;
|
||||
}
|
||||
|
||||
.justify-self-center {
|
||||
justify-self: center;
|
||||
.self-end {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.self-center {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
|
@ -842,16 +901,16 @@ video {
|
|||
border-color: rgb(255 255 255 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-light-gray {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: hsl(229 24% 87% / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-green-200 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(187 247 208 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(229 24% 87% / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-magnolia {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(217 100% 97% / var(--tw-bg-opacity));
|
||||
|
@ -862,48 +921,76 @@ video {
|
|||
background-color: hsl(213 96% 18% / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purplish-blue {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(243 100% 62% / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
.\!bg-red-500 {
|
||||
--tw-bg-opacity: 1 !important;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.bg-cool-gray {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(231 11% 63% / var(--tw-bg-opacity));
|
||||
.\!bg-white {
|
||||
--tw-bg-opacity: 1 !important;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.bg-light-blue {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(206 94% 87% / var(--tw-bg-opacity));
|
||||
.\!bg-light-blue {
|
||||
--tw-bg-opacity: 1 !important;
|
||||
background-color: hsl(206 94% 87% / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.bg-pastel-blue {
|
||||
.bg-light-gray {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(228 100% 84% / var(--tw-bg-opacity));
|
||||
background-color: hsl(229 24% 87% / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purplish-blue {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(243 100% 62% / var(--tw-bg-opacity));
|
||||
.bg-light-gray\/50 {
|
||||
background-color: hsl(229 24% 87% / 0.5);
|
||||
}
|
||||
|
||||
.bg-light-gray\/25 {
|
||||
background-color: hsl(229 24% 87% / 0.25);
|
||||
}
|
||||
|
||||
.bg-magnolia\/75 {
|
||||
background-color: hsl(217 100% 97% / 0.75);
|
||||
}
|
||||
|
||||
.bg-sidebar-mobile {
|
||||
background-image: url("images/bg-sidebar-mobile.svg");
|
||||
}
|
||||
|
||||
.bg-auto {
|
||||
background-size: auto;
|
||||
}
|
||||
|
||||
.bg-cover {
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bg-no-repeat {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.fill-current {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.p-1 {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
|
@ -914,6 +1001,11 @@ video {
|
|||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.py-20 {
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
|
@ -929,20 +1021,33 @@ video {
|
|||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.pl-6 {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.pt-3 {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pl-8 {
|
||||
padding-left: 2rem;
|
||||
.pl-4 {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.pl-5 {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.pl-6 {
|
||||
padding-left: 1.5rem;
|
||||
.pb-5 {
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
|
@ -960,6 +1065,11 @@ video {
|
|||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
@ -977,6 +1087,11 @@ video {
|
|||
color: hsl(231 11% 63% / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(34 197 94 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-light-gray {
|
||||
--tw-text-opacity: 1;
|
||||
color: hsl(229 24% 87% / var(--tw-text-opacity));
|
||||
|
@ -992,6 +1107,50 @@ video {
|
|||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.\!text-marine-blue {
|
||||
--tw-text-opacity: 1 !important;
|
||||
color: hsl(213 96% 18% / var(--tw-text-opacity)) !important;
|
||||
}
|
||||
|
||||
.text-purplish-blue {
|
||||
--tw-text-opacity: 1;
|
||||
color: hsl(243 100% 62% / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-magnolia {
|
||||
--tw-text-opacity: 1;
|
||||
color: hsl(217 100% 97% / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-alabaster {
|
||||
--tw-text-opacity: 1;
|
||||
color: hsl(231 100% 99% / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.accent-green-500 {
|
||||
accent-color: #22c55e;
|
||||
}
|
||||
|
||||
.accent-marine-blue {
|
||||
accent-color: hsl(213, 96%, 18%);
|
||||
}
|
||||
|
||||
.accent-pastel-blue {
|
||||
accent-color: hsl(228, 100%, 84%);
|
||||
}
|
||||
|
||||
.accent-light-blue {
|
||||
accent-color: hsl(206, 94%, 87%);
|
||||
}
|
||||
|
||||
.accent-purplish-blue {
|
||||
accent-color: hsl(243, 100%, 62%);
|
||||
}
|
||||
|
||||
.drop-shadow-xl {
|
||||
--tw-drop-shadow: drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08));
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
|
@ -1111,15 +1270,105 @@ html {
|
|||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.checked\:bg-marine-blue:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: hsl(213 96% 18% / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.checked\:bg-blue-500:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.checked\:after\:ml-\[1\.2rem\]:checked::after {
|
||||
content: var(--tw-content);
|
||||
margin-left: 1.2rem;
|
||||
}
|
||||
|
||||
.invalid\:border-strawberry-red:invalid {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: hsl(354 84% 57% / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.invalid\:border-red-500:invalid {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.invalid\:border-green-500:invalid {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:cursor-pointer:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hover\:border-purplish-blue:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: hsl(243 100% 62% / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:border-green-700:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(21 128 61 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:border-red-700:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(185 28 28 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-magnolia\/25:hover {
|
||||
background-color: hsl(217 100% 97% / 0.25);
|
||||
}
|
||||
|
||||
.hover\:bg-magnolia\/\[10\]:hover {
|
||||
background-color: hsl(217 100% 97% / 10);
|
||||
}
|
||||
|
||||
.hover\:bg-magnolia\/\[\.1\]:hover {
|
||||
background-color: hsl(217 100% 97% / .1);
|
||||
}
|
||||
|
||||
.hover\:bg-red-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-green-500:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-marine-blue:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: hsl(213 96% 18% / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:outline-none:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.focus\:outline-marine-blue:focus {
|
||||
outline-color: hsl(213, 96%, 18%);
|
||||
}
|
||||
|
||||
.focus\:outline-green-500:focus {
|
||||
outline-color: #22c55e;
|
||||
}
|
||||
|
||||
.active\:border-red-500:active {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.active\:bg-red-500:active {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.peer:checked ~ .peer-checked\:border-purplish-blue {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: hsl(243 100% 62% / var(--tw-border-opacity));
|
||||
|
@ -1134,8 +1383,16 @@ html {
|
|||
background-color: hsl(217 100% 97% / 0.5);
|
||||
}
|
||||
|
||||
.peer:checked ~ .peer-checked\:bg-magnolia\/20 {
|
||||
background-color: hsl(217 100% 97% / 0.2);
|
||||
.peer:checked ~ .peer-checked\:bg-magnolia\/75 {
|
||||
background-color: hsl(217 100% 97% / 0.75);
|
||||
}
|
||||
|
||||
.peer:checked ~ .peer-checked\:bg-magnolia\/25 {
|
||||
background-color: hsl(217 100% 97% / 0.25);
|
||||
}
|
||||
|
||||
.peer:checked ~ .peer-checked\:bg-magnolia\/\[\.1\] {
|
||||
background-color: hsl(217 100% 97% / .1);
|
||||
}
|
||||
|
||||
.peer:checked ~ .peer-checked\:text-cool-gray {
|
||||
|
@ -1153,6 +1410,30 @@ html {
|
|||
grid-row: span 2 / span 2;
|
||||
}
|
||||
|
||||
.md\:row-span-1 {
|
||||
grid-row: span 1 / span 1;
|
||||
}
|
||||
|
||||
.md\:mx-10 {
|
||||
margin-left: 2.5rem;
|
||||
margin-right: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:mx-4 {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.md\:my-10 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:my-7 {
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.md\:ml-20 {
|
||||
margin-left: 5rem;
|
||||
}
|
||||
|
@ -1165,6 +1446,22 @@ html {
|
|||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.md\:ml-8 {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.md\:ml-10 {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:ml-24 {
|
||||
margin-left: 6rem;
|
||||
}
|
||||
|
||||
.md\:mt-5 {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.md\:flex {
|
||||
display: flex;
|
||||
}
|
||||
|
@ -1193,6 +1490,30 @@ html {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.md\:h-32 {
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.md\:h-40 {
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
.md\:h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.md\:h-20 {
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
.md\:h-24 {
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.md\:w-32 {
|
||||
width: 8rem;
|
||||
}
|
||||
|
@ -1209,8 +1530,8 @@ html {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.md\:w-20 {
|
||||
width: 5rem;
|
||||
.md\:grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.md\:grid-cols-1 {
|
||||
|
@ -1225,6 +1546,10 @@ html {
|
|||
grid-template-rows: repeat(4, auto);
|
||||
}
|
||||
|
||||
.md\:grid-rows-\[1fr_auto_auto\] {
|
||||
grid-template-rows: 1fr auto auto;
|
||||
}
|
||||
|
||||
.md\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
@ -1245,6 +1570,10 @@ html {
|
|||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.md\:justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.md\:gap-x-4 {
|
||||
-moz-column-gap: 1rem;
|
||||
column-gap: 1rem;
|
||||
|
@ -1254,6 +1583,14 @@ html {
|
|||
row-gap: 1.75rem;
|
||||
}
|
||||
|
||||
.md\:gap-y-4 {
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.md\:place-self-start {
|
||||
place-self: start;
|
||||
}
|
||||
|
||||
.md\:rounded-2xl {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
@ -1283,6 +1620,10 @@ html {
|
|||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.md\:p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.md\:px-24 {
|
||||
padding-left: 6rem;
|
||||
padding-right: 6rem;
|
||||
|
@ -1293,6 +1634,16 @@ html {
|
|||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.md\:py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.md\:px-0 {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.md\:pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
@ -1305,6 +1656,10 @@ html {
|
|||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.md\:pb-10 {
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
|
@ -1315,6 +1670,21 @@ html {
|
|||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.md\:text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.md\:text-6xl {
|
||||
font-size: 3.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.md\:text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.md\:drop-shadow-2xl {
|
||||
--tw-drop-shadow: drop-shadow(0 25px 25px rgb(0 0 0 / 0.15));
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
|
|
|
@ -9,10 +9,15 @@
|
|||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="./assets/images/favicon-32x32.png"
|
||||
th:href="'public/images/favicon-32x32.png'"
|
||||
href="../public/images/favicon-32x32.png"
|
||||
/>
|
||||
<link href="../public/out.css" rel="stylesheet" />
|
||||
|
||||
<link
|
||||
th:href="'public/out.css'"
|
||||
href="../public/out.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script th:src="'public/htmx.min.js'" src="../public/htmx.min.js"></script>
|
||||
<title>Frontend Mentor | Multi-step form</title>
|
||||
|
||||
<!-- Feel free to remove these styles or customise in your own stylesheet 👍 -->
|
||||
|
@ -27,7 +32,25 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="bg-light-gray h-screen w-screen">
|
||||
<!-- here be immediate hx-get for the form. to subscitute the body -->
|
||||
<main class="grid place-content-center w-screen h-screen bg-magnolia">
|
||||
<section
|
||||
hx-get="/get-form"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<!-- here be immediate hx-get for the form. to subscitute the body -->
|
||||
<img class="w-14 text-green-500 fill-current" th:src="'public/images/tail-spin.svg'" src="../public/images/tail-spin.svg" alt="loading..." />
|
||||
</section>
|
||||
|
||||
<section class="absolute top-0 end-0" id="new-session-control">
|
||||
<button hx-get="/force-new-session">Request new session</button>
|
||||
</section>
|
||||
<div class="fixed inset-x-0 bottom-0 attribution">
|
||||
Challenge by
|
||||
<a href="https://www.frontendmentor.io?ref=challenge" target="_blank"
|
||||
>Frontend Mentor</a
|
||||
>. Coded by <a href="#">Your Name Here</a>.
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -35,55 +35,82 @@
|
|||
</p>
|
||||
<![endif]-->
|
||||
|
||||
<article
|
||||
<form
|
||||
class="flex flex-col items-center w-screen h-screen md:grid md:items-start md:p-5 md:bg-white md:rounded-2xl md:grid-cols-[auto_1fr] md:w-desktop-form md:h-desktop-form md:drop-shadow-2xl"
|
||||
id="form-step"
|
||||
th:fragment="formFragment(formData)"
|
||||
hx-post="/submit-step/1/2"
|
||||
hx-swap="outerHTML"
|
||||
action="/submit-step/1/2"
|
||||
method="post"
|
||||
>
|
||||
<summary
|
||||
class="w-full h-44 bg-no-repeat md:row-span-2 bg-sidebar-mobile marker:text-white md:bg-sidebar-desktop md:h-[568px] md:w-[274px]"
|
||||
class="w-full h-44 bg-no-repeat bg-cover md:row-span-2 bg-sidebar-mobile marker:text-white md:bg-sidebar-desktop md:h-[568px] md:w-[274px]"
|
||||
id="sidebar"
|
||||
th:fragment="stepsSummary(formData)"
|
||||
>
|
||||
<ol
|
||||
class="grid grid-cols-[repeat(4,_auto)] gap-x-5 content-center items-center place-content-center h-24 md:flex-col md:h-full md:grid-rows-[repeat(4,_auto)] md:grid-cols-1 md:content-start md:p-10 md:gap-y-7 text-white text-sm uppercase"
|
||||
>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<li
|
||||
class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]"
|
||||
th:each="stepNum: ${formData.stepsAmount}"
|
||||
>
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
class="grid place-content-center w-8 h-8 font-bold text-white rounded-full border border-white"
|
||||
th:classappend="${stepNum.index} == ${formData.userAnswers.currentStep} ? '!bg-light-blue !text-marine-blue'"
|
||||
th:text="${stepNum.index}"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 1</span><span class="font-bold">Your info</span>
|
||||
<span class="text-light-gray"
|
||||
>Step <span th:text="${stepNum.index}">1</span></span
|
||||
><span class="font-bold" th:text="${stepNum.name}"
|
||||
>Your info</span
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<li
|
||||
class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]"
|
||||
th:remove="all"
|
||||
>
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 2</span><span class="font-bold">Select plan</span>
|
||||
<span class="text-light-gray">Step 2</span
|
||||
><span class="font-bold">Select plan</span>
|
||||
</p>
|
||||
</li>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<li
|
||||
class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]"
|
||||
th:remove="all"
|
||||
>
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 3</span><span class="font-bold">Add-ons</span>
|
||||
<span class="text-light-gray">Step 3</span
|
||||
><span class="font-bold">Add-ons</span>
|
||||
</p>
|
||||
</li>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<li
|
||||
class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]"
|
||||
th:remove="all"
|
||||
>
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 4</span><span class="font-bold">summary</span>
|
||||
<span class="text-light-gray">Step 4</span
|
||||
><span class="font-bold">summary</span>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
@ -93,34 +120,47 @@
|
|||
class="flex flex-col py-8 px-6 -mt-20 w-11/12 bg-white rounded-xl md:px-24 md:mt-0 md:w-full drop-shadow-xl md:drop-shadow-none"
|
||||
>
|
||||
<!-- Step 1 start -->
|
||||
<h1 class="text-2xl font-bold md:text-4xl text-marine-blue">Personal info</h1>
|
||||
<h1 class="text-2xl font-bold md:text-4xl text-marine-blue">
|
||||
Personal info
|
||||
</h1>
|
||||
<p class="py-3 text-cool-gray">
|
||||
Please provide your name, email address, and phone number.
|
||||
</p>
|
||||
<label for="name" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue">Name</label>
|
||||
<label for="name" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
th:value="${formData.userAnswers.step1.name}"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Stephen King"
|
||||
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg placeholder:text-cool-gray"
|
||||
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
|
||||
/>
|
||||
<label for="email" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
|
||||
>Email Address</label
|
||||
>
|
||||
<input
|
||||
id="email"
|
||||
th:value="${formData.userAnswers.step1.email}"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="e.g. stephenking@lorem.com"
|
||||
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg placeholder:text-cool-gray"
|
||||
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
|
||||
/>
|
||||
<label for="phone" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
|
||||
>Phone Number</label
|
||||
>
|
||||
<input
|
||||
id="phone"
|
||||
th:value="${formData.userAnswers.step1.phone}"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
placeholder="e.g. +1 234 567 890"
|
||||
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg placeholder:text-cool-gray"
|
||||
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
|
||||
/>
|
||||
|
||||
<!-- Step 1 end -->
|
||||
|
@ -131,13 +171,19 @@
|
|||
class="flex flex-row items-center py-4 w-full bg-white md:items-end md:h-full"
|
||||
>
|
||||
<div class="grow"></div>
|
||||
<input
|
||||
type="submit"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-marine-blue"
|
||||
value="Next Step"
|
||||
/>
|
||||
<a
|
||||
th:remove="all"
|
||||
href="step2.html"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-marine-blue"
|
||||
>Next Step</a
|
||||
>
|
||||
</section>
|
||||
</article>
|
||||
</form>
|
||||
<div class="fixed inset-x-0 bottom-0 attribution">
|
||||
Challenge by
|
||||
<a href="https://www.frontendmentor.io?ref=challenge" target="_blank"
|
||||
|
|
|
@ -36,13 +36,19 @@
|
|||
<![endif]-->
|
||||
|
||||
<main class="grid place-content-center h-screen">
|
||||
<article
|
||||
<form
|
||||
class="flex flex-col items-center w-screen h-screen md:grid md:items-start md:p-5 md:bg-white md:rounded-2xl md:grid-cols-[auto_1fr] md:w-desktop-form md:h-desktop-form md:drop-shadow-2xl"
|
||||
id="form-step"
|
||||
hx-post="/submit-step/2/3"
|
||||
hx-swap="outerHTML"
|
||||
action="/submit-step/2/3"
|
||||
method="post"
|
||||
th:fragment="formFragment(formData)"
|
||||
>
|
||||
<summary
|
||||
class="w-full h-44 bg-no-repeat md:row-span-2 bg-sidebar-mobile marker:text-white md:bg-sidebar-desktop md:h-[568px] md:w-[274px]"
|
||||
id="sidebar"
|
||||
th:replace="~{step1::stepsSummary (${formData})}"
|
||||
>
|
||||
<ol
|
||||
class="grid grid-cols-[repeat(4,_auto)] gap-x-5 content-center items-center place-content-center h-24 md:flex-col md:h-full md:grid-rows-[repeat(4,_auto)] md:grid-cols-1 md:content-start md:p-10 md:gap-y-7 text-white text-sm uppercase"
|
||||
|
@ -101,71 +107,120 @@
|
|||
<h1 class="text-2xl font-bold md:text-4xl text-marine-blue">
|
||||
Select your plan
|
||||
</h1>
|
||||
<p class="py-3 text-cool-gray">
|
||||
<p class="py-3 mb-4 text-cool-gray">
|
||||
You have the option of monthly or yearly billing.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-col w-full md:flex-row"
|
||||
class="flex flex-col gap-y-3 my-3 w-full md:flex-row md:gap-x-4"
|
||||
id="plan-type-inputs"
|
||||
th:fragment="planTypesInputs(formData)"
|
||||
>
|
||||
<label for="ArcadePlanType" class="relative h-20 md:w-32">
|
||||
<label
|
||||
for="ArcadePlanType"
|
||||
class="relative h-20 md:h-40 md:grow"
|
||||
th:each="planType: ${formData.availablePlans}"
|
||||
th:for="${planType}"
|
||||
>
|
||||
<input
|
||||
id="ArcadePlanType"
|
||||
th:id="${planType}"
|
||||
type="radio"
|
||||
name="plan-type"
|
||||
value="Arcade"
|
||||
th:value="${planType}"
|
||||
class="hidden peer"
|
||||
th:checked="${formData.userAnswers.step2.planType.toString()} == ${planType.toString()}"
|
||||
checked
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 inset-x-0 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia"
|
||||
class="grid absolute inset-y-0 inset-x-0 place-content-center h-full rounded-lg border md:grid-cols-1 md:p-4 grid-cols-[auto_1fr] border-light-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia/50 md:grid-rows-[1fr_auto_auto] hover:bg-magnolia/[.1] hover:border-purplish-blue"
|
||||
>
|
||||
<span class="">Arcade</span>
|
||||
<img
|
||||
class="row-span-2 place-self-center px-4 md:row-span-1 md:place-self-start md:px-0"
|
||||
th:src="${planType.iconPath}" src="../public/images/icon-arcade.svg" alt="" />
|
||||
<h2 th:text="${planType}" class="font-semibold text-marine-blue">Arcade</h2>
|
||||
<p
|
||||
th:text="|$${formData.planCost(planType)}/${formData.periodCostLabel}|"
|
||||
class="text-sm text-cool-gray"
|
||||
>
|
||||
$90/yr
|
||||
</p>
|
||||
<p th:if="${formData.userAnswers.step2.isYearly}" class="text-xs text-marine-blue">
|
||||
2 months free
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label for="AdvancedPlanType" class="relative h-20 md:w-32">
|
||||
<label
|
||||
for="AdvancedPlanType"
|
||||
class="relative h-20 md:w-32"
|
||||
th:remove="all"
|
||||
>
|
||||
<input
|
||||
id="AdvancedPlanType"
|
||||
type="radio"
|
||||
name="plan-type"
|
||||
value="Advanced"
|
||||
class="hidden peer"
|
||||
th:checked="${formData.userAnswers.step2.planType.toString()} == 'Advanced'"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 inset-x-0 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia"
|
||||
>
|
||||
<span>Advanced</span>
|
||||
<img src="../public/images/icon-advanced.svg" alt="" />
|
||||
<h2>Advanced</h2>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label for="ProPlanType" class="relative h-20 md:w-32">
|
||||
<label
|
||||
for="ProPlanType"
|
||||
class="relative h-20 md:w-32"
|
||||
th:remove="all"
|
||||
>
|
||||
<input
|
||||
id="ProPlanType"
|
||||
type="radio"
|
||||
name="plan-type"
|
||||
value="Pro"
|
||||
class="hidden peer"
|
||||
th:checked="${formData.userAnswers.step2.planType.toString()} == 'Pro'"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 inset-x-0 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia"
|
||||
>
|
||||
<span>Pro</span>
|
||||
<img src="../public/images/icon-pro.svg" alt="" />
|
||||
<h2>Pro</h2>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-flow-col-dense place-content-center w-full rounded-lg bg-magnolia">
|
||||
<div
|
||||
class="grid grid-flow-col-dense place-content-center mt-3 w-full rounded-lg md:mt-5 bg-light-gray/25"
|
||||
>
|
||||
<div
|
||||
class="inline-grid grid-cols-3 place-items-center h-12 text-sm font-bold"
|
||||
>
|
||||
<input
|
||||
class="mr-2 w-9 h-5 ml-2 rounded-full appearance-none mt-[0.3rem] bg-marine-blue after:absolute after:h-3 after:w-3 after:rounded-full after:border-none after:bg-neutral-100 after:transition-[background-color_0.2s,transform_0.2s] checked:after:ml-[1.2rem] after:ml-[0.25rem] after:mt-[0.25rem] hover:cursor-pointer col-start-2 row-start-1 peer"
|
||||
class="mr-2 w-9 h-5 ml-2 rounded-full appearance-none mt-[0.3rem] bg-marine-blue after:absolute after:h-3 after:w-3 after:rounded-full after:border-none after:bg-neutral-100 after:transition-[background-color_0.2s,transform_0.2s] checked:after:ml-[1.2rem] after:ml-[0.25rem] after:mt-[0.25rem] hover:cursor-pointer col-start-2 row-start-1 peer md:mx-4"
|
||||
type="checkbox"
|
||||
name="isPackageYearly"
|
||||
role="switch"
|
||||
id="packageDuration"
|
||||
th:checked="${formData.userAnswers.step2.isYearly}"
|
||||
hx-get="/step2/planTypeInputs"
|
||||
hx-target="#plan-type-inputs"
|
||||
hx-swap="outerHTML"
|
||||
/>
|
||||
<span class="row-start-1 text-marine-blue peer-checked:text-cool-gray">Monthly</span>
|
||||
<span class="row-start-1 text-cool-gray peer-checked:text-marine-blue">Yearly</span>
|
||||
<p
|
||||
class="row-start-1 text-marine-blue peer-checked:text-cool-gray"
|
||||
>
|
||||
Monthly
|
||||
</p>
|
||||
<p
|
||||
class="row-start-1 text-cool-gray peer-checked:text-marine-blue"
|
||||
>
|
||||
Yearly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -177,18 +232,27 @@
|
|||
class="flex flex-row items-center py-4 w-full bg-white md:items-end md:h-full"
|
||||
>
|
||||
<a
|
||||
hx-post="/submit-step/2/1"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#form-step"
|
||||
href="step1.html"
|
||||
class="ml-6 text-sm font-semibold md:pb-3 md:ml-20 md:text-base text-cool-gray"
|
||||
class="ml-6 text-sm font-semibold md:pb-3 md:ml-24 md:text-base text-cool-gray"
|
||||
>Go Back</a
|
||||
>
|
||||
<div class="grow"></div>
|
||||
<input
|
||||
type="submit"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-marine-blue"
|
||||
value="Next Step"
|
||||
/>
|
||||
<a
|
||||
th:remove="all"
|
||||
href="step3.html"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-marine-blue"
|
||||
>Next Step</a
|
||||
>
|
||||
</section>
|
||||
</article>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -36,13 +36,19 @@
|
|||
<![endif]-->
|
||||
|
||||
<main class="grid place-content-center h-screen">
|
||||
<article
|
||||
<form
|
||||
class="flex flex-col items-center w-screen h-screen md:grid md:items-start md:p-5 md:bg-white md:rounded-2xl md:grid-cols-[auto_1fr] md:w-desktop-form md:h-desktop-form md:drop-shadow-2xl"
|
||||
id="form-step"
|
||||
hx-post="/submit-step/3/4"
|
||||
hx-swap="outerHTML"
|
||||
action="/submit-step/3/4"
|
||||
method="post"
|
||||
th:fragment="formFragment(formData)"
|
||||
>
|
||||
<summary
|
||||
class="w-full h-44 bg-no-repeat md:row-span-2 bg-sidebar-mobile marker:text-white md:bg-sidebar-desktop md:h-[568px] md:w-[274px]"
|
||||
id="sidebar"
|
||||
th:replace="~{step1::stepsSummary (${formData})}"
|
||||
>
|
||||
<ol
|
||||
class="grid grid-cols-[repeat(4,_auto)] gap-x-5 content-center items-center place-content-center h-24 md:flex-col md:h-full md:grid-rows-[repeat(4,_auto)] md:grid-cols-1 md:content-start md:p-10 md:gap-y-7 text-white text-sm uppercase"
|
||||
|
@ -101,62 +107,100 @@
|
|||
<h1 class="text-2xl font-bold md:text-4xl text-marine-blue">
|
||||
Pick add-ons
|
||||
</h1>
|
||||
<p class="py-3 text-cool-gray">
|
||||
<p class="py-3 md:pb-10 text-cool-gray">
|
||||
Add-ons help enhance your gaming experience.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-col w-full text-sm md:text-base"
|
||||
>
|
||||
<label for="multiplayer-games" class="relative pl-6 h-20 md:w-full">
|
||||
<div class="flex flex-col gap-y-3 w-full text-sm md:text-base">
|
||||
<label
|
||||
th:each="addon: ${formData.availableAddons}"
|
||||
for="multiplayer-games"
|
||||
th:for="${addon}"
|
||||
class="relative pl-5 h-16 md:w-full md:h-20"
|
||||
>
|
||||
<input
|
||||
id="multiplayer-games"
|
||||
th:id="${addon}"
|
||||
type="checkbox"
|
||||
value="OnlineService"
|
||||
th:value="${addon}"
|
||||
name="addon-services"
|
||||
class="my-7 w-6 h-6 peer"
|
||||
class="absolute z-40 my-5 w-6 h-6 text-white rounded-lg border md:my-7 accent-purplish-blue border-light-gray peer"
|
||||
th:checked="${formData.userAnswers.step3.containsAddon(addon)}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 inset-x-0 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia/50"
|
||||
class="absolute inset-y-0 inset-x-0 z-20 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia/75 hover:bg-magnolia/[.1] hover:border-purplish-blue"
|
||||
>
|
||||
<div class="grid place-content-center ml-20 h-full grid-cols-[1fr_100px]">
|
||||
<h1>Online Service</h1>
|
||||
<p>Access to multiplayer games</p>
|
||||
<p class="col-start-2 row-span-2 row-start-1 self-center">+$1/mo</p>
|
||||
<div
|
||||
class="grid place-content-center ml-16 h-full grid-cols-[1fr_70px]"
|
||||
>
|
||||
<h1 th:text="${addon.name}"
|
||||
class="font-bold text-marine-blue"
|
||||
>Online Service</h1>
|
||||
<p th:text="${addon.description}"
|
||||
class="text-xs text-cool-gray"
|
||||
>
|
||||
Access to multiplayer games
|
||||
</p>
|
||||
<p
|
||||
class="col-start-2 row-span-2 row-start-1 self-center text-purplish-blue"
|
||||
th:text="|+$${formData.addonCost(addon)}/${formData.periodCostLabel}|"
|
||||
>
|
||||
+$1/mo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label for="larger-storage" class="relative pl-6 h-20 md:w-full">
|
||||
<label
|
||||
for="larger-storage"
|
||||
class="relative pl-6 h-20 md:w-full"
|
||||
th:remove="all"
|
||||
>
|
||||
<input
|
||||
id="larger-storage"
|
||||
type="checkbox"
|
||||
name="addon-services"
|
||||
value="LargerStorage"
|
||||
class="my-7 w-6 h-6 peer"
|
||||
th:checked="${formData.userAnswers.step3.containsAddon('LargerStorage')}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 inset-x-0 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia/50"
|
||||
>
|
||||
<div class="grid place-content-center ml-20 h-full grid-cols-[1fr_100px]">
|
||||
<div
|
||||
class="grid place-content-center ml-20 h-full grid-cols-[1fr_100px]"
|
||||
>
|
||||
<h1>Larger storage</h1>
|
||||
<p>Extra 1TB of cloud save</p>
|
||||
<p class="col-start-2 row-span-2 row-start-1 self-center">+$2/mo</p>
|
||||
<p class="col-start-2 row-span-2 row-start-1 self-center">
|
||||
+$2/mo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label for="custom-profile" class="relative pl-6 h-20 md:w-full">
|
||||
<label
|
||||
for="custom-profile"
|
||||
class="relative pl-6 h-20 md:w-full"
|
||||
th:remove="all"
|
||||
>
|
||||
<input
|
||||
id="custom-profile"
|
||||
type="checkbox"
|
||||
name="addon-services"
|
||||
value="CustomProfile"
|
||||
class="my-7 w-6 h-6 peer"
|
||||
th:checked="${formData.userAnswers.step3.containsAddon('CustomProfile')}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 inset-x-0 rounded-lg border border-cool-gray peer-checked:border-purplish-blue peer-checked:bg-magnolia/50"
|
||||
>
|
||||
<div class="grid place-content-center ml-20 h-full grid-cols-[1fr_100px]">
|
||||
<div
|
||||
class="grid place-content-center ml-20 h-full grid-cols-[1fr_100px]"
|
||||
>
|
||||
<h1>Customizable Profile</h1>
|
||||
<p>Custom theme on your profile</p>
|
||||
<p class="col-start-2 row-span-2 row-start-1 self-center">+$2/mo</p>
|
||||
<p class="col-start-2 row-span-2 row-start-1 self-center">
|
||||
+$2/mo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
@ -170,18 +214,28 @@
|
|||
class="flex flex-row items-center py-4 w-full bg-white md:items-end md:h-full"
|
||||
>
|
||||
<a
|
||||
hx-post="/submit-step/3/2"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#form-step"
|
||||
href="step2.html"
|
||||
class="ml-6 text-sm font-semibold md:pb-3 md:ml-20 md:text-base text-cool-gray"
|
||||
class="ml-6 text-sm font-semibold md:pb-3 md:ml-24 md:text-base text-cool-gray"
|
||||
>Go Back</a
|
||||
>
|
||||
<div class="grow"></div>
|
||||
<input
|
||||
type="submit"
|
||||
href="step4.html"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-marine-blue"
|
||||
value="Next Step"
|
||||
/>
|
||||
<a
|
||||
th:remove="all"
|
||||
href="step4.html"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-marine-blue"
|
||||
>Next Step</a
|
||||
>
|
||||
</section>
|
||||
</article>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -36,13 +36,19 @@
|
|||
<![endif]-->
|
||||
|
||||
<main class="grid place-content-center h-screen">
|
||||
<article
|
||||
<form
|
||||
class="flex flex-col items-center w-screen h-screen md:grid md:items-start md:p-5 md:bg-white md:rounded-2xl md:grid-cols-[auto_1fr] md:w-desktop-form md:h-desktop-form md:drop-shadow-2xl"
|
||||
id="form-step"
|
||||
hx-post="/submit-step/4/5"
|
||||
action="/submit-step/4/5"
|
||||
hx-swap="outerHTML"
|
||||
method="post"
|
||||
th:fragment="formFragment(formData)"
|
||||
>
|
||||
<summary
|
||||
class="w-full h-44 bg-no-repeat md:row-span-2 bg-sidebar-mobile marker:text-white md:bg-sidebar-desktop md:h-[568px] md:w-[274px]"
|
||||
id="sidebar"
|
||||
th:replace="~{step1::stepsSummary (${formData})}"
|
||||
>
|
||||
<ol
|
||||
class="grid grid-cols-[repeat(4,_auto)] gap-x-5 content-center items-center place-content-center h-24 md:flex-col md:h-full md:grid-rows-[repeat(4,_auto)] md:grid-cols-1 md:content-start md:p-10 md:gap-y-7 text-white text-sm uppercase"
|
||||
|
@ -98,28 +104,69 @@
|
|||
class="flex flex-col py-8 px-6 -mt-20 w-11/12 bg-white rounded-xl md:px-24 md:mt-0 md:w-full drop-shadow-xl md:drop-shadow-none"
|
||||
>
|
||||
<!-- Step 3 start -->
|
||||
<h1 class="text-2xl font-bold md:text-4xl text-marine-blue">Finishing up</h1>
|
||||
<p class="py-3 text-cool-gray">Double-check everything looks OK before confirming.</p>
|
||||
<h1 class="text-2xl font-bold md:text-4xl text-marine-blue">
|
||||
Finishing up
|
||||
</h1>
|
||||
<p class="pt-3 pb-5 text-cool-gray">
|
||||
Double-check everything looks OK before confirming.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-col w-full text-sm rounded-lg divide-y md:text-base bg-magnolia"
|
||||
class="flex flex-col px-3 w-full text-sm rounded-lg divide-y md:text-base bg-magnolia/75"
|
||||
id="selection-overview"
|
||||
>
|
||||
<div id="selected-plan">
|
||||
Arcade (Monthly) Change $90/y
|
||||
<div id="selected-plan" class="grid py-3 grid-cols-[1fr_auto]">
|
||||
<h2 th:text="${formData.fullPlanName}"
|
||||
class="font-bold text-marine-blue"
|
||||
>Arcade (Monthly)</h2>
|
||||
<p
|
||||
th:text="|$${formData.selectedPlanCost}/${formData.periodCostLabel}|"
|
||||
class="row-span-2 self-end font-bold text-marine-blue"
|
||||
>
|
||||
$90/y
|
||||
</p>
|
||||
<a
|
||||
hx-post="/submit-step/4/2"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#form-step"
|
||||
href="step3.html"
|
||||
hx-params="not form-confirmed"
|
||||
class="underline text-cool-gray"
|
||||
>Change</a
|
||||
>
|
||||
</div>
|
||||
<div id="selected-addons"
|
||||
class="flex flex-col"
|
||||
<div id="selected-addons" class="flex flex-col gap-y-4 py-3 mp-10"
|
||||
th:if="not ${formData.userAnswers.step3.addonsAsJava.isEmpty}"
|
||||
>
|
||||
<div>
|
||||
Online service +$10/yr
|
||||
</div>
|
||||
<div>
|
||||
Larger storage +$20/yr
|
||||
<div
|
||||
th:each="addon: ${formData.userAnswers.step3.addonsAsJava}"
|
||||
class="flex flex-row"
|
||||
>
|
||||
<p th:text="${addon.name}" class="grow text-cool-gray">Online service</p>
|
||||
<p
|
||||
th:text="|+$${formData.addonCost(addon)}/${formData.periodCostLabel}|"
|
||||
>
|
||||
+$10/yr
|
||||
</p>
|
||||
</div>
|
||||
<div th:remove="all">Larger storage +$20/yr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>Total (per year) $120</p>
|
||||
<section class="flex flex-row p-3 mt-5">
|
||||
<p class="grow text-cool-gray">
|
||||
Total (per
|
||||
<span
|
||||
th:text="${formData.userAnswers.step2.isYearly} ? 'year' : 'month'"
|
||||
>year</span
|
||||
>)
|
||||
</p>
|
||||
<p
|
||||
th:text="|+$${formData.fullOrderPrice}/${formData.periodCostLabel}|"
|
||||
class="font-bold text-purplish-blue"
|
||||
>
|
||||
$120
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Step 3 end -->
|
||||
</section>
|
||||
|
@ -129,18 +176,29 @@
|
|||
class="flex flex-row items-center py-4 w-full bg-white md:items-end md:h-full"
|
||||
>
|
||||
<a
|
||||
hx-post="/submit-step/4/3"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#form-step"
|
||||
href="step3.html"
|
||||
class="ml-6 text-sm font-semibold md:pb-3 md:ml-20 md:text-base text-cool-gray"
|
||||
hx-params="not form-confirmed"
|
||||
>Go Back</a
|
||||
>
|
||||
<div class="grow"></div>
|
||||
<input type="hidden" name="form-confirmed" value="true" />
|
||||
<input
|
||||
type="submit"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-purplish-blue"
|
||||
value="Confirm"
|
||||
/>
|
||||
<a
|
||||
th:remove="all"
|
||||
href="step5.html"
|
||||
class="grid place-content-center mr-3 w-24 h-10 text-sm font-semibold text-white rounded md:mr-24 md:w-32 md:h-12 md:text-base md:rounded-lg bg-purplish-blue"
|
||||
>Confirm</a
|
||||
>
|
||||
</section>
|
||||
</article>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -35,16 +35,85 @@
|
|||
</p>
|
||||
<![endif]-->
|
||||
|
||||
<main class="bg-green-200">
|
||||
<!-- Step 5 start -->
|
||||
<main class="grid place-content-center h-screen">
|
||||
<article
|
||||
class="flex flex-col items-center w-screen h-screen md:grid md:items-start md:p-5 md:bg-white md:rounded-2xl md:grid-cols-[auto_1fr] md:w-desktop-form md:h-desktop-form md:drop-shadow-2xl"
|
||||
id="form-step"
|
||||
th:fragment="formFragment(formData)"
|
||||
>
|
||||
<summary
|
||||
class="w-full h-44 bg-no-repeat md:row-span-2 bg-sidebar-mobile marker:text-white md:bg-sidebar-desktop md:h-[568px] md:w-[274px]"
|
||||
id="sidebar"
|
||||
th:replace="~{step1::stepsSummary (${formData})}"
|
||||
>
|
||||
<ol
|
||||
class="grid grid-cols-[repeat(4,_auto)] gap-x-5 content-center items-center place-content-center h-24 md:flex-col md:h-full md:grid-rows-[repeat(4,_auto)] md:grid-cols-1 md:content-start md:p-10 md:gap-y-7 text-white text-sm uppercase"
|
||||
>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 1</span
|
||||
><span class="font-bold">Your info</span>
|
||||
</p>
|
||||
</li>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 2</span
|
||||
><span class="font-bold">Select plan</span>
|
||||
</p>
|
||||
</li>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 3</span
|
||||
><span class="font-bold">Add-ons</span>
|
||||
</p>
|
||||
</li>
|
||||
<li class="items-center md:grid md:gap-x-4 md:grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="grid place-content-center w-8 h-8 text-white rounded-full border border-white"
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<p class="hidden md:flex md:flex-col">
|
||||
<span class="text-light-gray">Step 4</span
|
||||
><span class="font-bold">summary</span>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</summary>
|
||||
<section
|
||||
id="multipage-form-container"
|
||||
class="flex flex-col gap-y-4 items-center py-8 py-20 px-6 -mt-20 w-11/12 bg-white rounded-xl md:px-24 md:mt-0 md:w-full drop-shadow-xl md:drop-shadow-none"
|
||||
>
|
||||
<!-- Step 5 start -->
|
||||
|
||||
Thank you!
|
||||
|
||||
Thanks for confirming your subscription! We hope you have fun
|
||||
using our platform. If you ever need support, please feel free
|
||||
to email us at support@loremgaming.com.
|
||||
<img src="../public/images/icon-thank-you.svg" alt="" class="mb-2 w-14 h-14" />
|
||||
<h2 class="text-2xl font-bold">Thank you!</h2>
|
||||
<p class="text-center text-cool-gray">
|
||||
Thanks for confirming your subscription! We hope you have fun
|
||||
using our platform. If you ever need support, please feel free
|
||||
to email us at support@loremgaming.com.
|
||||
</p>
|
||||
|
||||
<!-- Step 5 end -->
|
||||
|
||||
</section>
|
||||
<div class="md:hidden grow"></div>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,10 +1,221 @@
|
|||
package multistepform
|
||||
|
||||
import java.util.UUID
|
||||
import scala.jdk.CollectionConverters._
|
||||
|
||||
object Models {
|
||||
|
||||
/** Labels and form info which dynamically depend on user answers e.g plan
|
||||
* name 'Arcade (Yearly)' vs 'Pro (Monthly)'
|
||||
*/
|
||||
final case class FormData(
|
||||
userAnswers: Answers
|
||||
) {
|
||||
val stepsAmount = Steps.values.toList.asJava
|
||||
|
||||
// yeah, in real world it will not be this simple
|
||||
def yearlyCost(monthlyCost: Int): Int = 10 * monthlyCost
|
||||
|
||||
def periodCostLabel: String = {
|
||||
if (userAnswers.step2.isYearly) "yr" else "mo"
|
||||
}
|
||||
|
||||
def selectedPlanCost: Int = planCost(userAnswers.step2.planType)
|
||||
|
||||
def planCost(plan: PlanType): Int = {
|
||||
val monthlyPlanCost = plan.monthlyCost
|
||||
if (userAnswers.step2.isYearly) yearlyCost(monthlyPlanCost)
|
||||
else monthlyPlanCost
|
||||
}
|
||||
|
||||
def addonCost(addon: Addons): Int = {
|
||||
val monthCost = addon.monthlyCost
|
||||
if (userAnswers.step2.isYearly) yearlyCost(monthCost) else monthCost
|
||||
}
|
||||
|
||||
def fullPlanName: String = {
|
||||
val period = if (userAnswers.step2.isYearly) "Yearly" else "Monthly"
|
||||
s"${userAnswers.step2.planType} (${period})"
|
||||
}
|
||||
|
||||
def fullOrderPrice: Int = {
|
||||
selectedPlanCost + userAnswers.step3.addons.toList.map(addonCost).sum
|
||||
}
|
||||
|
||||
def availablePlans = PlanType.values.toList.asJava
|
||||
|
||||
def availableAddons = Addons.values.toList.asJava
|
||||
}
|
||||
|
||||
final case class Answers(
|
||||
sessionId: String = "id1",
|
||||
currentStep: Int = 1,
|
||||
)
|
||||
sessionId: String = "",
|
||||
currentStep: Int = 4,
|
||||
step1: StepAnswers.Step1 = StepAnswers.Step1(),
|
||||
step2: StepAnswers.Step2 = StepAnswers.Step2(),
|
||||
step3: StepAnswers.Step3 = StepAnswers.Step3(
|
||||
addons = Addons.values.toSet
|
||||
),
|
||||
step4: StepAnswers.Step4 = StepAnswers.Step4()
|
||||
) {
|
||||
// this is not enforced by compiler, sad, maintain by hand in html template files
|
||||
def fragmentName: String = s"step${currentStep}"
|
||||
|
||||
def updateStep(stepNum: Int, rawData: String, nextStep: Int): Answers = {
|
||||
stepNum match {
|
||||
case 1 =>
|
||||
this.copy(
|
||||
step1 = this.step1.fromFormData(rawData),
|
||||
currentStep = nextStep
|
||||
)
|
||||
case 2 =>
|
||||
this.copy(
|
||||
step2 = this.step2.fromFormData(rawData),
|
||||
currentStep = nextStep
|
||||
)
|
||||
case 3 =>
|
||||
this.copy(
|
||||
step3 = this.step3.fromFormData(rawData),
|
||||
currentStep = nextStep
|
||||
)
|
||||
case 4 =>
|
||||
this.copy(
|
||||
step4 = this.step4.fromFormData(rawData),
|
||||
currentStep = nextStep
|
||||
)
|
||||
case _ => this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO would be nice to connect answers to the steps enum in some helpful
|
||||
* way.
|
||||
*/
|
||||
enum Steps(val index: Int, val name: String):
|
||||
case Step1 extends Steps(1, "Your info")
|
||||
case Step2 extends Steps(2, "Select plan")
|
||||
case Step3 extends Steps(3, "Add-ons")
|
||||
case Step4 extends Steps(4, "Summary")
|
||||
|
||||
enum PlanType(val monthlyCost: Int, val iconPath: String):
|
||||
case Arcade extends PlanType(9, "public/images/icon-arcade.svg")
|
||||
case Advanced extends PlanType(12, "public/images/icon-advanced.svg")
|
||||
case Pro extends PlanType(15, "public/images/icon-pro.svg")
|
||||
def name(): String = {
|
||||
this.toString().replaceAll("([a-z])([A-Z])", "$1 $2")
|
||||
}
|
||||
|
||||
enum Addons(val monthlyCost: Int, val description: String):
|
||||
case OnlineService extends Addons(1, "Access to multiplayer games")
|
||||
case LargerStorage extends Addons(2, "Extra 1TB of cloud storage")
|
||||
case CustomProfile extends Addons(2, "Custom theme on your profile")
|
||||
|
||||
/** Change camel case into human readable. Adding single space before each
|
||||
* uppercase
|
||||
*/
|
||||
def name(): String = {
|
||||
this.toString().replaceAll("([a-z])([A-Z])", "$1 $2")
|
||||
}
|
||||
|
||||
sealed trait StepAnswers {
|
||||
def fromFormData(rawData: String): StepAnswers
|
||||
def submitted: Boolean
|
||||
}
|
||||
object StepAnswers {
|
||||
final case class Step1(
|
||||
name: String = "",
|
||||
email: String = "",
|
||||
phone: String = "",
|
||||
override val submitted: Boolean = false
|
||||
) extends StepAnswers {
|
||||
override def fromFormData(rawData: String): Step1 = {
|
||||
println(s"parsing step 1 data $rawData")
|
||||
val fieldValues = rawData
|
||||
.split("&")
|
||||
.filterNot(_.isEmpty())
|
||||
.map { field =>
|
||||
val Array(name, value) = field.split("=")
|
||||
name -> value
|
||||
}
|
||||
.toMap
|
||||
|
||||
val name = fieldValues.getOrElse("name", "")
|
||||
val email = fieldValues.getOrElse("email", "")
|
||||
val phone = fieldValues.getOrElse("phone", "")
|
||||
|
||||
Step1(name, email, phone, submitted = true)
|
||||
}
|
||||
}
|
||||
final case class Step2(
|
||||
planType: PlanType = PlanType.Arcade,
|
||||
isYearly: Boolean = false,
|
||||
override val submitted: Boolean = false
|
||||
) extends StepAnswers {
|
||||
override def fromFormData(rawData: String): Step2 = {
|
||||
println(s"parsing step 2 data $rawData")
|
||||
val fieldValues = rawData
|
||||
.split("&")
|
||||
.filterNot(_.isEmpty())
|
||||
.map { field =>
|
||||
val Array(name, value) = field.split("=")
|
||||
name -> value
|
||||
}
|
||||
.toMap
|
||||
|
||||
val planType =
|
||||
PlanType.valueOf(fieldValues.getOrElse("plan-type", "Arcade"))
|
||||
val isYearly = fieldValues.get("isPackageYearly").contains("on")
|
||||
|
||||
Step2(planType, isYearly, submitted = true)
|
||||
}
|
||||
}
|
||||
final case class Step3(
|
||||
addons: Set[Addons] = Set.empty,
|
||||
override val submitted: Boolean = false
|
||||
) extends StepAnswers {
|
||||
def addonsAsJava = addons.asJava
|
||||
|
||||
def containsAddon(addonName: String): Boolean = {
|
||||
addons.contains(Addons.valueOf(addonName))
|
||||
}
|
||||
|
||||
override def fromFormData(rawData: String): Step3 = {
|
||||
println(s"parsing step 3 data $rawData")
|
||||
// for multiple checkboxes data comes in form of
|
||||
// addon-services=OnlineService&addon-services=CustomProfile
|
||||
val fieldValues = rawData
|
||||
.split("&")
|
||||
.filterNot(_.isEmpty())
|
||||
.map { field =>
|
||||
val Array(name, value) = field.split("=")
|
||||
name -> value
|
||||
}
|
||||
|
||||
val addonsStrings = fieldValues
|
||||
.groupMap(_._1)(_._2)
|
||||
.getOrElse("addon-services", Array.empty[String])
|
||||
println(s"in step 3 got strings ${addonsStrings.mkString(", ")}")
|
||||
val addons = addonsStrings.map(Addons.valueOf(_)).toSet
|
||||
|
||||
Step3(addons, submitted = true)
|
||||
}
|
||||
}
|
||||
final case class Step4(
|
||||
override val submitted: Boolean = false
|
||||
) extends StepAnswers {
|
||||
override def fromFormData(rawData: String): Step4 = {
|
||||
val fieldValues = rawData
|
||||
.split("&")
|
||||
.filterNot(_.isEmpty())
|
||||
.map { field =>
|
||||
println(s"working with field $field")
|
||||
val Array(name, value) = field.split("=")
|
||||
name -> value
|
||||
}
|
||||
.toMap
|
||||
|
||||
val isConfirmed = fieldValues.contains("form-confirmed")
|
||||
Step4(isConfirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,12 @@ package multistepform
|
|||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
||||
import org.thymeleaf.TemplateEngine
|
||||
import org.thymeleaf.context.Context
|
||||
import cask.endpoints.ParamReader
|
||||
import java.util.UUID
|
||||
import scala.jdk.CollectionConverters._
|
||||
import multistepform.Models.Answers
|
||||
import scala.annotation.internal.requiresCapability
|
||||
import java.net.URLDecoder
|
||||
|
||||
case class Routes()(implicit cc: castor.Context, log: cask.Logger)
|
||||
extends cask.Routes {
|
||||
|
@ -14,16 +20,144 @@ case class Routes()(implicit cc: castor.Context, log: cask.Logger)
|
|||
val templateEngine = new TemplateEngine()
|
||||
templateEngine.setTemplateResolver(templateResolver)
|
||||
|
||||
val sessoinCookieName = "sessionId"
|
||||
|
||||
/** This route works with and without 'sessionId' cookie present set's this
|
||||
* cookie if not present, and returns initial 'index.html' where the form is
|
||||
* not yet initialized, and will be requested for the session
|
||||
*/
|
||||
@cask.get("/")
|
||||
def getIndex() = {
|
||||
def getIndex(ctx: cask.Request) = {
|
||||
val sessionCookie = ctx.cookies.get(sessoinCookieName)
|
||||
lazy val newSessionCookies = sessionCookie match {
|
||||
case None =>
|
||||
Seq(
|
||||
cask.Cookie(
|
||||
sessoinCookieName,
|
||||
UUID.randomUUID().toString(),
|
||||
path = "/"
|
||||
)
|
||||
)
|
||||
case Some(_) => Seq.empty // don't set new cookies
|
||||
}
|
||||
|
||||
println(s"getting cookie $sessionCookie will set new? ${newSessionCookies}")
|
||||
|
||||
val context = new Context()
|
||||
val indexPage = templateEngine.process("index", context)
|
||||
cask.Response(
|
||||
indexPage,
|
||||
headers = Seq("Content-Type" -> "text/html;charset=UTF-8"),
|
||||
cookies = newSessionCookies
|
||||
)
|
||||
}
|
||||
|
||||
@cask.get("/force-new-session")
|
||||
def forceNewSession() = {
|
||||
val newSessionCookie =
|
||||
cask.Cookie(sessoinCookieName, UUID.randomUUID().toString(), path = "/")
|
||||
println(s"setting new session ${newSessionCookie.value}")
|
||||
cask.Response(
|
||||
s"New session forced. Force new session",
|
||||
headers = Seq("Content-Type" -> "text/html;charset=UTF-8"),
|
||||
cookies = Seq(newSessionCookie)
|
||||
)
|
||||
}
|
||||
|
||||
val formDataContextVarName = "formData"
|
||||
|
||||
/** This method only works when cookie 'sessionId' is present will get or init
|
||||
* Form State for the session, and return last unsubmitted form step fragment
|
||||
*/
|
||||
@cask.get("/get-form")
|
||||
def getForm(sessionId: cask.Cookie) = {
|
||||
val id = sessionId.value
|
||||
val state = Sessions.sessionReplies.getOrElse(id, Answers(id))
|
||||
println(s"starting form for $state")
|
||||
val context = new Context()
|
||||
val formData = Models.FormData(state)
|
||||
context.setVariable(formDataContextVarName, formData)
|
||||
val formFragment = templateEngine.process(
|
||||
state.fragmentName,
|
||||
Set("formFragment").asJava,
|
||||
context
|
||||
)
|
||||
cask.Response(
|
||||
formFragment,
|
||||
headers = Seq("Content-Type" -> "text/html;charset=UTF-8")
|
||||
)
|
||||
}
|
||||
|
||||
@cask.post("/submit-step/:stepNum/:nextStep")
|
||||
def submitStep(
|
||||
sessionId: cask.Cookie,
|
||||
stepNum: Int,
|
||||
nextStep: Int,
|
||||
request: cask.Request
|
||||
) = {
|
||||
val id = sessionId.value
|
||||
println(s"got $request for $id. it's data is ${request.text()}")
|
||||
|
||||
// note: this is nice at step #1 because not storing anything before first submission
|
||||
// but on followup steps, if data lost - new default object is created
|
||||
// and that is not nice
|
||||
val userAnswers = Sessions.sessionReplies.getOrElse(id, Answers(id))
|
||||
|
||||
val submittedData = URLDecoder.decode(request.text() , "UTF-8")
|
||||
|
||||
val updatedAnswers = userAnswers.updateStep(stepNum, submittedData, nextStep)
|
||||
|
||||
Sessions.sessionReplies.update(id, updatedAnswers)
|
||||
|
||||
val context = new Context()
|
||||
val formData = Models.FormData(updatedAnswers)
|
||||
context.setVariable(formDataContextVarName, formData)
|
||||
val nextFormFragment = templateEngine.process(
|
||||
updatedAnswers.fragmentName,
|
||||
Set("formFragment").asJava,
|
||||
context
|
||||
)
|
||||
println(s"the state now is $updatedAnswers")
|
||||
cask.Response(
|
||||
nextFormFragment,
|
||||
headers = Seq("Content-Type" -> "text/html;charset=UTF-8")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This endpoint re-renders 'plan type inputs'
|
||||
* so that togglng monthly\yearly could redraw their html
|
||||
*/
|
||||
@cask.get("/step2/planTypeInputs")
|
||||
def getPlanTypeInputs(sessionId: cask.Cookie, isPackageYearly: Option[String] = None) = {
|
||||
val id = sessionId.value
|
||||
val isPackageYearlyBool = isPackageYearly.contains("on")
|
||||
println(s"requesting plan type inputs for $id and $isPackageYearlyBool")
|
||||
Sessions.sessionReplies.get(id) match {
|
||||
case None =>
|
||||
cask.Response("Can't find answers for your session, please reload the page", 404)
|
||||
case Some(answers) => {
|
||||
// here changing yearly/monthly part of state based on passed checkbox value
|
||||
// only for purposes of rendering the fragment
|
||||
// not persisting, unless next or previous buttons are pressed
|
||||
val withYearlyParamSet = answers.copy(step2 = answers.step2.copy(isYearly = isPackageYearlyBool))
|
||||
val formData = Models.FormData(withYearlyParamSet)
|
||||
val context = new Context()
|
||||
context.setVariable(formDataContextVarName, formData)
|
||||
val planTypesInputsHtml = templateEngine.process(
|
||||
"step2",
|
||||
Set("planTypesInputs").asJava,
|
||||
context
|
||||
)
|
||||
println(s"updating plan type inputs for $answers")
|
||||
cask.Response(
|
||||
planTypesInputsHtml,
|
||||
headers = Seq("Content-Type" -> "text/html;charset=UTF-8")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@cask.staticResources("/public")
|
||||
def publicFiles() = "public"
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package multistepform
|
||||
|
||||
import multistepform.Models.Answers
|
||||
|
||||
object Sessions {
|
||||
// the simplest form of storing data
|
||||
// i'll be relying on Render.com killing my app after 15 minutes of inactivity for GC
|
||||
// no need to manage concurrency really, because requests for same session are really far apart
|
||||
// and load will average to be 10req/day :shrug:
|
||||
val sessionReplies = scala.collection.mutable.Map.empty[String, Answers]
|
||||
}
|
Loading…
Reference in New Issue