Compare commits

...
Sign in to create a new pull request.

41 commits

Author SHA1 Message Date
f5fa5b2971 fix: small wording issues
Fix wording issues for consistency
2025-05-23 10:32:39 +02:00
d8c28ceea1 fix: dialog max width on desktop
Add clamp for dialogs for better look on desktop
2025-05-23 10:14:07 +02:00
eec6175e13 fix: dialog max width on desktop
Add max width for dialogs for better look on desktop
2025-05-23 10:10:54 +02:00
d7b624df4d fix: Calculator fixes
Add mobile/desktop fixes to calculator
2025-05-23 09:20:59 +02:00
d71e59b9c0 add: OpenGraph image, timeline card state
Added three states to timeline card, added open graph image and description
2025-05-23 08:58:14 +02:00
f75d66a0d0 Merge pull request 'add: desktop-background + fix: desktop-layout' (#7) from desktop-fixes into main
Reviewed-on: #7
2025-05-23 08:26:41 +02:00
0ccfe985ae add: small fix for flex
Add flex attribute
2025-05-23 08:26:03 +02:00
2d31b019e9 add: desktop-background + fix: desktop-layout 2025-05-22 14:23:09 +02:00
3b67ce0e5a add: tracking
Add Plausible
2025-05-22 10:57:42 +02:00
0a71a62af8 add: more lp stuff
Added timeline to LP
2025-05-22 10:39:06 +02:00
c7286a60da add: lp and navigation
Added... a lot of stuff
2025-05-22 10:12:47 +02:00
fb10e5b746 Merge pull request 'fix: normalized and centralized sizes and padding & landingpage structure' (#5) from landingpage into main
Reviewed-on: #5
2025-05-22 10:01:29 +02:00
f60719fa9e add: lp and navigation
Added... a lot of stuff
2025-05-22 09:59:27 +02:00
55fc3fe4e0 fix: textfield-padding 2025-05-21 13:28:57 +02:00
704fdaf27d Merge pull request 'add: sitemap + robots plugin' (#6) from sitemap into main
Reviewed-on: #6
2025-05-21 13:18:11 +02:00
d34096d3c8 add: sitemap + robots plugin 2025-05-20 11:22:47 +02:00
dd707bbf62 add: general structure for landingpage design 2025-05-15 14:12:40 +02:00
e591c276f5 fix: normalized and centralized sizes and padding 2025-05-14 10:58:19 +02:00
38cd37cf74 add: only delete swipe, edit on tap
Edit price cards on tap, only delete swipe exists, fixed dialogs, add ripple
2025-05-12 14:56:05 +02:00
1a5dd102e0 fix: more color fixes
Fix colors in dialogs
2025-05-12 12:58:46 +02:00
5e70236eac fix: header and subheader
Subheader claim always in now
2025-05-12 09:38:41 +02:00
cc54cb8112 fix: black text for h1
Added black text and centering for h1 text
2025-05-12 09:23:58 +02:00
236397fc9c Merge pull request 'fix: sticky-header-border-effect' (#4) from sticky-header-fix into main
Reviewed-on: #4
2025-05-12 09:17:00 +02:00
bb346773bd Merge pull request 'Added SEO Basics' (#3) from seo-basics into main
Reviewed-on: #3
Reviewed-by: Fiona Lena Urban <fiona@webfussel.de>
2025-05-12 09:16:44 +02:00
3083b99898 fix: sticky-header-border-effect 2025-05-11 21:43:04 +02:00
33713419f6 fix: (seo-)headings for pages + css 2025-05-11 14:05:01 +02:00
9746896a43 add: nuxt-seo-utils basic functions 2025-05-11 14:03:59 +02:00
c95aa68ec2 add: padding for Imp/Priv + fix: padding-defaults 2025-05-11 11:19:23 +02:00
1504b8bfe9 fix: a lot of design flaws
Info text when empty, new inputs, minimum height of content fixed
2025-05-11 11:07:19 +02:00
cef5330567 add: logo
ProPapier Logo, different claim
2025-05-10 21:10:32 +02:00
1bd69c9c97 add: subheader
Subheader for dynamic content
2025-05-10 18:36:42 +02:00
4b07ebb2ec add: search bar
Add new header and search bar
2025-05-10 18:07:18 +02:00
0aa495e05b add: reactive store
Add useLocalStorage from VueUse
2025-05-10 14:33:49 +02:00
9498911e7a add: reactive store
Add useLocalStorage from VueUse
2025-05-10 14:32:40 +02:00
27f051cf14 add: wording, vibration
Adapt wordings, add small vibration on edit and delete
2025-05-10 13:59:25 +02:00
3f398a0081 Merge pull request 'add: inputmode for better UI' (#2) from inputmode into main
Reviewed-on: #2
2025-05-10 13:50:08 +02:00
0133475e2a add: input mode
Add input mode for decimals on number fields
2025-05-10 13:48:39 +02:00
9a953980dc add: inputmode for better UI 2025-05-10 13:41:50 +02:00
4b22115159 remove: console.log
Removed useless console.log
2025-05-10 13:41:10 +02:00
70348d85ee Merge pull request 'add: new list layout design' (#1) from feature/list-layout into main
Reviewed-on: #1
2025-05-10 13:35:49 +02:00
11bcdce6cb add: new list layout design
New design, custom uuid, better handling of swipe
2025-05-10 13:32:28 +02:00
48 changed files with 2751 additions and 735 deletions

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

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

View file

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

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

View file

@ -0,0 +1,15 @@
.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

@ -0,0 +1,72 @@
.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);
}
}

View file

@ -1,52 +0,0 @@
.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,194 +1,191 @@
:root { :root {
--padding-default: 1rem; --color-success: #328104;
--padding-small: .5rem; --color-error: #a20606;
--radius-default: 3px; --color-blue-light: #d7e1f1;
--transition-default: 150ms; --color-blue: #05b0ff;
--color-blue-dark: #0266f2;
--color-blue-darkest: #013174;
--color-success: #328104; --color-darkest: #292929;
--color-error: #A20606; --color-dark: #404040;
--color-blue-light: #0DDCE7; --color-middle: #707070;
--color-blue: #05B0FF; --color-light: #e0e0e6;
--color-blue-dark: #0266F2; --color-lightest: #fafaff;
--color-blue-darkest: #013174;
--color-darkest: #292929; --color-green-light: #05ffc5;
--color-dark: #404040; --color-green: #02f276;
--color-middle: #707070; --color-green-dark: #09dc33;
--color-light: #E0E0E6; --color-green-darkest: #07b029;
--color-lightest: #FAFAFF; --color-green-darkest-most: #157c2a;
--color-green-light: #05FFC5; --color-main: var(--color-blue);
--color-green: #02F276; --color-main-lightest: var(--color-blue-light);
--color-green-dark: #09DC33; --color-main-light: var(--color-blue-light);
--color-green-darkest: #07B029; --color-main-dark: var(--color-blue-dark);
--color-green-darkest-most: #157C2A; --color-main-darkest: var(--color-blue-darkest);
--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-main: var(--color-blue); --color-text: var(--color-darkest);
--color-main-light: var(--color-blue-light); --color-text-invert: var(--color-lightest);
--color-main-dark: var(--color-blue-dark);
--color-main-darkest: var(--color-blue-darkest);
--color-accent: var(--color-green); --color-gradient-main: linear-gradient(to bottom right, var(--color-main), var(--color-main-light));
--color-accent-light: var(--color-green-light); --color-gradient-main-dark: linear-gradient(to bottom right, var(--color-main-darkest), var(--color-main-dark));
--color-accent-dark: var(--color-green-dark); --color-gradient-accent: linear-gradient(to bottom right, var(--color-accent), var(--color-accent-light));
--color-accent-darkest: var(--color-green-darkest); --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-gradient-main: linear-gradient(to bottom right, var(--color-main), var(--color-main-light)); --box-shadow-upper: 0 -3px 6px rgba(0, 0, 0, 0.16), 0 -3px 6px rgba(0, 0, 0, 0.23);
--color-gradient-main-dark: linear-gradient(to bottom right, var(--color-main-darkest), var(--color-main-dark)); --box-shadow-z2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
--color-gradient-accent: linear-gradient(to bottom right, var(--color-accent), var(--color-accent-light)); --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-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);
--box-shadow-upper: 0 -3px 6px rgba(0,0,0,0.16), 0 -3px 6px rgba(0,0,0,0.23); /* Font Sizes & Scaling Factor*/
--box-shadow-z2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); --scaling-factor: 1.25;
--box-shadow-inset: inset 0 3px 6px rgba(0,0,0,0.16), inset 0 3px 6px rgba(0,0,0,0.23); --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));
/* 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; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
html, html,
body { body {
height: 100%; height: 100%;
overflow-x: hidden; overflow-x: hidden;
font-family: sans-serif; 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);
} }
.dot { h1, h2, h3 {
--size: 10px; margin: var(--padding-xl) 0 var(--padding-default);
width: var(--size); text-wrap: balance;
height: var(--size); hyphens: auto;
top: 5px; font-size: var(--font-size-xl);
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;
}
} }
@keyframes pulse { h2 {
0% { margin: var(--padding-l) 0 var(--padding-default);
transform: scale(0.95); font-size: var(--font-size-l);
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);
}
100% { h3 {
transform: scale(0.95); margin: var(--padding-default) 0 var(--padding-default);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); font-size: var(--font-size-default);
} }
a:has(button) {
text-decoration: none;
} }
.card { .card {
overflow: hidden; overflow: hidden;
border-radius: var(--radius-default); border-radius: var(--radius-default);
box-shadow: var(--box-shadow-z2); 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 { .flex-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.flex-row { .flex-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
.text-white { .text-white {
color: var(--color-lightest); color: var(--color-text-invert);
} }
.bg-main { .bg-main {
background-color: var(--color-main); background-color: var(--color-main);
} }
.bg-main-hover:hover { .bg-main-hover:hover {
background-color: var(--color-main); background-color: var(--color-main);
} }
.bg-main-dark { .bg-main-dark {
background-color: var(--color-main-dark); background-color: var(--color-main-dark);
}
.bg-main-darkest {
background-color: var(--color-main-darkest);
} }
.bg-main-dark-hover:hover { .bg-main-dark-hover:hover {
background-color: var(--color-main-dark); background-color: var(--color-main-dark);
} }
.gap-default { .gap-default {
gap: 1rem; gap: var(--padding-default);
} }
.bg-blue { .bg-blue {
background: var(--color-blue); background: var(--color-blue);
} }
.bg-white { .bg-white {
background: var(--color-lightest); background: var(--color-lightest);
} }
.padding { .padding {
gap: 1rem; gap: var(--padding-default);
padding: var(--padding-default); padding: var(--padding-default);
} }
.padding-small { .padding-small {
gap: 1rem; gap: var(--padding-default);
padding: var(--padding-small); padding: var(--padding-s);
} }
dialog { .roboto-condensed {
top: 50%; font-family: "Roboto Condensed", sans-serif;
left: 50%; }
width: 100vw;
transform: translate(-50%, -50%);
border: none;
border-radius: var(--radius-default);
font-size: 1rem; .roboto {
font-family: "Roboto", sans-serif;
}
& header { .open-sans {
justify-content: space-between; font-family: "Open Sans", sans-serif;
align-items: center; }
}
& footer { .grow {
justify-content: space-between; flex-grow: 1;
} height: var(--padding-s);
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
} }

View file

@ -1,57 +1,88 @@
.Header { .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; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
display: flex;
align-items: center;
gap: var(--padding-default);
padding: var(--padding-default);
& strong { &:not(.lp) {
font-size: 2em; background: var(--color-main-darkest);
}
& span { &.lp {
color: var(--color-main-dark); 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);
top: 0;
padding-bottom: var(--padding-xxl);
}
& .logo {
height: 40px;
}
& .burger-button {
all: unset;
color: var(--color-lightest);
}
& header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: bold;
& a {
text-decoration: none;
color: var(--color-lightest);
}
& .header-text {
display: flex;
align-items: center;
gap: var(--padding-default);
& > div {
display: flex;
flex-direction: column;
}
& .big {
font-size: var(--font-size-xl);
}
& .small {
font-size: var(--font-size-s);
font-weight: 100;
}
} }
} }
& input[type="checkbox"] { &:not(.lp) {
display: none; &: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);
}
& input[type="checkbox"]:checked + nav { &:after {
translate: 0; right: 0;
} mask: radial-gradient(var(--radius-border) at 0 100%,#0000 98%,#000);
}
& nav, &:before {
& ul { left: 0;
gap: 1em; mask: radial-gradient(var(--radius-border) at 100% 100%,#0000 98%,#000);
}
& nav {
position: fixed;
padding: var(--padding-default);
translate: 100% 0;
width: 100vw;
right: 0;
top: 0;
height: 100dvh;
transition: 150ms ease-in-out;
background: var(--color-lightest);
font-size: 2em;
align-items: end;
z-index: 100;
}
& ul {
width: 100%;
align-items: center;
& li {
list-style: none;
} }
} }
} }

View file

@ -0,0 +1,40 @@
.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

@ -0,0 +1,69 @@
.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

@ -0,0 +1,80 @@
.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);
}

View file

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

View file

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

View file

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

View file

@ -9,7 +9,6 @@
</li> </li>
</ul> </ul>
<div class="bottom"> <div class="bottom">
<small>&copy; 2025 by webfussel</small>
<ul class="data-links"> <ul class="data-links">
<li v-for="dataLink in dataLinks"> <li v-for="dataLink in dataLinks">
<NuxtLink :to="dataLink.to"> <NuxtLink :to="dataLink.to">
@ -18,6 +17,8 @@
</li> </li>
</ul> </ul>
</div> </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> </footer>
</template> </template>

View file

@ -0,0 +1,25 @@
<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

@ -0,0 +1,30 @@
<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>

View file

@ -1,43 +0,0 @@
<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,31 +1,28 @@
<template> <template>
<header class="Header"> <div class="Header" :class="[type]">
<NuxtLink to="/"> <header class="roboto-condensed">
<strong><span>Pro</span>Papier</strong> <NuxtLink class="header-text" to="/">
</NuxtLink> <img class="logo" src="/img/propapier.svg" alt="ProPapier logo" />
<label for="burger_nav_toggle" v-if="available"> <div>
<Icon name="solar:hamburger-menu-broken" size="2em" /> <span class="big">ProPapier</span>
</label> <span class="small">Vergleichen. Schnell. Unkompliziert.</span>
<input type="checkbox" id="burger_nav_toggle" v-if="available" /> </div>
<nav class="flex-col" v-if="available"> </NuxtLink>
<label for="burger_nav_toggle"> <button class="burger-button" @click="open()">
<Icon name="solar:close-circle-broken" /> <Icon name="uil:bars" size="2em" mode="svg" />
</label> </button>
<ul class="flex-col"> </header>
<li>Home</li> <div id="subheader" />
<li>Übersicht</li> </div>
</ul>
</nav>
</header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const available = false type Props = {
</script> type ?: 'lp'
}
<style scoped> defineProps<Props>()
header a {
text-decoration: none; const nav = useNavigation()
color: var(--color-black); const open = () => { nav.showNavigation() }
} </script>
</style>

View file

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

View file

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

View file

@ -0,0 +1,51 @@
<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

@ -0,0 +1,24 @@
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
}
}

11
app/layouts/default.vue Executable file
View file

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

View file

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

View file

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

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

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

View file

@ -1,13 +1,10 @@
<template> <template>
<section class="flex-col gap-default content full"> <section class="Legal flex-col gap-default content full">
<h3> <h1>
Datenschutzerklärung Datenschutzerklärung
</h3> </h1>
<p> <p>
Wir senden keinerlei Daten an Drittanbieter. Wir sammeln anonyme Daten zum Erstellen von Statistiken über die Anzahl der Besuche auf unserer Seite, um herauszufinden, wie viel Pflegeaufwand und Rechenleistung benötigt wird.
</p>
<p>
Wir verwerten keinerlei Daten.
</p> </p>
<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. Alle persistierten Daten befinden sich ausschließlich auf Ihrem Endgerät im sogenannten "localStorage" und werden ausschließlich auf Ihrem Gerät verarbeitet.

151
app/pages/rechner.vue Executable file
View file

@ -0,0 +1,151 @@
<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,3 +1,5 @@
export const replaceComma = (value: string | number) => `${value}`.replace(',', '.') 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 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

7
app/utils/uuid.ts Normal file
View file

@ -0,0 +1,7 @@
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,4 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config const description = 'Du zahlst zuviel für\'s Papier? Vergleiche schnell und unkompliziert die Preise für Toiletten-, Küchen- und andere Papier hier.'
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2024-11-01', compatibilityDate: '2024-11-01',
devtools: { enabled: false }, devtools: { enabled: false },
@ -16,22 +17,95 @@ 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: { routeRules: {
'/': { prerender: true }, '/': { prerender: true },
'/imprint': { prerender: true }, '/imprint': { prerender: true },
'/privacy': { prerender: true }, '/privacy': { prerender: true },
}, },
modules: ['@nuxt/icon', '@vueuse/nuxt', '@nuxtjs/device'], modules: [
'@nuxt/icon',
'@vueuse/nuxt',
'@nuxtjs/device',
'@nuxt/fonts',
'nuxt-seo-utils',
'nuxt-ripple',
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'@nuxtjs/plausible'
],
css : [ css : [
'./app/assets/styles/general.css', './app/assets/styles/general.css',
'./app/assets/styles/header.css', './app/assets/styles/header.css',
'./app/assets/styles/navigation.css',
'./app/assets/styles/footer.css', './app/assets/styles/footer.css',
'./app/assets/styles/button.css', './app/assets/styles/button.css',
'./app/assets/styles/buttonGroup.css', './app/assets/styles/buttonGroup.css',
'./app/assets/styles/priceCard.css', './app/assets/styles/priceCard.css',
'./app/assets/styles/formInput.css', './app/assets/styles/timelineCard.css',
'./app/assets/styles/form/textfield.css',
'./app/assets/styles/form/search.css',
'./app/assets/styles/toolbar.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',
},
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'],
},
}) })

1519
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,17 +7,23 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"dev:expose": "nuxt dev --host", "dev:expose": "nuxt dev --host",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "npx serve .output/public",
"prepare": "nuxt prepare", "prepare": "nuxt prepare",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@iconify-json/simple-icons": "^1.2.32", "@iconify-json/simple-icons": "^1.2.32",
"@iconify-json/uil": "^1.2.3", "@iconify-json/uil": "^1.2.3",
"@nuxt/fonts": "^0.11.3",
"@nuxt/icon": "^1.10.3", "@nuxt/icon": "^1.10.3",
"@nuxtjs/device": "^3.2.4", "@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", "@vueuse/nuxt": "^13.1.0",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"nuxt-ripple": "^0.0.8",
"nuxt-seo-utils": "^7.0.11",
"vue": "latest", "vue": "latest",
"vue-router": "latest" "vue-router": "latest"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

1
public/favicon.svg Executable file
View file

@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/img/hero-image.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/img/og.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

1
public/img/propapier.svg Executable file
View file

@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

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

8
shared/PriceCard.ts Normal file
View file

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

11
shared/TimelineCard.ts Normal file
View file

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