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

View file

@ -1,9 +1,6 @@
.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);
@ -12,8 +9,8 @@
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
padding: .5rem;
gap: var(--padding-xs);
padding: var(--padding-s);
flex-grow: 1;
background: var(--background);
color: var(--color);
@ -24,13 +21,5 @@
--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

@ -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 {
position: relative;
background: var(--color-darkest);
padding: 1rem;
padding: var(--padding-default);
z-index: 100;
& h4 {
color: var(--color-lightest);
text-align: center;
margin-bottom: 1rem;
margin-bottom: var(--padding-default);
}
& .bottom {
display: flex;
gap: 1rem;
justify-content: space-between;
justify-content: center;
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: 1.5rem;
font-size: var(--font-size-xl);
justify-content: center;
margin-bottom: 2rem;
margin-bottom: var(--padding-xl);
}
& .data-links {
justify-content: flex-end;
font-size: .8rem;
font-size: var(--font-size-s);
}
& ul {
list-style: none;
display: flex;
align-items: center;
gap: 1rem;
gap: var(--padding-default);
& a {
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 {
--padding-default: 1rem;
--padding-small: .5rem;
--radius-default: 3px;
--transition-default: 150ms;
--color-success: #328104;
--color-error: #a20606;
--color-blue-light: #d7e1f1;
--color-blue: #05b0ff;
--color-blue-dark: #0266f2;
--color-blue-darkest: #013174;
--color-success: #328104;
--color-error: #A20606;
--color-blue-light: #0DDCE7;
--color-blue: #05B0FF;
--color-blue-dark: #0266F2;
--color-blue-darkest: #013174;
--color-darkest: #292929;
--color-dark: #404040;
--color-middle: #707070;
--color-light: #e0e0e6;
--color-lightest: #fafaff;
--color-darkest: #292929;
--color-dark: #404040;
--color-middle: #707070;
--color-light: #E0E0E6;
--color-lightest: #FAFAFF;
--color-green-light: #05ffc5;
--color-green: #02f276;
--color-green-dark: #09dc33;
--color-green-darkest: #07b029;
--color-green-darkest-most: #157c2a;
--color-green-light: #05FFC5;
--color-green: #02F276;
--color-green-dark: #09DC33;
--color-green-darkest: #07B029;
--color-green-darkest-most: #157C2A;
--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-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-main-light: var(--color-blue-light);
--color-main-dark: var(--color-blue-dark);
--color-main-darkest: var(--color-blue-darkest);
--color-text: var(--color-darkest);
--color-text-invert: var(--color-lightest);
--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-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-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);
--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);
--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);
/* 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));
/* 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;
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);
}
.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;
}
h1, h2, h3 {
margin: var(--padding-xl) 0 var(--padding-default);
text-wrap: balance;
hyphens: auto;
font-size: var(--font-size-xl);
}
@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);
}
h2 {
margin: var(--padding-l) 0 var(--padding-default);
font-size: var(--font-size-l);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 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;
}
.card {
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;
overflow: hidden;
border-radius: var(--radius-default);
box-shadow: var(--box-shadow-z2);
}
.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-lightest);
color: var(--color-text-invert);
}
.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);
background-color: var(--color-main-dark);
}
.bg-main-darkest {
background-color: var(--color-main-darkest);
}
.bg-main-dark-hover:hover {
background-color: var(--color-main-dark);
background-color: var(--color-main-dark);
}
.gap-default {
gap: 1rem;
gap: var(--padding-default);
}
.bg-blue {
background: var(--color-blue);
background: var(--color-blue);
}
.bg-white {
background: var(--color-lightest);
background: var(--color-lightest);
}
.padding {
gap: 1rem;
padding: var(--padding-default);
gap: var(--padding-default);
padding: var(--padding-default);
}
.padding-small {
gap: 1rem;
padding: var(--padding-small);
gap: var(--padding-default);
padding: var(--padding-s);
}
dialog {
top: 50%;
left: 50%;
width: 100vw;
transform: translate(-50%, -50%);
border: none;
border-radius: var(--radius-default);
.roboto-condensed {
font-family: "Roboto Condensed", sans-serif;
}
font-size: 1rem;
.roboto {
font-family: "Roboto", sans-serif;
}
& header {
justify-content: space-between;
align-items: center;
}
.open-sans {
font-family: "Open Sans", sans-serif;
}
& footer {
justify-content: space-between;
}
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.grow {
flex-grow: 1;
height: var(--padding-s);
}

View file

@ -1,57 +1,88 @@
.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);
& strong {
font-size: 2em;
&:not(.lp) {
background: var(--color-main-darkest);
}
& 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);
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"] {
display: none;
}
&: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);
}
& input[type="checkbox"]:checked + nav {
translate: 0;
}
&:after {
right: 0;
mask: radial-gradient(var(--radius-border) at 0 100%,#0000 98%,#000);
}
& nav,
& ul {
gap: 1em;
}
& 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;
&:before {
left: 0;
mask: radial-gradient(var(--radius-border) at 100% 100%,#0000 98%,#000);
}
}
}

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

View file

@ -1,24 +1,24 @@
<template>
<dialog
ref="dialog"
closedby="any"
closedby="none"
>
<form method="dialog">
<header class="flex-row padding">
<form method="dialog" class="wrapper" ref="wrapper">
<header class="flex-row">
Wirklich löschen?
<PpButton class="round text">
<Icon name="uil:times" mode="svg" />
</PpButton>
</header>
<main>
<div class="padding flex-col">
<div class="flex-col">
<p>Bist du dir sicher, dass du diesen Eintrag löschen möchtest?</p>
</div>
</main>
<footer class="flex-row padding">
<PpButton class="text">
<span>Abbrechen</span>
</PpButton>
<footer class="flex-row">
<PpButton class="text">
<span>Abbrechen</span>
</PpButton>
<PpButton class="danger raised" @click="$emit('delete')">
<span>Löschen</span>
</PpButton>
@ -35,5 +35,11 @@ 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,7 +9,6 @@
</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">
@ -18,6 +17,8 @@
</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

@ -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>
<header class="Header">
<NuxtLink to="/">
<strong><span>Pro</span>Papier</strong>
</NuxtLink>
<label for="burger_nav_toggle" v-if="available">
<Icon name="solar:hamburger-menu-broken" size="2em" />
</label>
<input type="checkbox" id="burger_nav_toggle" v-if="available" />
<nav class="flex-col" v-if="available">
<label for="burger_nav_toggle">
<Icon name="solar:close-circle-broken" />
</label>
<ul class="flex-col">
<li>Home</li>
<li>Übersicht</li>
</ul>
</nav>
</header>
<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>
</template>
<script setup lang="ts">
const available = false
</script>
type Props = {
type ?: 'lp'
}
<style scoped>
header a {
text-decoration: none;
color: var(--color-black);
}
</style>
defineProps<Props>()
const nav = useNavigation()
const open = () => { nav.showNavigation() }
</script>

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

View file

@ -1,96 +1,134 @@
<template>
<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>
<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>
<main v-if="currentCard">
<div class="padding flex-col">
<div class="flex-col gap-default">
<div class="flex-row gap-default">
<PpFormInput
<PpFormTextField
v-model="currentCard.name"
id="card_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.' : ''"
@input="validFields.name = true"
/>
<PpFormInput
<PpFormTextField
v-model="currentCard.price"
id="card_price"
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.' : ''"
@input="validFields.price = true"
/>
</div>
<div class="flex-row gap-default">
<PpFormInput
<PpFormTextField
v-model="currentCard.roles"
id="card_roles"
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.' : ''"
@input="validFields.roles = true"
/>
<PpFormInput
<PpFormTextField
v-model="currentCard.sheets"
id="card_sheets"
label="Blätter"
:class="{'error': !validFields.sheets }"
label="Blatt"
placeholder="150"
icon="uil:file-landscape"
mode="decimal"
:class="{ error: !validFields.sheets }"
:message="!validFields.sheets ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.sheets = true"
/>
<PpFormInput
<PpFormTextField
v-model="currentCard.layers"
id="card_layers"
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.' : ''"
@input="validFields.layers = true"
/>
</div>
</div>
</main>
<footer class="flex-row padding">
<footer class="flex-row">
<form method="dialog">
<PpButton class="danger text">
<span>Abbrechen</span>
</PpButton>
</form>
<PpButton class="raised" @click="validate">
<span>{{ cardLabel }}</span>
<span>{{ currentCardIndex > -1 ? 'Übernehmen' : 'Hinzufügen' }}</span>
</PpButton>
</footer>
</div>
</dialog>
</template>
<script setup lang="ts">
import type { Card } from '../../../shared/Card'
import type { PriceCard } from '../../../shared/PriceCard'
type Props = {
currentCardIndex : number
currentCard ?: Card
currentCardIndex: number
currentCard?: PriceCard
}
const { currentCardIndex, currentCard } = defineProps<Props>()
const emit = defineEmits(['update'])
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 = () => {
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)
}
@ -103,7 +141,7 @@ const validFields = reactive({
})
const validate = () => {
if (!currentCard) { return }
if (!currentCard) return
validFields.name = currentCard.name.length > 0
validFields.price = checkPrice()
@ -111,7 +149,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()
}
@ -124,6 +162,9 @@ onMounted(() => {
validFields.roles = true
validFields.sheets = 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>
<div>
<section class="Imp flex-col gap-default content full">
<section class="Legal flex-col gap-default content full">
<h1>Impressum</h1>
<div>
<p>
Fiona Lena Urban<br/>

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

@ -1,185 +1,97 @@
<template>
<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()"
/>
<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>
</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 { Card } from '../../shared/Card'
import type { Button } from '../../shared/ButtonGroup'
import { PpPriceCardDialog, PpDeleteDialog } from '#components'
import type { TimelineCard } from '../../shared/TimelineCard'
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,
definePageMeta({
layout: 'landingpage'
})
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[]>([
const timeline : TimelineCard[] = [
{
label: 'Rollen',
icon: 'uil:toilet-paper',
icon: 'uil:chart-bar',
title: 'Mehr Vergleiche',
description: 'Zusätzliche Kategorien für Taschentücher und Küchenrolle',
state: {
value: 'inProgress',
}
},
{
label: 'Blatt',
icon: 'uil:file-landscape',
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: 'Lagen',
icon: 'uil:layer-group',
icon: 'uil:qrcode-scan',
title: 'Barcode Scan',
description: 'Ganz einfach Barcode Scannen und Produkt direkt zum Rechner hinzufügen',
state: {
value: 'planned',
message: '2025',
}
},
])
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>
{
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>

View file

@ -1,13 +1,10 @@
<template>
<section class="flex-col gap-default content full">
<h3>
<section class="Legal flex-col gap-default content full">
<h1>
Datenschutzerklärung
</h3>
</h1>
<p>
Wir senden keinerlei Daten an Drittanbieter.
</p>
<p>
Wir verwerten keinerlei Daten.
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>
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 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({
compatibilityDate: '2024-11-01',
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: {
'/': { prerender: true },
'/imprint': { 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 : [
'./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/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/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:expose": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "nuxt preview",
"preview": "npx serve .output/public",
"prepare": "nuxt prepare",
"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",
"vue": "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
}
}