Compare commits

..

2 commits

Author SHA1 Message Date
220d4b1f30 add: new design
New design
2025-05-10 13:01:21 +02:00
ae0f6cb620 add: data from MongoDB
Retrieve Data from MongoDB
2025-05-10 12:56:57 +02:00
54 changed files with 5731 additions and 3495 deletions

19
app/app.vue Normal file → Executable file
View file

@ -1,18 +1,5 @@
<template>
<PpNavigation />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<PpHeader />
<NuxtPage />
<PpFooter />
</template>
<style>
.page-enter-active,
.page-leave-active {
transition: all 200ms;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(.5rem);
}
</style>

View file

@ -1,6 +1,6 @@
.Button {
--padding: var(--padding-xs);
--background: var(--color-main-dark);
--padding: .2rem;
--background: var(--color-gradient-main-dark);
--color: var(--color-lightest);
--background-hover: var(--color-main-dark);
@ -8,7 +8,7 @@
display: flex;
justify-content: center;
align-items: center;
gap: var(--padding-default);
gap: 1rem;
cursor: pointer;
transition: var(--transition-default);
outline: none;
@ -19,17 +19,17 @@
&.transparent {
--background: transparent;
box-shadow: none;
padding: var(--padding-s) var(--padding-l);
padding: .5em 1.5em;
border-radius: var(--radius-default);
}
&.raised {
box-shadow: var(--box-shadow-z2);
padding: var(--padding-s) var(--padding-l);
padding: .5em 1.5em;
border-radius: var(--radius-default);
&.danger {
--background: var(--color-error);
--background: var(--color-gradient-error);
--color: var(--color-lightest);
}
}
@ -37,7 +37,7 @@
&.text {
--background: transparent;
--color: var(--color-darkest);
padding: var(--padding-s) var(--padding-l);
padding: .5em 1.5em;
border-radius: var(--radius-default);
&:hover {
@ -62,13 +62,13 @@
justify-content: center;
align-items: center;
border-radius: 100%;
padding: var(--padding-s);
padding: .5rem;
}
&.cta {
background: var(--background);
color: var(--color);
padding: var(--padding-s) var(--padding-l);
padding: .5rem 1.5rem;
border-radius: var(--radius-default);
box-shadow: var(--box-shadow-z2);
@ -93,30 +93,18 @@
}
}
&.search-button {
--background: var(--color-lightest);
--color: var(--color-main-darkest);
border-radius: 100%;
padding: var(--padding);
font-size: var(--font-size-l);
&:hover {
scale: 1.2;
}
}
&.mini-button {
padding: var(--padding-s) var(--padding-l);
padding: .5rem 1.5rem;
display: flex;
flex-direction: column;
gap: var(--padding-xs);
gap: .5rem;
& > .icon {
font-size: var(--font-size-xl);
font-size: 1.5rem;
}
& > span {
font-size: var(--font-size-s);
font-size: .8rem;
}
}
}

View file

@ -1,6 +1,9 @@
.ButtonGroup {
display: flex;
background: var(--color-main);
border-radius: var(--radius-default);
overflow: hidden;
box-shadow: var(--box-shadow-z2);
& button {
--color: var(--color-light);
@ -9,8 +12,8 @@
display: flex;
align-items: center;
justify-content: center;
gap: var(--padding-xs);
padding: var(--padding-s);
gap: .5rem;
padding: .5rem;
flex-grow: 1;
background: var(--background);
color: var(--color);
@ -21,5 +24,13 @@
--color: var(--color-lightest);
--background: var(--color-main-dark);
}
&:first-child {
border-radius: var(--radius-default) 0 0 var(--radius-default);
}
&:last-child {
border-radius: 0 var(--radius-default) var(--radius-default) 0;
}
}
}

View file

@ -1,71 +0,0 @@
dialog {
top: 50%;
left: 50%;
width: clamp(400px, 100vw, calc(var(--page-max-width) - var(--padding-xxl) * 2));
translate: -50% -50%;
border: none;
border-radius: var(--radius-default);
background: var(--color-lightest);
font-size: var(--font-size-default);
color: var(--color-darkest);
position: relative;
opacity: 0;
scale: 0;
transition:
opacity var(--transition-default) ease-out,
scale var(--transition-default) ease-out,
overlay var(--transition-default) ease-out allow-discrete,
display var(--transition-default) ease-out allow-discrete;
&[open] {
opacity: 1;
scale: 1;
&::backdrop {
background-color: rgb(0 0 0 / 25%);
}
}
&::backdrop {
z-index: 2000;
background-color: rgb(0 0 0 / 0%);
transition:
display var(--transition-default) allow-discrete,
overlay var(--transition-default) allow-discrete,
background-color var(--transition-default);
}
& > .wrapper {
display: flex;
flex-direction: column;
gap: var(--padding-default);
}
& header {
justify-content: space-between;
align-items: center;
padding: var(--padding-default) var(--padding-default) 0;
}
main {
padding: 0 var(--padding-default);
}
& footer {
justify-content: space-between;
padding: 0 var(--padding-default) var(--padding-default);
}
}
@starting-style {
dialog[open] {
opacity: 0;
scale: 0;
&::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}
}

View file

@ -1,49 +1,38 @@
.Footer {
position: relative;
background: var(--color-darkest);
padding: var(--padding-default);
padding: 1rem;
z-index: 100;
& h4 {
color: var(--color-lightest);
text-align: center;
margin-bottom: var(--padding-default);
margin-bottom: 1rem;
}
& .bottom {
display: flex;
justify-content: center;
gap: 1rem;
justify-content: space-between;
color: var(--color-light);
}
& .copy {
font-size: var(--font-size-xs);
color: var(--color-light);
margin-top: var(--padding-default);
text-align: center;
& a {
color: var(--color-main);
text-decoration: none;
}
}
& .socials {
font-size: var(--font-size-xl);
font-size: 1.5rem;
justify-content: center;
margin-bottom: var(--padding-xl);
margin-bottom: 2rem;
}
& .data-links {
justify-content: flex-end;
font-size: var(--font-size-s);
font-size: .8rem;
}
& ul {
list-style: none;
display: flex;
align-items: center;
gap: var(--padding-default);
gap: 1rem;
& a {
color: var(--color-lightest);

View file

@ -1,15 +0,0 @@
.Search {
border-radius: 9999px;
background: var(--color-lightest);
padding: 0 var(--padding-default);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--padding-default);
& > input {
all: unset;
flex-grow: 1;
padding: var(--padding-s) 0;
}
}

View file

@ -1,72 +0,0 @@
.TextField {
--border-color: var(--color-light);
--label-color: var(--color-middle);
--label-position-top: var(--font-size-default);
--label-position-left: 2.5rem;
--label-font-size: var(--font-size-default);
--message-color: var(--color-middle);
position: relative;
flex: 25% 1 0;
&:focus-within {
--border-color: var(--color-main-dark);
}
&:focus-within,
&:has(input:not(:placeholder-shown)) {
--label-color: var(--color-main-dark);
}
&.error {
--label-color: var(--color-error) !important;
--border-color: var(--color-error);
--message-color: var(--color-error);
}
& .wrapper {
display: flex;
align-items: center;
padding: var(--padding-xxs) var(--padding-xs);
border: 1px solid var(--border-color);
border-radius: var(--radius-default);
}
& label {
position: absolute;
display: flex;
align-items: center;
color: var(--label-color);
gap: 2px;
top: -6px;
left: calc(var(--padding-xs) - 2px);
font-size: var(--font-size-s);
transition: var(--transition-default);
& > * {
background: var(--color-lightest);
padding: 0 2px;
}
}
& .icon {
position: relative;
color: var(--label-color);
font-size: var(--font-size-default);
top: -1px;
}
& input {
all: unset;
padding: var(--padding-xxs) var(--padding-s) var(--padding-xxs) 0;
font-size: var(--font-size-s);
width: 100%;
flex: 25% 1 0;
color: var(--color-darkest);
}
& > span {
color: var(--message-color);
font-size: var(--font-size-xs);
}
}

52
app/assets/styles/formInput.css Executable file
View file

@ -0,0 +1,52 @@
.Input {
&.error {
& .input-wrapper {
border-color: var(--color-error);
outline-width: 2px;
}
& span {
color: var(--color-error);
}
}
& span {
font-size: .65em;
}
& .input-wrapper {
position: relative;
flex: 25% 1 0;
border: 2px solid var(--color-main-dark);
border-radius: var(--radius-default);
overflow: hidden;
transition: var(--transition-default);
outline: 0 solid var(--color-lightest);
& label {
position: absolute;
font-size: .8em;
top: .3rem;
left: .5rem;
transition: var(--transition-default);
}
& input {
all: unset;
width: calc(100% - 1rem);
padding: 1.3rem .5rem .5rem .5rem;
background: var(--color-lightest);
&[type="number"] {
text-align: right;
}
}
& input:focus,
& input:not(:placeholder-shown) {
& + label {
color: var(--color-main-dark);
}
}
}
}

View file

@ -1,191 +1,195 @@
:root {
--color-success: #328104;
--color-error: #a20606;
--color-blue-light: #d7e1f1;
--color-blue: #05b0ff;
--color-blue-dark: #0266f2;
--color-blue-darkest: #013174;
--padding-default: 1rem;
--padding-small: .5rem;
--radius-default: 3px;
--transition-default: 150ms;
--color-darkest: #292929;
--color-dark: #404040;
--color-middle: #707070;
--color-light: #e0e0e6;
--color-lightest: #fafaff;
--color-success: #328104;
--color-error: #A20606;
--color-blue-light: #0DDCE7;
--color-blue: #05B0FF;
--color-blue-dark: #0266F2;
--color-blue-darkest: #013174;
--color-green-light: #05ffc5;
--color-green: #02f276;
--color-green-dark: #09dc33;
--color-green-darkest: #07b029;
--color-green-darkest-most: #157c2a;
--color-darkest: #292929;
--color-dark: #404040;
--color-middle: #707070;
--color-light: #E0E0E6;
--color-lightest: #FAFAFF;
--color-main: var(--color-blue);
--color-main-lightest: var(--color-blue-light);
--color-main-light: var(--color-blue-light);
--color-main-dark: var(--color-blue-dark);
--color-main-darkest: var(--color-blue-darkest);
--color-green-light: #05FFC5;
--color-green: #02F276;
--color-green-dark: #09DC33;
--color-green-darkest: #07B029;
--color-green-darkest-most: #157C2A;
--color-accent: var(--color-green);
--color-accent-light: var(--color-green-light);
--color-accent-dark: var(--color-green-dark);
--color-accent-darkest: var(--color-green-darkest);
--color-text: var(--color-darkest);
--color-text-invert: var(--color-lightest);
--color-main: var(--color-blue);
--color-main-light: var(--color-blue-light);
--color-main-dark: var(--color-blue-dark);
--color-main-darkest: var(--color-blue-darkest);
--color-gradient-main: linear-gradient(to bottom right, var(--color-main), var(--color-main-light));
--color-gradient-main-dark: linear-gradient(to bottom right, var(--color-main-darkest), var(--color-main-dark));
--color-gradient-accent: linear-gradient(to bottom right, var(--color-accent), var(--color-accent-light));
--color-gradient-accent-dark: linear-gradient(to bottom right, var(--color-accent-darkest), var(--color-accent-dark));
--color-gradient-error: linear-gradient(to bottom right, #b00707, #dc0909);
--color-gradient-error-reverse: linear-gradient(to top left, #b00707, #dc0909);
--color-accent: var(--color-green);
--color-accent-light: var(--color-green-light);
--color-accent-dark: var(--color-green-dark);
--color-accent-darkest: var(--color-green-darkest);
--box-shadow-upper: 0 -3px 6px rgba(0, 0, 0, 0.16), 0 -3px 6px rgba(0, 0, 0, 0.23);
--box-shadow-z2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
--box-shadow-inset: inset 0 3px 6px rgba(0, 0, 0, 0.16), inset 0 3px 6px rgba(0, 0, 0, 0.23);
--color-gradient-main: linear-gradient(to bottom right, var(--color-main), var(--color-main-light));
--color-gradient-main-dark: linear-gradient(to bottom right, var(--color-main-darkest), var(--color-main-dark));
--color-gradient-accent: linear-gradient(to bottom right, var(--color-accent), var(--color-accent-light));
--color-gradient-accent-dark: linear-gradient(to bottom right, var(--color-accent-darkest), var(--color-accent-dark));
--color-gradient-error: linear-gradient(to bottom right, #B00707, #DC0909);
--color-gradient-error-reverse: linear-gradient(to top left, #B00707, #DC0909);
/* Font Sizes & Scaling Factor*/
--scaling-factor: 1.25;
--font-size-xs: calc(var(--font-size-s) / var(--scaling-factor));
--font-size-s: calc(var(--font-size-default) / var(--scaling-factor));
--font-size-default: 1rem;
--font-size-l: calc(var(--font-size-default) * var(--scaling-factor));
--font-size-xl: calc(var(--font-size-l) * var(--scaling-factor));
--font-size-xxl: calc(var(--font-size-xl) * var(--scaling-factor));
--box-shadow-upper: 0 -3px 6px rgba(0,0,0,0.16), 0 -3px 6px rgba(0,0,0,0.23);
--box-shadow-z2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
--box-shadow-inset: inset 0 3px 6px rgba(0,0,0,0.16), inset 0 3px 6px rgba(0,0,0,0.23);
/* Paddings depend on Font-Size */
--padding-xxs: calc(var(--padding-xs) / var(--scaling-factor));
--padding-xs: calc(var(--padding-s) / var(--scaling-factor));
--padding-default: var(--font-size-default);
--padding-s: calc(var(--padding-default) / var(--scaling-factor));
--padding-l: calc(var(--padding-default) * var(--scaling-factor));
--padding-xl: calc(var(--padding-l) * var(--scaling-factor));
--padding-xxl: calc(var(--padding-xl) * var(--scaling-factor));
--radius-default: calc(var(--font-size-default) / 3);
--radius-border: var(--font-size-default);
--transition-default: 150ms;
--page-max-width: 820px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
overflow-x: hidden;
font-family: sans-serif;
color: var(--color-text);
font-size: var(--font-size-default);
max-width: var(--page-max-width);
margin: 0 auto;
background-image: url("/img/desktop-background.svg");
background-position: center;
box-shadow: var(--box-shadow-z2);
height: 100%;
overflow-x: hidden;
font-family: sans-serif;
color: var(--color-darkest);
}
h1, h2, h3 {
margin: var(--padding-xl) 0 var(--padding-default);
text-wrap: balance;
hyphens: auto;
font-size: var(--font-size-xl);
.dot {
--size: 10px;
width: var(--size);
height: var(--size);
top: 5px;
right: 25%;
border-radius: 50%;
background-color: var(--color-accent);
display: inline-block;
margin-right: 0.5rem;
position: absolute;
box-shadow: var(--box-shadow-z2);
scale: 0;
transition: var(--transition-default);
&.visible {
scale: 1;
animation: pulse 1s infinite;
}
}
h2 {
margin: var(--padding-l) 0 var(--padding-default);
font-size: var(--font-size-l);
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
h3 {
margin: var(--padding-default) 0 var(--padding-default);
font-size: var(--font-size-default);
}
a:has(button) {
text-decoration: none;
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
}
.card {
overflow: hidden;
border-radius: var(--radius-default);
box-shadow: var(--box-shadow-z2);
overflow: hidden;
border-radius: var(--radius-default);
box-shadow: var(--box-shadow-z2);
}
.content {
padding: 1rem;
min-height: 100dvh;
}
.filter-bar {
margin-bottom: 1rem;
}
.pc-wrapper {
gap: 1rem;
margin-bottom: 1rem;
}
.flex-col {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
.flex-row {
display: flex;
flex-direction: row;
display: flex;
flex-direction: row;
}
.text-white {
color: var(--color-text-invert);
color: var(--color-lightest);
}
.bg-main {
background-color: var(--color-main);
background-color: var(--color-main);
}
.bg-main-hover:hover {
background-color: var(--color-main);
background-color: var(--color-main);
}
.bg-main-dark {
background-color: var(--color-main-dark);
}
.bg-main-darkest {
background-color: var(--color-main-darkest);
background-color: var(--color-main-dark);
}
.bg-main-dark-hover:hover {
background-color: var(--color-main-dark);
background-color: var(--color-main-dark);
}
.gap-default {
gap: var(--padding-default);
gap: 1rem;
}
.bg-blue {
background: var(--color-blue);
background: var(--color-blue);
}
.bg-white {
background: var(--color-lightest);
background: var(--color-lightest);
}
.padding {
gap: var(--padding-default);
padding: var(--padding-default);
gap: 1rem;
padding: var(--padding-default);
}
.padding-small {
gap: var(--padding-default);
padding: var(--padding-s);
gap: 1rem;
padding: var(--padding-small);
}
.roboto-condensed {
font-family: "Roboto Condensed", sans-serif;
}
dialog {
top: 50%;
left: 50%;
width: 100vw;
transform: translate(-50%, -50%);
border: none;
border-radius: var(--radius-default);
.roboto {
font-family: "Roboto", sans-serif;
}
font-size: 1rem;
.open-sans {
font-family: "Open Sans", sans-serif;
}
& header {
justify-content: space-between;
align-items: center;
}
.grow {
flex-grow: 1;
height: var(--padding-s);
& footer {
justify-content: space-between;
}
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
}

View file

@ -1,88 +1,68 @@
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--padding-default);
background-color: rgba(255 255 255 / .8);
backdrop-filter: blur(10px);
box-shadow: var(--box-shadow-z2);
color: var(--color-darkest);
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: var(--padding-default);
padding: var(--padding-default);
&:not(.lp) {
background: var(--color-main-darkest);
& strong {
font-size: 2em;
& span {
color: var(--color-main-dark);
}
}
&.lp {
position: absolute;
background: rgba(0,0,0,.5);
backdrop-filter: blur(10px);
mask: linear-gradient(to top, transparent, black 30%);
width: 100%;
max-width: var(--page-max-width);
& input[type="checkbox"] {
display: none;
}
& input[type="checkbox"]:checked + nav {
translate: 0;
}
& nav,
& ul {
gap: 1em;
}
& nav {
position: fixed;
padding: var(--padding-default);
translate: 100% 0;
width: 100vw;
right: 0;
top: 0;
padding-bottom: var(--padding-xxl);
height: 100dvh;
transition: 150ms ease-in-out;
background: var(--color-lightest);
font-size: 2em;
align-items: end;
z-index: 100;
}
& .logo {
height: 40px;
}
& .burger-button {
all: unset;
color: var(--color-lightest);
}
& header {
display: flex;
align-items: center;
justify-content: space-between;
& ul {
width: 100%;
font-weight: bold;
align-items: center;
& li {
list-style: none;
& a {
text-decoration: none;
color: var(--color-lightest);
}
& a {
color: var(--color-black);
text-decoration: none;
transition: var(--transition-default);
& .header-text {
display: flex;
align-items: center;
gap: var(--padding-default);
& > div {
display: flex;
flex-direction: column;
&.active {
color: var(--color-main);
font-weight: bold;
}
}
& .big {
font-size: var(--font-size-xl);
}
& .small {
font-size: var(--font-size-s);
font-weight: 100;
}
}
}
&:not(.lp) {
&:after, &:before {
content: '';
display: block;
position: absolute;
bottom: calc(-1 * var(--radius-border));
background: var(--color-blue-darkest);
width: var(--radius-border);
height: var(--radius-border);
}
&:after {
right: 0;
mask: radial-gradient(var(--radius-border) at 0 100%,#0000 98%,#000);
}
&:before {
left: 0;
mask: radial-gradient(var(--radius-border) at 100% 100%,#0000 98%,#000);
}
}
}

View file

@ -1,40 +0,0 @@
.home-hero {
background-image: linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.6)), url("/img/hero-image.webp"); /* single color gradient for dark layer over image */
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
color: var(--color-text-invert);
text-align: center;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
button {
margin: var(--padding-xxl) auto;
}
.text {
padding: var(--padding-default);
}
h1 {
font-size: var(--font-size-xxl);
}
}
.home-text {
padding: var(--padding-xxl) var(--padding-default);
text-align: center;
& h3:has(+ .padding) {
margin-bottom: 0;
}
}
.timeline {
display: flex;
flex-direction: column;
gap: var(--padding-default);
}

View file

@ -1,69 +0,0 @@
.Navigation {
position: fixed;
display: flex;
justify-content: flex-end;
align-items: center;
z-index: 5000;
width: 100vw;
height: 100dvh;
right: 0;
top: 0;
transition: 150ms ease-in-out;
pointer-events: none;
&.open {
pointer-events: all;
background: rgba(0, 0, 0, .5);
& nav {
translate: -1rem 0;
}
}
nav {
background: var(--color-lightest);
align-items: end;
box-shadow: var(--box-shadow-z2);
padding: var(--padding-default);
height: calc(100% - var(--padding-xxl));
width: 70%;
translate: 100% 0;
transition: 150ms ease-in-out;
border-radius: var(--radius-default);
}
& button {
justify-self: flex-end;
font-size: var(--font-size-xl);
}
& ul {
width: 100%;
align-items: flex-start;
font-size: var(--font-size-l);
gap: var(--padding-default);
& li {
list-style: none;
width: 100%;
& a {
text-decoration: none;
display: flex;
align-items: center;
gap: var(--padding-default);
color: var(--color-middle);
border-radius: var(--radius-default);
padding: var(--padding-xs) var(--padding-s);
transition: var(--transition-default);
background: transparent;
cursor: pointer;
&.active {
background: var(--color-main-light);
color: var(--color-main-dark);
}
}
}
}
}

View file

@ -1,84 +0,0 @@
.page-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
& .page {
flex-grow: 1;
& .nuxt-page-wrapper {
height: 100%;
justify-content: space-between;
}
}
}
.filter-bar {
background: var(--color-lightest);
display: flex;
justify-content: space-between;
padding: var(--padding-default);
& > button {
all: unset;
cursor: pointer;
color: var(--color-main-darkest);
font-weight: bolder;
font-family: 'Roboto', sans-serif;
&.active {
color: var(--color-main-darkest);
}
&:not(.active) {
opacity: .5;
}
}
}
.search-bar {
z-index: 100;
& p {
font-weight: 100;
color: var(--color-lightest);
}
}
.content {
overflow: hidden;
background: var(--color-lightest);
height: 100%;
h1:first-of-type,
h2:first-of-type,
h3:first-of-type,
p:first-of-type,
figure:first-of-type {
margin-top: 0;
}
}
.content-text {
padding: var(--padding-xl) var(--padding-default) 0;
color: var(--color-darkest);
text-align: center;
}
.info-text {
padding: 0 var(--padding-default);
text-align: center;
width: 100%;
top: 40%;
transform: translateY(-50%);
position: absolute;
}
.Legal {
padding: var(--padding-l) var(--padding-default);
color: var(--color-darkest);
ul {
padding-left: var(--padding-default);
}
}

View file

@ -1,8 +1,11 @@
.PriceCard {
position: relative;
overflow: hidden;
width: 100%;
transition: 150ms;
opacity: 1;
color: var(--color-darkest);
border-bottom: 1px dashed var(--color-light);
background: black;
.bottom {
position: absolute;
@ -15,7 +18,7 @@
& > * {
flex-grow: 1;
color: var(--color-lightest);
font-size: var(--font-size-xl);
font-size: 2rem;
display: flex;
align-items: center;
@ -25,14 +28,14 @@
}
& .bg-edit {
background: var(--color-main-dark);
padding: var(--padding-xl);
background: var(--color-gradient-main-dark);
padding: 2rem;
text-align: left;
}
& .bg-delete {
background: var(--color-error);
padding: var(--padding-xl);
background: var(--color-gradient-error-reverse);
padding: 2rem;
text-align: right;
justify-content: flex-end;
}
@ -42,8 +45,9 @@
position: relative;
background: var(--color-lightest);
z-index: 2;
gap: var(--padding-default);
padding: var(--padding-default);
gap: 1rem;
padding: 1rem;
border-radius: var(--radius-default);
&.animated {
transition: var(--transition-default);
@ -56,14 +60,14 @@
color: var(--color-darkest);
& .icon {
font-size: var(--font-size-default);
font-size: 1rem;
cursor: pointer;
}
}
& .name-price {
display: flex;
gap: var(--padding-xs);
gap: .5rem;
& > span:nth-child(1) {
font-weight: bold;
@ -75,7 +79,7 @@
& > span:nth-child(2)::before {
content: '•';
margin-right: var(--padding-xs);
margin-right: .5rem;
color: var(--color-middle);
}
}
@ -84,19 +88,19 @@
display: flex;
flex-direction: row;
width: 100%;
gap: var(--padding-default);
gap: 1rem;
justify-content: space-between;
& > .info {
flex-grow: 0;
align-items: center;
gap: var(--padding-xxs);
gap: .25rem;
font-weight: bold;
& > .price {
display: flex;
align-items: center;
gap: var(--padding-xs);
gap: .5rem;
& > .icon {
color: var(--color-main-dark);
@ -104,7 +108,7 @@
}
& > .pro {
font-size: var(--font-size-xs);
font-size: .6rem;
color: var(--color-middle);
font-weight: lighter;
}

View file

@ -1,28 +0,0 @@
.TimelineCard {
display: flex;
align-items: center;
gap: var(--padding-default);
border: 1px solid var(--color-light);
border-radius: var(--radius-default);
padding: var(--padding-xs);
& > .icon {
flex: 0 0 25%;
font-size: var(--font-size-xxl);
color: var(--color-main-dark);
}
& .text {
text-align: left;
flex-grow: 1;
}
& .state {
--color: var(--color-darkest);
display: flex;
align-items: center;
gap: var(--padding-xxs);
margin-top: var(--padding-s);
color: var(--color);
}
}

View file

@ -4,10 +4,11 @@
background: var(--color-main-darkest);
position: sticky;
bottom: 0;
z-index: 1000;
z-index: 100;
box-shadow: var(--box-shadow-upper);
& > .Button {
font-size: var(--font-size-default);
--padding: 1rem;
font-size: 1rem;
}
}

View file

@ -1,24 +1,24 @@
<template>
<dialog
ref="dialog"
closedby="none"
closedby="any"
>
<form method="dialog" class="wrapper" ref="wrapper">
<header class="flex-row">
<form method="dialog">
<header class="flex-row padding">
Wirklich löschen?
<PpButton class="round text">
<Icon name="uil:times" mode="svg" />
</PpButton>
</header>
<main>
<div class="flex-col">
<div class="padding flex-col">
<p>Bist du dir sicher, dass du diesen Eintrag löschen möchtest?</p>
</div>
</main>
<footer class="flex-row">
<PpButton class="text">
<span>Abbrechen</span>
</PpButton>
<footer class="flex-row padding">
<PpButton class="text">
<span>Abbrechen</span>
</PpButton>
<PpButton class="danger raised" @click="$emit('delete')">
<span>Löschen</span>
</PpButton>
@ -35,11 +35,5 @@ type Props = {
defineProps<Props>()
defineEmits(['delete'])
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
const wrapper = useTemplateRef<HTMLElement>('wrapper')
onMounted(() => {
onClickOutside(wrapper, () => dialog.value?.close())
})
</script>

View file

@ -9,6 +9,7 @@
</li>
</ul>
<div class="bottom">
<small>&copy; 2025 by webfussel</small>
<ul class="data-links">
<li v-for="dataLink in dataLinks">
<NuxtLink :to="dataLink.to">
@ -17,8 +18,6 @@
</li>
</ul>
</div>
<p class="copy">ProPapier ist ein Gemeinschaftsprojekt von <NuxtLink to="https://webertoire.de" external>webertoire</NuxtLink> und <NuxtLink to="https://webfussel.de" external>webfussel</NuxtLink></p>
<p class="copy">&copy; 2025 by webfussel, webertoire</p>
</footer>
</template>

View file

@ -1,25 +0,0 @@
<template>
<div class="Search">
<input
v-model="text"
:id="id"
:placeholder="label"
@blur="emit('search', text)"
/>
<PpButton class="search-button">
<Icon name="uil:search" mode="svg" />
</PpButton>
</div>
</template>
<script setup lang="ts">
type Props = {
label : string
id : string
}
defineProps<Props>()
const emit = defineEmits(['search'])
const text = defineModel()
</script>

View file

@ -1,30 +0,0 @@
<template>
<div class="TextField">
<div class="wrapper">
<input v-model="text" :type="type" :id="id" :placeholder="placeholder" @blur="emit('blur')" @input="emit('input')" :inputmode="mode" />
<label :for="id">
<Icon v-if="icon" class="icon" :name="icon" mode="svg" />
<span>{{ label }}</span>
</label>
</div>
<span v-if="message">{{ message }}</span>
</div>
</template>
<script setup lang="ts">
type Props = {
type?: 'text' | 'number'
message?: string
icon?: string
label: string
placeholder: string
id: string
mode?: 'text' | 'email' | 'search' | 'tel' | 'url' | 'none' | 'numeric' | 'decimal'
};
const { type = "text", mode = "text" } = defineProps<Props>();
const emit = defineEmits(['blur', 'input']);
const text = defineModel();
</script>

43
app/components/Pp/FormInput.vue Executable file
View file

@ -0,0 +1,43 @@
<template>
<div class="Input">
<div class="input-wrapper flex-col">
<input
v-model="text"
:type="type"
:id="id"
:step="step"
:min="min"
:max="max"
:required="required"
placeholder=" "
@blur="emit('blur')"
/>
<label :for="id">{{ label }}</label>
</div>
<span v-if="message">{{ message }}</span>
</div>
</template>
<script setup lang="ts">
type Props = {
type ?: 'text' | 'number'
max ?: number
min ?: number
step ?: number
required ?: boolean
message ?: string
label : string
id : string
}
const {
type = 'text',
required = false,
step = 0.01,
min = 1,
} = defineProps<Props>()
const emit = defineEmits(['blur'])
const text = defineModel()
</script>

View file

@ -1,28 +1,56 @@
<template>
<div class="Header" :class="[type]">
<header class="roboto-condensed">
<NuxtLink class="header-text" to="/">
<img class="logo" src="/img/propapier.svg" alt="ProPapier logo" />
<div>
<span class="big">ProPapier</span>
<span class="small">Vergleichen. Schnell. Unkompliziert.</span>
</div>
</NuxtLink>
<button class="burger-button" @click="open()">
<Icon name="uil:bars" size="2em" mode="svg" />
</button>
</header>
<div id="subheader" />
</div>
<header class="Header">
<NuxtLink to="/">
<strong><span>Pro</span>Papier</strong>
</NuxtLink>
<label for="burger_nav_toggle">
<Icon name="solar:hamburger-menu-broken" size="2em" />
</label>
<input ref="checkbox" type="checkbox" id="burger_nav_toggle"/>
<nav class="flex-col">
<label for="burger_nav_toggle">
<Icon name="solar:close-circle-broken" />
</label>
<ul class="flex-col">
<li v-for="navPoint in navPoints" :key="navPoint.label">
<NuxtLink
:to="navPoint.to"
active-class="active"
@click="closeNav()"
>
{{ navPoint.label }}
</NuxtLink>
</li>
</ul>
</nav>
</header>
</template>
<script setup lang="ts">
type Props = {
type ?: 'lp'
type NavPoint = {
label: string
to: string
}
defineProps<Props>()
const navPoints = useState<NavPoint[]>('nav', () => ([
{
label: 'Home',
to: '/',
},
{
label: 'Übersicht',
to: '/overview',
},
]))
const cb = useTemplateRef('checkbox')
const closeNav = () => { cb.value!.checked = false }
const nav = useNavigation()
const open = () => { nav.showNavigation() }
</script>
<style scoped>
header a {
text-decoration: none;
color: var(--color-darkest);
}
</style>

View file

@ -1,41 +0,0 @@
<template>
<section class="Navigation" :class="{ open }">
<nav>
<PpButton class="round text" @click="close()">
<Icon name="uil:times" mode="svg" />
</PpButton>
<ul class="flex-col">
<li v-for="page in pages">
<NuxtLink :to="page.route" @click="close()" active-class="active">
<Icon class="icon" :name="`uil:${page.icon}`" mode="svg" />
<span>{{ page.label }}</span>
</NuxtLink>
</li>
</ul>
</nav>
</section>
</template>
<script setup lang="ts">
const nav = useNavigation()
const close = () => nav.hideNavigation()
const open = computed(() => nav.isNavigationVisible.value)
const pages = [
{
label: 'Home',
icon: 'home',
route: '/'
},
{
label: 'Schnellrechner',
icon: 'calculator',
route: '/rechner'
},
// {
// label: 'Über uns',
// icon: 'users-alt',
// route: '/about-us'
// }
]
</script>

View file

@ -1,5 +1,5 @@
<template>
<article class="PriceCard roboto-condensed" v-ripple="$device.isMobile ? { color: 'rgba(0, 0, 0, 0.1)' } : { duration: 0, scale: 0 }">
<article class="PriceCard card">
<div class="bottom">
<div class="bg-edit">
<Icon class="icon" name="uil:pen" mode="svg" />
@ -13,7 +13,6 @@
class="top flex-col"
:class="{ 'animated' : !isSwiping }"
:style="{ left }"
@click="cardClick"
>
<header>
<div class="name-price">
@ -21,10 +20,10 @@
<span>{{ intl.format(+replaceComma(card.price))}}</span>
</div>
<div v-if="$device.isDesktop" class="flex-row gap-default">
<PpButton class="icon-button" @click="update">
<PpButton class="icon-button" @click="update()">
<Icon class="icon" name="uil:pen" mode="svg" />
</PpButton>
<PpButton class="icon-button" @click="deleteCard">
<PpButton class="icon-button" @click="deleteCard()">
<Icon class="icon" name="uil:trash-alt" mode="svg" />
</PpButton>
</div>
@ -34,23 +33,23 @@
<div class="info flex-col">
<div class="price">
<Icon class="icon" name="uil:toilet-paper" mode="svg" />
<span class="value">{{ intl.format(ppr) }}</span>
<span class="value">{{ intl.format(card.ppr) }}</span>
</div>
<span class="pro">Pro 1 {{ card.roles ? `(${card.roles})` : '' }}</span>
</div>
<div class="info flex-col">
<div class="price">
<Icon class="icon" name="uil:file-landscape" mode="svg" />
<span class="value">{{ intl.format(pps) }}</span>
<span class="value">{{ intl.format(card.pps) }}</span>
</div>
<span class="pro">Pro 100 {{ card.sheets ? `(${card.sheets})` : '' }}</span>
</div>
<div class="info flex-col">
<div class="price">
<Icon class="icon" name="uil:layer-group" mode="svg" />
<span class="value">{{ intl.format(ppl) }}</span>
<span class="value">{{ intl.format(card.ppl) }}</span>
</div>
<span class="pro">Pro 1000 {{ card.layers ? `(${card.layers})` : '' }}</span>
<span class="pro">Pro 100 {{ card.layers ? `(${card.layers})` : '' }}</span>
</div>
</main>
</div>
@ -58,17 +57,16 @@
</template>
<script setup lang="ts">
import type { PriceCard } from '../../../shared/PriceCard'
import type { Card } from '../../../shared/Card'
type Props = {
deletable: boolean
card: PriceCard
card: Card
}
const { card } = defineProps<Props>()
const emit = defineEmits(['remove', 'update'])
const { vibrate } = useVibrate()
const top = useTemplateRef('top')
const left = shallowRef<string>('0')
@ -77,40 +75,22 @@ const intl = Intl.NumberFormat('de-DE', {
currency: 'EUR',
})
const { lengthX, direction, isSwiping } = useSwipe(top, {
passive: true,
threshold: 20,
const { lengthX, isSwiping } = useSwipe(top, {
passive: false,
threshold: 30,
onSwipe() {
if (['down', 'up'].includes(direction.value)) return
left.value = `${-clamp(lengthX.value, 0, 100)}px`
if (lengthX.value > 50 || lengthX.value < -50) {
left.value = `${-clamp(lengthX.value, -100, 100)}px`
}
},
onSwipeEnd() {
if (['down', 'up'].includes(direction.value)) return
if (lengthX.value > 50) {
vibrate(100)
deleteCard()
}
if (lengthX.value < -50) update()
if (lengthX.value > 50) deleteCard()
left.value = '0'
},
})
const priceClean = computed<number>(() => +replaceComma(card.price))
const ppr = computed(() => priceClean.value / +card.roles)
const pps = computed(() => (ppr.value / +card.sheets) * 100)
const ppl = computed(() => (pps.value / +card.layers) * 10)
const { isDesktop } = useDevice()
const cardClick = () => {
if (isDesktop) return
emit('update')
}
const update = () => emit('update')
const deleteCard = () => emit('remove')
defineExpose({
ppr, pps, ppl, uuid : card.uuid,
})
</script>

View file

@ -1,134 +1,96 @@
<template>
<dialog ref="dialog" closedby="none">
<div class="wrapper" ref="wrapper">
<form method="dialog">
<header class="flex-row">
{{ currentCardIndex > -1 ? 'Bearbeiten' : 'Neues hinzufügen' }}
<PpButton class="round text">
<Icon name="uil:times" mode="svg" />
</PpButton>
</header>
</form>
<dialog
ref="dialog"
closedby="any"
>
<form method="dialog">
<header class="flex-row padding">
{{ cardLabel }}
<PpButton class="round text">
<Icon name="uil:times" mode="svg" />
</PpButton>
</header>
</form>
<main v-if="currentCard">
<div class="flex-col gap-default">
<div class="padding flex-col">
<div class="flex-row gap-default">
<PpFormTextField
<PpFormInput
v-model="currentCard.name"
id="card_name"
label="Name"
icon="uil:pricetag-alt"
:placeholder="randomName"
:class="{ error: !validFields.name }"
:class="{'error': !validFields.name }"
:message="!validFields.name ? 'Feld darf nicht leer sein.' : ''"
@input="validFields.name = true"
/>
<PpFormTextField
<PpFormInput
v-model="currentCard.price"
id="card_price"
label="Preis"
placeholder="2,49"
icon="uil:euro"
mode="decimal"
:class="{ error: !validFields.price }"
:class="{'error': !validFields.price }"
:message="!validFields.price ? 'Muss eine Zahl sein.' : ''"
@input="validFields.price = true"
/>
</div>
<div class="flex-row gap-default">
<PpFormTextField
<PpFormInput
v-model="currentCard.roles"
id="card_roles"
label="Rollen"
placeholder="8"
icon="uil:toilet-paper"
mode="decimal"
:class="{ error: !validFields.roles }"
:class="{'error': !validFields.roles }"
:message="!validFields.roles ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.roles = true"
/>
<PpFormTextField
<PpFormInput
v-model="currentCard.sheets"
id="card_sheets"
label="Blatt"
placeholder="150"
icon="uil:file-landscape"
mode="decimal"
:class="{ error: !validFields.sheets }"
label="Blätter"
:class="{'error': !validFields.sheets }"
:message="!validFields.sheets ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.sheets = true"
/>
<PpFormTextField
<PpFormInput
v-model="currentCard.layers"
id="card_layers"
label="Lagen"
placeholder="3"
icon="uil:layer-group"
mode="decimal"
:class="{ error: !validFields.layers }"
:class="{'error': !validFields.layers }"
:message="!validFields.layers ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.layers = true"
/>
</div>
</div>
</main>
<footer class="flex-row">
<footer class="flex-row padding">
<form method="dialog">
<PpButton class="danger text">
<span>Abbrechen</span>
</PpButton>
</form>
<PpButton class="raised" @click="validate">
<span>{{ currentCardIndex > -1 ? 'Übernehmen' : 'Hinzufügen' }}</span>
<span>{{ cardLabel }}</span>
</PpButton>
</footer>
</div>
</dialog>
</template>
<script setup lang="ts">
import type { PriceCard } from '../../../shared/PriceCard'
import type { Card } from '../../../shared/Card'
type Props = {
currentCardIndex: number
currentCard?: PriceCard
currentCardIndex : number
currentCard ?: Card
}
const { currentCardIndex, currentCard } = defineProps<Props>()
const emit = defineEmits(['update'])
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
const wrapper = useTemplateRef<HTMLElement>('wrapper')
const market = [
'Lotl',
'Olda',
'Bäwä',
'Brutto',
]
const product = [
'Weichelig',
'Sau Rauh',
'Bissl Sanft',
'Ganz ok',
'Flauschi'
]
const generateRandomName = () => `${market[Math.floor(Math.random() * market.length)]} ${product[Math.floor(Math.random() * product.length)]}`
const randomName = useState('randomName', () => generateRandomName())
const cardLabel = computed(() => currentCardIndex > -1 ? 'Bearbeiten' : 'Hinzufügen')
const checkPrice = () => {
if (!currentCard) return false
if (currentCard.price.length === 0) return false
if (!currentCard) { return false }
if (currentCard.price.length === 0) { return false }
const price = +replaceComma(currentCard.price)
return !isNaN(price)
}
const checkIfInteger = (toBeNumber: string) => {
if (toBeNumber.length === 0) return false
if (toBeNumber.includes(',') || toBeNumber.includes(',')) return false
const checkIfInteger = (toBeNumber : string) => {
if (toBeNumber.length === 0) { return false }
if (toBeNumber.includes(',') || toBeNumber.includes('.')) { return false }
return !isNaN(+toBeNumber)
}
@ -141,7 +103,7 @@ const validFields = reactive({
})
const validate = () => {
if (!currentCard) return
if (!currentCard) { return }
validFields.name = currentCard.name.length > 0
validFields.price = checkPrice()
@ -149,7 +111,7 @@ const validate = () => {
validFields.sheets = checkIfInteger(currentCard.sheets)
validFields.layers = checkIfInteger(currentCard.layers)
if (Object.values(validFields).every((value) => value)) {
if (Object.values(validFields).every(value => value)) {
emit('update')
dialog.value?.close()
}
@ -162,9 +124,6 @@ onMounted(() => {
validFields.roles = true
validFields.sheets = true
validFields.layers = true
randomName.value = generateRandomName()
})
onClickOutside(wrapper, () => dialog.value?.close())
})
</script>
</script>

View file

@ -1,51 +0,0 @@
<template>
<article class="TimelineCard">
<Icon class="icon" :name="icon" mode="svg" />
<div class="text">
<strong>{{ title }}</strong>
<p>{{ description }}</p>
<div class="state" :style="{
'--color': stateColor,
}">
<Icon :name="stateIcon" mode="svg" />
<span>{{ stateMessage }}</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import type { TimelineCard, TimelineState } from '../../../shared/TimelineCard'
const { state } = defineProps<TimelineCard>()
const icons : Record<TimelineState, string> = {
planned: 'uil:clock',
inProgress: 'uil:cog',
done: 'uil:check-circle'
}
const colors : Record<TimelineState, string> = {
planned: 'var(--color-darkest)',
inProgress: 'var(--color-main-dark)',
done: 'var(--color-accent-darkest)',
}
const stateColor = computed(() => colors[state.value])
const stateIcon = computed(() => icons[state.value])
const stateMessage = computed(() => {
switch (state.value) {
case 'planned':
let planned = 'Geplant'
if (state.message) planned += ` für ${state.message}`
return planned
case 'inProgress':
return 'In Bearbeitung'
case 'done':
let done = 'Abgeschlossen'
if (state.message) done += ` am ${state.message}`
return done
}
})
</script>

View file

@ -1,24 +0,0 @@
import { ref } from 'vue'
const isNavigationVisible = ref(false)
export const useNavigation = () => {
const toggleNavigation = () => {
isNavigationVisible.value = !isNavigationVisible.value
}
const showNavigation = () => {
isNavigationVisible.value = true
}
const hideNavigation = () => {
isNavigationVisible.value = false
}
return {
isNavigationVisible,
toggleNavigation,
showNavigation,
hideNavigation
}
}

View file

@ -1,11 +0,0 @@
<template>
<div class="page-wrapper">
<PpHeader />
<div class="page">
<NuxtPage />
</div>
<PpFooter />
</div>
</template>
<script setup lang="ts">
</script>

View file

@ -1,11 +0,0 @@
<template>
<div class="page-wrapper">
<PpHeader type="lp" />
<div class="page">
<NuxtPage />
</div>
<PpFooter />
</div>
</template>
<script setup lang="ts">
</script>

View file

@ -1,7 +1,6 @@
<template>
<div>
<section class="Legal flex-col gap-default content full">
<h1>Impressum</h1>
<section class="Imp flex-col gap-default content full">
<div>
<p>
Fiona Lena Urban<br/>

252
app/pages/index.vue Normal file → Executable file
View file

@ -1,97 +1,185 @@
<template>
<section class="Home flex-col content full">
<div class="home-hero">
<div class="text">
<h1>
Du zahlst zuviel für's Papier?
</h1>
<NuxtLink to="/rechner">
<PpButton class="cta">Preise vergleichen</PpButton>
</NuxtLink>
<h2>
Mit ProPapier Preise vergleichen und sparen.
</h2>
</div>
</div>
<div class="home-text padding">
<p>
Mit <strong>ProPapier</strong> vergleichst du schnell & unkompliziert Preise für Klopapier und sparst so bares Geld.
</p>
</div>
<div class="home-text padding">
<h3>
Wir haben noch viel vor!
</h3>
<p class="padding">
Für ProPapier sind über die nächste Zeit noch einige weitere Features geplant.
</p>
<div class="timeline">
<PpTimelineCard
v-for="card in timeline"
v-bind="card"
/>
</div>
<PpDeleteDialog
ref="deleteModal"
:current-card-index="currentCardIndex"
@delete="removeCard(currentCardIndex)"
/>
<PpPriceCardDialog
ref="modal"
:current-card="currentCard"
:current-card-index="currentCardIndex"
@update="updateCard()"
/>
<section class="content flex-col">
<aside class="filter-bar">
<PpButtonGroup
:buttons="filterButtons"
@click="sort"
/>
</aside>
<div class="pc-wrapper flex-col" role="list">
<PpPriceCard
v-for="(card, index) in cards"
:key="card.uuid"
:deletable="cards.length > 1"
:card="card"
@update="openModal(false, index)"
@remove="openDeleteModal()"
/>
</div>
</section>
<PpToolbar>
<PpButton class="mini-button text-white transparent" @click="sort(currentSort)">
<Icon class="icon" name="uil:refresh" mode="svg" />
<span>Neu sortieren</span>
<span
class="dot"
:class="{ visible : isDirty}"
aria-hidden="true"
/>
</PpButton>
<PpButton class="mini-button text-white transparent" @click="openModal(true, -1)">
<Icon class="icon" name="uil:plus" mode="svg" />
<span>Hinzufügen</span>
</PpButton>
</PpToolbar>
</template>
<script setup lang="ts">
import type { TimelineCard } from '../../shared/TimelineCard'
import type { Card } from '../../shared/Card'
import type { Button } from '../../shared/ButtonGroup'
import { PpPriceCardDialog, PpDeleteDialog } from '#components'
definePageMeta({
layout: 'landingpage'
const currentSort = ref(0)
const isDirty = ref(false)
const currentCard = ref<Card>()
const currentCardIndex = ref<number>(-1)
const modal = useTemplateRef<typeof PpPriceCardDialog>('modal')
const deleteModal = useTemplateRef<typeof PpDeleteDialog>('deleteModal')
const createCard = (uuid : string) : Card => ({
uuid,
name: '',
price: '',
roles: '',
sheets: '',
layers: '',
ppr: 0,
pps: 0,
ppl: 0,
})
const timeline : TimelineCard[] = [
const cards = useState<Card[]>('cards', () => [
createCard(crypto.randomUUID()),
])
const addCard = (card : Card) => {
const price = calculate(card)
cards.value.unshift({ ...card, ...price })
isDirty.value = true
updateLocalStorage()
}
const removeCard = (index : number) => {
cards.value.splice(index, 1)
isDirty.value = true
updateLocalStorage()
}
const updateCard = () => {
if (currentCardIndex.value === -1) {
addCard(currentCard.value!)
return
}
const price = calculate(currentCard.value!)
const newCard = { ...currentCard.value!, ...price }
cards.value.splice(currentCardIndex.value, 1, newCard)
isDirty.value = true
updateLocalStorage()
}
const openModal = (createNew : boolean, index : number) => {
if (createNew) {
currentCardIndex.value = -1
currentCard.value = createCard(crypto.randomUUID())
modal.value?.$el.showModal()
return
}
currentCardIndex.value = index
currentCard.value = { ...cards.value[index]! }
modal.value?.$el.showModal()
return
}
const openDeleteModal = () => {
deleteModal.value?.$el.showModal()
}
const updateLocalStorage = () => {
localStorage.setItem('cards', JSON.stringify(cards.value.map(card => {
const { uuid, name, price, roles, sheets, layers } = card
return { uuid, name, price, roles, sheets, layers }
})))
localStorage.setItem('sort', JSON.stringify(currentSort.value))
}
const filterButtons = ref<Button[]>([
{
icon: 'uil:chart-bar',
title: 'Mehr Vergleiche',
description: 'Zusätzliche Kategorien für Taschentücher und Küchenrolle',
state: {
value: 'inProgress',
}
label: 'Rollen',
icon: 'uil:toilet-paper',
},
{
icon: 'uil:cloud-database-tree',
title: 'Datenbank',
description: 'Eine von der Community gestützte Datenbank mit Preisen für alle Produkte',
state: {
value: 'planned',
message: '2025',
}
label: 'Blatt',
icon: 'uil:file-landscape',
},
{
icon: 'uil:qrcode-scan',
title: 'Barcode Scan',
description: 'Ganz einfach Barcode Scannen und Produkt direkt zum Rechner hinzufügen',
state: {
value: 'planned',
message: '2025',
}
label: 'Lagen',
icon: 'uil:layer-group',
},
{
icon: 'uil:user',
title: 'Optionale Accounts',
description: 'Zur Synchronisierung auf mehreren Geräten',
state: {
value: 'planned',
}
},
{
icon: 'uil:cog',
title: 'Personalisierung',
description: 'Persönliche Präferenzen zur Wortwahl, Standardsortierung und mehr',
state: {
value: 'planned',
}
},
{
icon: 'uil:vector-square',
title: 'm² Preise',
description: 'Quadratmeterpreise für noch genauere Vergleiche',
state: {
value: 'planned',
}
},
]
</script>
])
const sortBy = (key : 'ppr' | 'pps' | 'ppl') => {
cards.value.sort((a : Card, b : Card) => a[key] - b[key])
}
const sort = (index : number) => {
currentSort.value = index
filterButtons.value.forEach(button => { button.active = false })
filterButtons.value[index]!.active = true
switch (index) {
case 0:
sortBy('ppr')
break
case 1:
sortBy('pps')
break
case 2:
sortBy('ppl')
break
}
updateLocalStorage()
isDirty.value = false
}
const calculate = (card : Card) => {
const ppr = +replaceComma(card.price) / +card.roles
const pps = (ppr / +card.sheets) * 100
const ppl = (pps / +card.layers)
return { ppr, pps, ppl }
}
onMounted(() => {
const cardsFromStorage = JSON.parse(localStorage.getItem('cards') ?? '[]').map((card : Card) => ({ ...card, ...calculate(card) }))
cards.value = cardsFromStorage.length !== 0 ? cardsFromStorage : cards.value
const sortFromStorage = +JSON.parse(localStorage.getItem('sort') ?? '0')
sort(sortFromStorage)
filterButtons.value[sortFromStorage]!.active = true
})
</script>

81
app/pages/overview.vue Executable file
View file

@ -0,0 +1,81 @@
<template>
<section class="content flex-col">
<aside class="filter-bar">
<PpButtonGroup
:buttons="filterButtons"
@click="sort"
/>
</aside>
<div class="pc-wrapper flex-col" role="list">
<p
v-for="product in products"
:key="product.sku"
>
{{ product.name }} - {{ product.price }}
</p>
</div>
</section>
</template>
<script setup lang="ts">
import type { Card } from '../../shared/Card'
import type { Button } from '../../shared/ButtonGroup'
import type { Product } from '../../shared/db/Product'
const currentSort = ref(0)
const createCard = (uuid : string) : Card => ({
uuid,
name: '',
price: 0,
roles: 0,
sheets: 0,
layers: 0,
ppr: 0,
pps: 0,
ppl: 0,
})
const { data : products } = useFetch<Product[]>('/api/products')
const cards = useState('cards', () => [
createCard(crypto.randomUUID()),
])
const filterButtons = ref<Button[]>([
{
label: 'Rollen',
icon: 'uil:toilet-paper',
},
{
label: 'Blatt',
icon: 'uil:file-landscape',
},
{
label: 'Lagen',
icon: 'uil:layer-group',
},
])
const sortBy = (key : 'ppr' | 'pps' | 'ppl') => {
cards.value.sort((a : Card, b : Card) => a[key] - b[key])
}
const sort = (index : number) => {
currentSort.value = index
filterButtons.value.forEach(button => { button.active = false })
filterButtons.value[index]!.active = true
switch (index) {
case 0:
sortBy('ppr')
break
case 1:
sortBy('pps')
break
case 2:
sortBy('ppl')
break
}
}
</script>

View file

@ -1,53 +1,17 @@
<template>
<section class="Legal flex-col gap-default content full">
<h2>1) Einleitung und Kontaktdaten des Verantwortlichen</h2>
<p><b>1.1</b>&nbsp;Wir freuen uns, dass du unsere Website besuchst und bedanken uns f&uuml;r dein Interesse. Im Folgenden informieren wir dich &uuml;ber den Umgang mit deinen personenbezogenen Daten bei der Nutzung unserer Website. Personenbezogene Daten sind hierbei alle Daten, mit denen du pers&ouml;nlich identifiziert werden kannst.</p>
<p><b>1.2</b>&nbsp;Verantwortlicher f&uuml;r die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO) ist Fiona Lena Urban, Teichäckerweg 39, 76297 Stutensee , Tel.: 017631640961, E-Mail: fiona@webfussel.de. Der f&uuml;r die Verarbeitung von personenbezogenen Daten Verantwortliche ist diejenige nat&uuml;rliche oder juristische Person, die allein oder gemeinsam mit anderen &uuml;ber die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten entscheidet.</p>
<h2>2) Datenerfassung beim Besuch unserer Website</h2>
<p><b>2.1</b>&nbsp;Bei der blo&szlig; informatorischen Nutzung unserer Website, also wenn du dich nicht registrierst oder uns anderweitig Informationen &uuml;bermittelst, erheben wir nur solche Daten, die dein Browser an den Seitenserver &uuml;bermittelt (sog. &bdquo;Server-Logfiles&#8220;). Wenn du unsere Website aufrufst, erheben wir die folgenden Daten, die f&uuml;r uns technisch erforderlich sind, um dir die Website anzuzeigen:</p>
<ul>
<li>Unsere besuchte Website</li>
<li>Datum und Uhrzeit zum Zeitpunkt des Zugriffs</li>
<li>Menge der gesendeten Daten in Byte</li>
<li>Quelle/Verweis, von welchem du auf die Seite gelangtest</li>
<li>Verwendeter Browser</li>
<li>Verwendetes Betriebssystem</li>
<li>Verwendete IP-Adresse (ggf.: in anonymisierter Form)</li>
</ul>
<p>Die Verarbeitung erfolgt gem&auml;&szlig; Art. 6 Abs. 1 lit. f DSGVO auf Basis unseres berechtigten Interesses an der Verbesserung der Stabilit&auml;t und Funktionalit&auml;t unserer Website. Eine Weitergabe oder anderweitige Verwendung der Daten findet nicht statt. Wir behalten uns allerdings vor, die Server-Logfiles nachtr&auml;glich zu &uuml;berpr&uuml;fen, sollten konkrete Anhaltspunkte auf eine rechtswidrige Nutzung hinweisen.</p>
<p><b>2.2</b>&nbsp;Diese Website nutzt aus Sicherheitsgr&uuml;nden und zum Schutz der &Uuml;bertragung personenbezogener Daten und anderer vertraulicher Inhalte (z.B. Bestellungen oder Anfragen an uns) eine SSL-bzw. TLS-Verschl&uuml;sselung. Du kannst eine verschl&uuml;sselte Verbindung an der Zeichenfolge &bdquo;https://&#8220; und dem Schloss-Symbol in deiner Browserzeile erkennen.</p>
<h2>3) Kontaktaufnahme</h2>
<p>Im Rahmen der Kontaktaufnahme mit uns (z.B. per Kontaktformular oder E-Mail) werden &ndash; ausschlie&szlig;lich zum Zweck der Bearbeitung und Beantwortung deines Anliegens und nur im daf&uuml;r erforderlichen Umfang &ndash; personenbezogene Daten verarbeitet.</p>
<p>Rechtsgrundlage f&uuml;r die Verarbeitung dieser Daten ist unser berechtigtes Interesse an der Beantwortung deines Anliegens gem&auml;&szlig; Art. 6 Abs. 1 lit. f DSGVO. Zielt deine Kontaktierung auf einen Vertrag ab, so ist zus&auml;tzliche Rechtsgrundlage f&uuml;r die Verarbeitung Art. 6 Abs. 1 lit. b DSGVO. Deine Daten werden gel&ouml;scht, wenn sich aus den Umst&auml;nden entnehmen l&auml;sst, dass der betroffene Sachverhalt abschlie&szlig;end gekl&auml;rt ist und sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.</p>
<h2>4) Webanalysedienste</h2>
<p>Plausible</p>
<p>Diese Website nutzt &bdquo;Plausible&#8220;, ein Webanalyse-Tool der Firma Plausible Insights O&Uuml; V&auml;striku tn 2, 50403, Tartu, Estland.</p>
<p>Es werden damit Interaktionen von zuf&auml;llig ausgew&auml;hlten, einzelnen Besuchern mit der Internetseite anonymisiert aufgezeichnet. So entsteht ein Protokoll von z.B. Mausbewegungen und -Klicks mit dem Ziel, Verbesserungsm&ouml;glichkeiten der jeweiligen Internetseite aufzuzeigen. Zu keinem Zeitpunkt werden personenbezogene Daten erhoben oder verarbeitet. Plausible erhebt bei der Nutzung dieser Internetseite ausschlie&szlig;lich nicht personenbezogene Daten wie Informationen zum Browser und zum User Agent. Diese werden in nicht personenbeziehbarer Form gespeichert und zu statistischen Zwecken ausgewertet. Eine L&ouml;schung findet statt, sobald die Daten f&uuml;r unsere Auswertungszwecke nicht mehr ben&ouml;tigt werden.</p>
<p>Sofern im Einzelfall doch personenbezogene Daten verarbeitet werden, erfolgt die Verarbeitung auf Basis unseres berechtigten Interesses an der statistischen Auswertung des Nutzungsverhaltens zu Optimierungszwecken gem&auml;&szlig; Art. 6 Abs. 1 lit. f DSGVO.</p>
<h2>5) Rechte des Betroffenen</h2>
<p><b>5.1</b>&nbsp;Das geltende Datenschutzrecht gew&auml;hrt dir gegen&uuml;ber uns als Verantwortlichen hinsichtlich der Verarbeitung deiner personenbezogenen Daten die nachstehenden Betroffenenrechte (Auskunfts- und Interventionsrechte), wobei f&uuml;r die jeweiligen Aus&uuml;bungsvoraussetzungen auf die angef&uuml;hrte Rechtsgrundlage verwiesen wird:</p>
<ul>
<li>Auskunftsrecht gem&auml;&szlig; Art. 15 DSGVO;</li>
<li>Recht auf Berichtigung gem&auml;&szlig; Art. 16 DSGVO;</li>
<li>Recht auf L&ouml;schung gem&auml;&szlig; Art. 17 DSGVO;</li>
<li>Recht auf Einschr&auml;nkung der Verarbeitung gem&auml;&szlig; Art. 18 DSGVO;</li>
<li>Recht auf Unterrichtung gem&auml;&szlig; Art. 19 DSGVO;</li>
<li>Recht auf Daten&uuml;bertragbarkeit gem&auml;&szlig; Art. 20 DSGVO;</li>
<li>Recht auf Widerruf erteilter Einwilligungen gem&auml;&szlig; Art. 7 Abs. 3 DSGVO;</li>
<li>Recht auf Beschwerde gem&auml;&szlig; Art. 77 DSGVO.</li>
</ul>
<p><b>5.2</b>&nbsp;WIDERSPRUCHSRECHT</p>
<p>WENN WIR IM RAHMEN EINER INTERESSENABW&Auml;GUNG DEINE PERSONENBEZOGENEN DATEN AUFGRUND UNSERES &Uuml;BERWIEGENDEN BERECHTIGTEN INTERESSES VERARBEITEN, HAST DU DAS JEDERZEITIGE RECHT, AUS GR&Uuml;NDEN, DIE SICH AUS DEINER BESONDEREN SITUATION ERGEBEN, GEGEN DIESE VERARBEITUNG WIDERSPRUCH MIT WIRKUNG F&Uuml;R DIE ZUKUNFT EINZULEGEN.</p>
<p>MACHST DU VON DEINEM WIDERSPRUCHSRECHT GEBRAUCH, BEENDEN WIR DIE VERARBEITUNG DER BETROFFENEN DATEN. EINE WEITERVERARBEITUNG BLEIBT ABER VORBEHALTEN, WENN WIR ZWINGENDE SCHUTZW&Uuml;RDIGE GR&Uuml;NDE F&Uuml;R DIE VERARBEITUNG NACHWEISEN K&Ouml;NNEN, DIE DEINE INTERESSEN, GRUNDRECHTE UND GRUNDFREIHEITEN &Uuml;BERWIEGEN, ODER WENN DIE VERARBEITUNG DER GELTENDMACHUNG, AUS&Uuml;BUNG ODER VERTEIDIGUNG VON RECHTSANSPR&Uuml;CHEN DIENT.</p>
<p>WERDEN DEINE PERSONENBEZOGENEN DATEN VON UNS VERARBEITET, UM DIREKTWERBUNG ZU BETREIBEN, HAST DU DAS RECHT, JEDERZEIT WIDERSPRUCH GEGEN DIE VERARBEITUNG DIR BETREFFENDER PERSONENBEZOGENER DATEN ZUM ZWECKE DERARTIGER WERBUNG EINZULEGEN. DU KANNST DEN WIDERSPRUCH WIE OBEN BESCHRIEBEN AUS&Uuml;BEN.</p>
<p>MACHST DU VON DEINEM WIDERSPRUCHSRECHT GEBRAUCH, BEENDEN WIR DIE VERARBEITUNG DER BETROFFENEN DATEN ZU DIREKTWERBEZWECKEN.</p>
<h2>6) Dauer der Speicherung personenbezogener Daten</h2>
<p>Die Dauer der Speicherung von personenbezogenen Daten bemisst sich anhand der jeweiligen Rechtsgrundlage, am Verarbeitungszweck und &ndash; sofern einschl&auml;gig &ndash; zus&auml;tzlich anhand der jeweiligen gesetzlichen Aufbewahrungsfrist (z.B. handels- und steuerrechtliche Aufbewahrungsfristen).</p>
<p>Bei der Verarbeitung von personenbezogenen Daten auf Grundlage einer ausdr&uuml;cklichen Einwilligung gem&auml;&szlig; Art. 6 Abs. 1 lit. a DSGVO werden die betroffenen Daten so lange gespeichert, bis du deine Einwilligung widerrufst.</p>
<p>Existieren gesetzliche Aufbewahrungsfristen f&uuml;r Daten, die im Rahmen rechtsgesch&auml;ftlicher bzw. rechtsgesch&auml;fts&auml;hnlicher Verpflichtungen auf der Grundlage von Art. 6 Abs. 1 lit. b DSGVO verarbeitet werden, werden diese Daten nach Ablauf der Aufbewahrungsfristen routinem&auml;&szlig;ig gel&ouml;scht, sofern sie nicht mehr zur Vertragserf&uuml;llung oder Vertragsanbahnung erforderlich sind und/oder unsererseits kein berechtigtes Interesse an der Weiterspeicherung fortbesteht.</p>
<p>Bei der Verarbeitung von personenbezogenen Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO werden diese Daten so lange gespeichert, bis du dein Widerspruchsrecht nach Art. 21 Abs. 1 DSGVO aus&uuml;bst, es sei denn, wir k&ouml;nnen zwingende schutzw&uuml;rdige Gr&uuml;nde f&uuml;r die Verarbeitung nachweisen, die deine Interessen, Rechte und Freiheiten &uuml;berwiegen, oder die Verarbeitung dient der Geltendmachung, Aus&uuml;bung oder Verteidigung von Rechtsanspr&uuml;chen.</p>
<p>Bei der Verarbeitung von personenbezogenen Daten zum Zwecke der Direktwerbung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO werden diese Daten so lange gespeichert, bis du dein Widerspruchsrecht nach Art. 21 Abs. 2 DSGVO aus&uuml;bst.</p>
<p>Sofern sich aus den sonstigen Informationen dieser Erkl&auml;rung &uuml;ber spezifische Verarbeitungssituationen nichts anderes ergibt, werden gespeicherte personenbezogene Daten im &Uuml;brigen dann gel&ouml;scht, wenn sie f&uuml;r die Zwecke, f&uuml;r die sie erhoben oder auf sonstige Weise verarbeitet wurden, nicht mehr notwendig sind.</p>
<section class="flex-col gap-default content full">
<h3>
Datenschutzerklärung
</h3>
<p>
Wir senden keinerlei Daten an Drittanbieter.
</p>
<p>
Wir verwerten keinerlei Daten.
</p>
<p>
Alle persistierten Daten befinden sich ausschließlich auf Ihrem Endgerät im sogenannten "localStorage" und werden ausschließlich auf Ihrem Gerät verarbeitet.
</p>
</section>
</template>

View file

@ -1,151 +0,0 @@
<template>
<div class="nuxt-page-wrapper flex-col">
<ClientOnly>
<PpDeleteDialog
ref="deleteModal"
:current-card-index="currentCardIndex"
@delete="removeCard(currentCardIndex)"
/>
<PpPriceCardDialog
ref="modal"
:current-card="currentCard"
:current-card-index="currentCardIndex"
@update="updateCard()"
/>
<section class="content flex-col">
<aside class="filter-bar">
<button v-for="(button, index) in filterButtons" @click="() => sort(index)" :class="{ 'active': button.active }">
{{ button.label }}
</button>
</aside>
<div class="flex-col" role="list" v-if="cards.length">
<PpPriceCard
ref="priceCard"
v-for="(card, index) in cards"
:key="card.uuid"
:deletable="cards.length > 1"
:card="card"
@update="openModal(false, index)"
@remove="openDeleteModal()"
/>
</div>
<p class="info-text grow" v-else>
Du hast noch keinerlei Einträge angelegt.
<br />Aber das ist gar nicht schlimm!
<br />Tippe einfach unten auf "+ Hinzufügen" und leg los.
</p>
</section>
<PpToolbar>
<PpButton class="mini-button text-white transparent" @click="openModal(true, -1)">
<Icon class="icon" name="uil:plus" mode="svg" />
<span>Hinzufügen</span>
</PpButton>
</PpToolbar>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { PriceCard } from '../../shared/PriceCard'
import type { Button } from '../../shared/ButtonGroup'
import { PpPriceCardDialog, PpDeleteDialog, PpPriceCard } from '#components'
const cards = useLocalStorage<PriceCard[]>('cards', [])
const currentSort = useLocalStorage<number>('sort', 0)
const currentCard = ref<PriceCard>()
const currentCardIndex = ref<number>(-1)
const modal = useTemplateRef<typeof PpPriceCardDialog>('modal')
const deleteModal = useTemplateRef<typeof PpDeleteDialog>('deleteModal')
const priceCards = useTemplateRef<(typeof PpPriceCard)[]>('priceCard')
const createCard = (uuid : string) : PriceCard => ({
uuid,
name: '',
price: '',
roles: '',
sheets: '',
layers: '',
})
const addCard = (card : PriceCard) => {
cards.value.unshift({ ...card })
sort()
}
const removeCard = (index : number) => {
cards.value.splice(index, 1)
sort()
}
const updateCard = () => {
if (currentCardIndex.value === -1) {
addCard(currentCard.value!)
return
}
const newCard = { ...currentCard.value! }
cards.value.splice(currentCardIndex.value, 1, newCard)
sort()
}
const openModal = (createNew : boolean, index : number) => {
if (createNew) {
currentCardIndex.value = -1
currentCard.value = createCard(randomUUID())
modal.value?.$el.showModal()
return
}
currentCardIndex.value = index
currentCard.value = { ...cards.value[index]! }
modal.value?.$el.showModal()
return
}
const openDeleteModal = () => {
deleteModal.value?.$el.showModal()
}
const filterButtons = ref<Button[]>([
{
label: 'Rollen',
icon: 'uil:toilet-paper',
active: currentSort.value === 0,
},
{
label: 'Blatt',
icon: 'uil:file-landscape',
active: currentSort.value === 1,
},
{
label: 'Lagen',
icon: 'uil:layer-group',
active: currentSort.value === 2,
},
])
const sortBy = (key : 'ppr' | 'pps' | 'ppl') => {
cards.value.sort((a, b) => {
const aCard = priceCards.value?.find(card => card.uuid === a.uuid) || null
const bCard = priceCards.value?.find(card => card.uuid === b.uuid) || null
if (!aCard || !bCard) return 0
return aCard[key] - bCard[key]
})
}
const sort = async (index : number = currentSort.value) => {
currentSort.value = index
filterButtons.value.forEach(button => { button.active = false })
filterButtons.value[index]!.active = true
await nextTick()
switch (index) {
case 0: return sortBy('ppr')
case 1: return sortBy('pps')
case 2: return sortBy('ppl')
}
}
</script>

View file

@ -1,5 +1,3 @@
export const replaceComma = (value: string | number) => `${value}`.replace(',', '.')
export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
export const between = (value: number, min: number, max: number) => value >= min && value <= max
export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)

View file

@ -1,7 +0,0 @@
export const randomUUID = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
})
}

View file

@ -1,47 +0,0 @@
version: '3'
services:
propapier:
image: oven/bun:latest
container_name: propapier
working_dir: /app
ports:
- "1338:3000"
volumes:
- propapier_data:/app
environment:
- NODE_ENV=production
command: >
sh -c "
# Install git and curl if not already installed
if ! command -v git &> /dev/null || ! command -v curl &> /dev/null; then
echo 'Installing required packages...'
apt-get update && apt-get install -y git curl
fi &&
# Clone repository if not already cloned
if [ ! -d /app/.git ]; then
echo 'Cloning repository...'
git clone https://git.webfussel.de/webfussel/propapier /tmp/propapier &&
cp -r /tmp/propapier/. /app/ &&
rm -rf /tmp/propapier
fi &&
# Install dependencies and start application
echo 'Installing dependencies...' &&
bun install &&
echo 'Building application...' &&
bun run build &&
echo 'Starting application...' &&
bun .output/server/index.mjs
"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
propapier_data:

View file

@ -1,5 +1,4 @@
const description = 'Du zahlst zuviel für\'s Papier? Vergleiche schnell und unkompliziert die Preise für Toiletten-, Küchen- und andere Papier hier.'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: false },
@ -17,20 +16,6 @@ export default defineNuxtConfig({
}
},
app: {
pageTransition: {
name: 'page',
mode: 'out-in',
},
head: {
htmlAttrs: { lang: 'de' },
link: [
{ rel: 'preload', as: 'image', href: '/img/propapier.svg', type: 'image/svg+xml' },
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' },
],
}
},
routeRules: {
'/': { prerender: true },
'/imprint': { prerender: true },
@ -41,71 +26,24 @@ export default defineNuxtConfig({
'@nuxt/icon',
'@vueuse/nuxt',
'@nuxtjs/device',
'@nuxt/fonts',
'nuxt-seo-utils',
'nuxt-ripple',
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'@nuxtjs/plausible'
'nuxt-mongoose',
],
css : [
'./app/assets/styles/general.css',
'./app/assets/styles/header.css',
'./app/assets/styles/navigation.css',
'./app/assets/styles/footer.css',
'./app/assets/styles/button.css',
'./app/assets/styles/buttonGroup.css',
'./app/assets/styles/priceCard.css',
'./app/assets/styles/timelineCard.css',
'./app/assets/styles/form/textfield.css',
'./app/assets/styles/form/search.css',
'./app/assets/styles/formInput.css',
'./app/assets/styles/toolbar.css',
'./app/assets/styles/page.css',
'./app/assets/styles/dialog.css',
'./app/assets/styles/landingpage.css',
],
site: {
url: 'https://pro-papier.de',
name: 'ProPapier',
mongoose: {
uri: `${process.env.MONGODB_URI}`,
modelsDir: 'models',
devtools: true,
},
seo: {
meta: {
title: 'ProPapier',
description,
themeColor: [
{ content: '#18181b', media: '(prefers-color-scheme: dark)' },
{ content: 'white', media: '(prefers-color-scheme: light)' },
],
twitterCreator: '@webfussel',
twitterSite: '@propapier',
author: 'webfussel',
colorScheme: 'dark light',
applicationName: 'ProPapier',
// Nuxt SEO Utils already sets the below tags for you
ogSiteName: 'ProPapier',
ogLocale: 'de_DE',
ogType: 'website',
ogUrl: 'https://pro-papier.de',
ogTitle: 'ProPapier',
ogDescription: description,
// Other Nuxt SEO modules handles these
ogImage: '/img/og.png',
robots: 'index, follow',
}
},
sitemap: {
// exclude all URLs that start with /secret
exclude: ['/other/**'],
},
plausible: {
// Prevent tracking on localhost
ignoredHostnames: ['localhost'],
},
})

6983
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,27 +3,22 @@
"private": true,
"type": "module",
"scripts": {
"prepare": "nuxt prepare",
"build": "nuxt build",
"dev": "nuxt dev",
"dev:expose": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "npx serve .output/public",
"prepare": "nuxt prepare",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@iconify-json/simple-icons": "^1.2.32",
"@iconify-json/uil": "^1.2.3",
"@nuxt/fonts": "^0.11.3",
"@nuxt/icon": "^1.10.3",
"@nuxtjs/device": "^3.2.4",
"@nuxtjs/plausible": "^1.2.0",
"@nuxtjs/robots": "^5.2.10",
"@nuxtjs/sitemap": "^7.3.0",
"@vueuse/nuxt": "^13.1.0",
"nuxt": "^3.16.2",
"nuxt-ripple": "^0.0.8",
"nuxt-seo-utils": "^7.0.11",
"nuxt-mongoose": "^1.0.6",
"vue": "latest",
"vue-router": "latest"
}

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1835 1957" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M1567.66,18.116c145.891,-0 264.336,225.821 264.336,503.97c-0,278.149 -118.445,503.971 -264.336,503.971c-145.891,-0 -264.336,-225.822 -264.336,-503.971c0,-278.149 118.445,-503.97 264.336,-503.97Zm-0,224.151c-68.571,-0 -124.242,125.383 -124.242,279.819c0,154.437 55.671,279.82 124.242,279.82c68.571,0 124.241,-125.383 124.241,-279.82c0,-154.436 -55.67,-279.819 -124.241,-279.819Z" style="fill:#fff;"/><path d="M1303.43,755.105c13.811,85.466 89.537,243.344 171.549,272.195l-167.42,-1.213l-4.129,-270.982Z" style="fill:#fff;"/><path d="M9.052,1835.28l1.152,-1300.71c4.727,-203.082 60.44,-510.777 379.038,-525.353l1070.29,5.76c-106.16,85.364 -183.364,230.766 -179.726,543.787l0,1309.93c0,94.917 -24.936,111.898 -116.361,20.738c-67.285,-67.09 -128.514,-68.942 -195.856,-2.304c-80.33,79.49 -145.126,81.518 -216.593,2.304c-64.824,-71.852 -138.451,-64.497 -203.92,-0c-74.453,73.348 -133.367,79.706 -208.528,-4.609c-59.394,-66.627 -136.107,-49.149 -208.528,9.217c-95.361,76.853 -120.97,91.489 -120.97,-58.757Zm265.074,-1341.6c-0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.181,2.321 -5.181,5.18c0,2.859 2.322,5.181 5.181,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm138.149,624.213l0,79.687l-84.961,0l0,72.657l85.037,-0c0.756,52.26 7.176,98.257 19.26,137.988c12.891,42.383 31.25,77.734 55.078,106.055c23.828,28.32 51.953,49.609 84.375,63.867c32.422,14.258 68.164,21.386 107.227,21.386c20.703,0 41.211,-1.562 61.523,-4.687c20.313,-3.125 39.844,-7.422 58.594,-12.891l-11.719,-93.75c-16.406,6.25 -33.691,11.133 -51.855,14.649c-18.164,3.515 -36.621,5.273 -55.371,5.273c-24.219,0 -46.094,-5.078 -65.625,-15.234c-19.532,-10.156 -36.328,-25.391 -50.391,-45.703c-14.062,-20.313 -24.805,-46.094 -32.227,-77.344c-6.796,-28.617 -10.481,-61.82 -11.053,-99.609l182.147,-0l0,-72.657l-182.226,0l-0,-79.687l182.226,-0l0,-73.242l-181.972,-0c0.952,-34.41 4.578,-64.879 10.878,-91.407c7.422,-31.25 17.969,-57.031 31.641,-77.343c13.672,-20.313 30.274,-35.547 49.805,-45.704c19.531,-10.156 41.601,-15.234 66.211,-15.234c19.14,0 37.793,1.856 55.957,5.567c18.164,3.71 35.449,8.691 51.855,14.941l11.719,-94.336c-20.313,-5.469 -40.234,-9.863 -59.766,-13.184c-19.531,-3.32 -39.843,-4.98 -60.937,-4.98c-39.063,-0 -74.903,7.129 -107.52,21.387c-32.617,14.257 -60.644,35.644 -84.082,64.16c-23.437,28.515 -41.601,64.062 -54.492,106.64c-11.367,37.545 -17.722,80.709 -19.066,129.493l-85.231,-0l0,73.242l84.961,-0Zm403.587,-624.213c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm-272.284,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm541.737,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c-0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Z" style="fill:#fff;"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1 +0,0 @@
<svg id="visual" viewBox="0 0 3840 2160" width="3840" height="2160" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"><rect x="0" y="0" width="3840" height="2160" fill="#292929"></rect><defs><linearGradient id="grad1_0" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#013174" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#013174" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad1_1" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#013174" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#202e5a" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad1_2" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#282b41" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#202e5a" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad1_3" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#282b41" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#292929" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_0" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#013174" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#013174" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_1" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#202e5a" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#013174" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_2" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#202e5a" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#282b41" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_3" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#292929" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#282b41" stop-opacity="1"></stop></linearGradient></defs><g transform="translate(3840, 2160)"><path d="M-1956 0C-1926.7 -253.3 -1897.4 -506.6 -1820 -753.9C-1742.7 -1001.2 -1617.2 -1242.4 -1438.3 -1438.3C-1259.3 -1634.1 -1027 -1784.5 -778.4 -1879.2C-529.8 -1973.9 -264.9 -2012.9 0 -2052L0 0Z" fill="#292a35"></path><path d="M-1467 0C-1445 -190 -1423.1 -380 -1365 -565.4C-1307 -750.9 -1212.9 -931.8 -1078.7 -1078.7C-944.5 -1225.6 -770.2 -1338.3 -583.8 -1409.4C-397.3 -1480.4 -198.7 -1509.7 0 -1539L0 0Z" fill="#252d4d"></path><path d="M-978 0C-963.4 -126.7 -948.7 -253.3 -910 -376.9C-871.3 -500.6 -808.6 -621.2 -719.1 -719.1C-629.7 -817 -513.5 -892.2 -389.2 -939.6C-264.9 -986.9 -132.4 -1006.5 0 -1026L0 0Z" fill="#172f67"></path><path d="M-489 0C-481.7 -63.3 -474.4 -126.7 -455 -188.5C-435.7 -250.3 -404.3 -310.6 -359.6 -359.6C-314.8 -408.5 -256.7 -446.1 -194.6 -469.8C-132.4 -493.5 -66.2 -503.2 0 -513L0 0Z" fill="#013174"></path></g><g transform="translate(0, 0)"><path d="M1962 0C1948.3 263.6 1934.6 527.1 1846.8 765C1759 1002.9 1597.1 1215.1 1413.5 1413.5C1229.9 1612 1024.5 1796.7 785.3 1895.8C546.1 1994.9 273 2008.5 0 2022L0 0Z" fill="#292a35"></path><path d="M1471.5 0C1461.2 197.7 1451 395.3 1385.1 573.7C1319.3 752.1 1197.9 911.3 1060.1 1060.1C922.4 1209 768.4 1347.5 588.9 1421.9C409.5 1496.2 204.8 1506.3 0 1516.5L0 0Z" fill="#252d4d"></path><path d="M981 0C974.2 131.8 967.3 263.6 923.4 382.5C879.5 501.4 798.6 607.5 706.8 706.8C614.9 806 512.2 898.3 392.6 947.9C273 997.5 136.5 1004.2 0 1011L0 0Z" fill="#172f67"></path><path d="M490.5 0C487.1 65.9 483.7 131.8 461.7 191.2C439.8 250.7 399.3 303.8 353.4 353.4C307.5 403 256.1 449.2 196.3 474C136.5 498.7 68.3 502.1 0 505.5L0 0Z" fill="#013174"></path></g></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

View file

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1835 1957" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M1567.66,18.116c145.891,-0 264.336,225.821 264.336,503.97c-0,278.149 -118.445,503.971 -264.336,503.971c-145.891,-0 -264.336,-225.822 -264.336,-503.971c0,-278.149 118.445,-503.97 264.336,-503.97Zm-0,224.151c-68.571,-0 -124.242,125.383 -124.242,279.819c0,154.437 55.671,279.82 124.242,279.82c68.571,0 124.241,-125.383 124.241,-279.82c0,-154.436 -55.67,-279.819 -124.241,-279.819Z" style="fill:#fff;"/><path d="M1303.43,755.105c13.811,85.466 89.537,243.344 171.549,272.195l-167.42,-1.213l-4.129,-270.982Z" style="fill:#fff;"/><path d="M9.052,1835.28l1.152,-1300.71c4.727,-203.082 60.44,-510.777 379.038,-525.353l1070.29,5.76c-106.16,85.364 -183.364,230.766 -179.726,543.787l0,1309.93c0,94.917 -24.936,111.898 -116.361,20.738c-67.285,-67.09 -128.514,-68.942 -195.856,-2.304c-80.33,79.49 -145.126,81.518 -216.593,2.304c-64.824,-71.852 -138.451,-64.497 -203.92,-0c-74.453,73.348 -133.367,79.706 -208.528,-4.609c-59.394,-66.627 -136.107,-49.149 -208.528,9.217c-95.361,76.853 -120.97,91.489 -120.97,-58.757Zm265.074,-1341.6c-0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.181,2.321 -5.181,5.18c0,2.859 2.322,5.181 5.181,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm138.149,624.213l0,79.687l-84.961,0l0,72.657l85.037,-0c0.756,52.26 7.176,98.257 19.26,137.988c12.891,42.383 31.25,77.734 55.078,106.055c23.828,28.32 51.953,49.609 84.375,63.867c32.422,14.258 68.164,21.386 107.227,21.386c20.703,0 41.211,-1.562 61.523,-4.687c20.313,-3.125 39.844,-7.422 58.594,-12.891l-11.719,-93.75c-16.406,6.25 -33.691,11.133 -51.855,14.649c-18.164,3.515 -36.621,5.273 -55.371,5.273c-24.219,0 -46.094,-5.078 -65.625,-15.234c-19.532,-10.156 -36.328,-25.391 -50.391,-45.703c-14.062,-20.313 -24.805,-46.094 -32.227,-77.344c-6.796,-28.617 -10.481,-61.82 -11.053,-99.609l182.147,-0l0,-72.657l-182.226,0l-0,-79.687l182.226,-0l0,-73.242l-181.972,-0c0.952,-34.41 4.578,-64.879 10.878,-91.407c7.422,-31.25 17.969,-57.031 31.641,-77.343c13.672,-20.313 30.274,-35.547 49.805,-45.704c19.531,-10.156 41.601,-15.234 66.211,-15.234c19.14,0 37.793,1.856 55.957,5.567c18.164,3.71 35.449,8.691 51.855,14.941l11.719,-94.336c-20.313,-5.469 -40.234,-9.863 -59.766,-13.184c-19.531,-3.32 -39.843,-4.98 -60.937,-4.98c-39.063,-0 -74.903,7.129 -107.52,21.387c-32.617,14.257 -60.644,35.644 -84.082,64.16c-23.437,28.515 -41.601,64.062 -54.492,106.64c-11.367,37.545 -17.722,80.709 -19.066,129.493l-85.231,-0l0,73.242l84.961,-0Zm403.587,-624.213c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm-272.284,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm541.737,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c-0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Z" style="fill:#fff;"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1 +0,0 @@
export default defineEventHandler((): string => 'ok' )

View file

@ -0,0 +1,9 @@
export default defineEventHandler(async () => {
try {
return await ProductSchema.find()
} catch (error) {
console.error(error)
return []
}
})

View file

@ -0,0 +1,49 @@
import { defineMongooseModel } from '#nuxt/mongoose'
export const ProductSchema = defineMongooseModel({
name: 'products',
schema: {
name: {
type: String,
required: true,
},
brand: {
type: String,
required: true,
},
image: {
type: String,
required: true,
unique: true,
},
market: {
type: String,
required: true,
},
category: {
type: String,
required: true,
},
sku: {
type: String,
required: true,
unique: true,
},
price: {
type: Number,
required: true,
},
pieces: {
type: Number,
required: true,
},
sheets: {
type: Number,
required: true,
},
layers: {
type: Number,
required: true,
},
}
})

11
shared/Card.ts Normal file
View file

@ -0,0 +1,11 @@
export type Card = {
ppr: number
pps: number
ppl: number
uuid: string
name: string
price: string
roles: string
sheets: string
layers: string
}

View file

@ -1,8 +0,0 @@
export type PriceCard = {
uuid : string
name : string
price : string
roles : string
sheets : string
layers : string
}

View file

@ -1,11 +0,0 @@
export type TimelineState = 'planned' | 'inProgress' | 'done'
export type TimelineCard = {
icon: string
title: string
description: string
state: {
value: TimelineState
message ?: string
}
}

12
shared/db/Product.ts Normal file
View file

@ -0,0 +1,12 @@
export type Product = {
name: string
brand: string
image: string
market: string
category: string
sku: string
price: number
pieces: number
sheets: number
layers: number
}