Compare commits

...

20 Commits

Author SHA1 Message Date
efim 0e7d53b582 feat(15): stylings of steps 1-3
and mobile styling of step 4
2023-07-15 20:19:54 +00:00
efim 96edd50450 feat(15): adding only required validation 2023-07-15 17:31:25 +00:00
efim dd590e1530 feat(15): jump from step4 to step2
here hidden 'form-confirmed' input will result in 'submitted' attribute
of Step4 answer to be set,
but this field is explicitly excluded on the hx-post from the Back
and Change links, this seems OK, i guess!
2023-07-15 17:15:21 +00:00
efim 3133a9aa8c feat(15): dynamicly redrawing plan type info yr/mo 2023-07-15 15:46:32 +00:00
efim a52ab42c61 feat(15): steps summary as dynamic fragment
defined in step1, reused in other steps
2023-07-15 14:35:52 +00:00
efim 77e70c3536 fix(15): set skipped identical prices
so 150 + 10 + 20 + 20 was only 180
2023-07-15 13:49:51 +00:00
efim 4d9a7c3e12 feat(15): dynamic values in step 3 addon selection 2023-07-15 13:48:48 +00:00
efim 6d827365ac feat(15): dynamic rendering of plan types
currently only on page revisit.
for dynamic - need to use htmx swapping, with separate endpoing getting
updates value from whole element being a fragment
2023-07-15 13:21:26 +00:00
efim f9c32fd7dc feat(15): putting state values into summary step 2023-07-15 12:55:22 +00:00
efim 998cc778e6 feat(15): adding dynamic form data source 2023-07-15 12:05:40 +00:00
efim 076dc76ca4 feat(15): submitting form data on step back 2023-07-15 12:05:04 +00:00
efim 5f260455cb feat(15): displaying existing answers in form step 2023-07-15 04:36:28 +00:00
efim bf33858e41 feat(15): route to get previous form step 2023-07-15 03:50:51 +00:00
efim 6ed489835f feat(15): start form, save state, decode steps 2023-07-14 20:49:00 +00:00
efim febc77032b feat(15): steps submit form and set next 2023-07-14 20:17:15 +00:00
efim 65bfe3077f feat(15): receiving form submission from step 1 2023-07-14 18:29:25 +00:00
efim f8f1580fc7 feat(15): making fragments, loading based on step 2023-07-14 05:03:18 +00:00
efim aca7ae8ecf feat(15): index placeholder for the form
to be htmx'ed into getting appropriate step for the session
2023-07-13 19:31:53 +00:00
efim 03aa9f8b94 feat(15): associating session with root page 2023-07-13 18:31:30 +00:00
efim 2d514e9258 feat(15): initial styling step 5 2023-07-13 06:15:53 +00:00
13 changed files with 1248 additions and 176 deletions

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -1 +0,0 @@
hello from the resource file!

View 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);

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
}
}
}

View File

@ -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"

View File

@ -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]
}