feat: added product page

This commit is contained in:
Tobias Klemp
2025-11-29 22:27:37 +01:00
parent 90280fd436
commit 6d03984e21
85 changed files with 1869 additions and 436 deletions

View File

@@ -1,9 +1,27 @@
<script lang="ts">
import '../app.css'
import favicon from '$lib/assets/favicon.svg'
import * as r from '$lib/paraglide/runtime'
import {
TolgeeProvider,
Tolgee,
DevTools,
FormatSimple,
} from '@tolgee/svelte'
import { TopBar } from '$lib/components/ui/topBar'
r.setLocale('de-de')
const tolgee = Tolgee()
.use(DevTools())
.use(FormatSimple())
.init({
language: 'de',
// for development
apiUrl: import.meta.env.VITE_TOLGEE_API_URL,
apiKey: import.meta.env.VITE_TOLGEE_API_KEY,
// for production
staticData: {},
})
let { children } = $props()
</script>
@@ -12,8 +30,15 @@
<link rel="icon" href={favicon} />
</svelte:head>
<div class="flex justify-center items-center">
<div class="max-w-full lg:max-w-5xl pt-4">
{@render children?.()}
<TolgeeProvider {tolgee}>
<TopBar />
<div class="box-content">
<div
class="bg-gray-50 dark:bg-gray-950 transition-colors flex justify-center min-h-screen"
>
<div class="max-w-full px-4 lg:max-w-6xl box-content">
{@render children?.()}
</div>
</div>
</div>
</div>
</TolgeeProvider>

View File

@@ -13,6 +13,9 @@
import CapacityVarations from '$lib/components/ui/variations/CapacityVarations.svelte'
import ConditionVariations from '$lib/components/ui/variations/ConditionVariations.svelte'
import { SpecCard } from '$lib/components/ui/specCard'
import ImageCarousel from '$lib/components/ui/image-carousel/ImageCarousel.svelte'
import type { ImageCarouselItem } from '$lib/components/ui/image-carousel'
import { ShopList, type TShopListItem } from '$lib/components/ui/shopList'
async function crawlClevertronik() {
const response = await fetch('/crawl', { method: 'POST' })
@@ -30,11 +33,28 @@
description:
'Das iPhone 15 bietet modernste Technologie zu einem nachhaltigen Preis. Professionell aufbereitet und vollständig getestet.',
images: [
'https://images.unsplash.com/photo-1702184117235-56002cb13663?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMGJsYWNrfGVufDF8fHx8MTc2MzI5NDI5OHww&ixlib=rb-4.1.0&q=80&w=1080',
'https://images.unsplash.com/photo-1710023038502-ba80a70a9f53?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMHByb3xlbnwxfHx8fDE3NjMzMjEyMTF8MA&ixlib=rb-4.1.0&q=80&w=1080',
'https://images.unsplash.com/photo-1761907174062-c8baf8b7edb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbWFydHBob25lJTIwbW9kZXJufGVufDF8fHx8MTc2MzM2OTEyOXww&ixlib=rb-4.1.0&q=80&w=1080',
'https://images.unsplash.com/photo-1567985944845-cb4d1e598822?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjBjYW1lcmF8ZW58MXx8fHwxNzYzMzkxOTUyfDA&ixlib=rb-4.1.0&q=80&w=1080',
],
{
image:
'https://images.unsplash.com/photo-1702184117235-56002cb13663?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMGJsYWNrfGVufDF8fHx8MTc2MzI5NDI5OHww&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
{
image:
'https://images.unsplash.com/photo-1710023038502-ba80a70a9f53?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMHByb3xlbnwxfHx8fDE3NjMzMjEyMTF8MA&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
{
image:
'https://images.unsplash.com/photo-1761907174062-c8baf8b7edb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbWFydHBob25lJTIwbW9kZXJufGVufDF8fHx8MTc2MzM2OTEyOXww&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
{
image:
'https://images.unsplash.com/photo-1567985944845-cb4d1e598822?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjBjYW1lcmF8ZW58MXx8fHwxNzYzMzkxOTUyfDA&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
] satisfies ImageCarouselItem[],
badges: [
{
text: 'Refurbished',
@@ -159,23 +179,98 @@
},
] satisfies ConditionProductVariation[],
},
shops: [
{
name: 'Refurbed',
badges: [
{
name: 'top_shop',
variant: 'default',
},
{
name: 'best_price',
icon: 'TrendingDown',
variant: 'default',
},
],
currency: '€',
price: 585,
rating: {
score: 4.8,
amount: 1247,
},
shipping: {
shippingCost: 0,
shippingTime: '1-2',
},
warrantyInMonths: 24,
linkUrl: 'https://refurbed.com',
},
{
name: 'GreenPhone',
badges: [],
currency: '€',
price: 600,
rating: {
score: 4.6,
amount: 856,
},
shipping: {
shippingCost: 4.99,
shippingTime: '2-3',
},
warrantyInMonths: 12,
linkUrl: 'https://google.com',
},
{
name: 'CleverTronik',
badges: [
{
name: 'verified',
variant: 'secondary',
},
],
currency: '€',
price: 610,
rating: {
score: 4.7,
amount: 2103,
},
shipping: {
shippingCost: 0,
shippingTime: '1-3',
},
warrantyInMonths: 18,
linkUrl: 'https://clevertronik.de',
},
{
name: 'Swappie',
badges: [
{
name: 'goofie',
variant: 'secondary',
},
],
currency: '€',
price: 710,
rating: {
score: 4.5,
amount: 634,
},
shipping: {
shippingCost: 5.99,
shippingTime: '3-5',
},
warrantyInMonths: 12,
linkUrl: 'https://clevertronik.de',
},
] satisfies TShopListItem[],
}
</script>
<div>
<div class="flex lg:flex-row flex-col gap-6">
<Carousel.Root class="max-w-xs mx-12">
<Carousel.Previous />
<Carousel.Content>
{#each product.images as image}
<Carousel.Item>
<img class="p-1 rounded-lg" src={image} alt="" />
</Carousel.Item>
{/each}
</Carousel.Content>
<Carousel.Next />
</Carousel.Root>
<div class="grid gap-8">
<div class="grid md:grid-cols-2 gap-8">
<ImageCarousel buttonPosition="contained" items={product.images} preview />
<div>
<div class="flex flex-col gap-y-1">
<div class="flex flex-row gap-2">
@@ -192,8 +287,13 @@
<CapacityVarations variations={product.variations.capacity} />
<ConditionVariations variations={product.variations.condition} />
</div>
</div>
</div>
<div class="flex gap-8 md:flex-row flex-col">
<div class="md:basis-2/3"><ShopList items={product.shops} /></div>
<div class="md:basis-1/3">
<SpecCard specifications={product.specifications} />
</div>
</div>
<button onclick={crawlClevertronik}>lets do it</button>
</div>

View File

@@ -1,2 +0,0 @@
<a href="/demo/paraglide">paraglide</a>
<a href="/demo/lucia">lucia</a>

View File

@@ -1,31 +0,0 @@
import * as auth from '$lib/server/auth'
import { fail, redirect } from '@sveltejs/kit'
import { getRequestEvent } from '$app/server'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async () => {
const user = requireLogin()
return { user }
}
export const actions: Actions = {
logout: async (event) => {
if (!event.locals.session) {
return fail(401)
}
await auth.invalidateSession(event.locals.session.id)
auth.deleteSessionTokenCookie(event)
return redirect(302, '/demo/lucia/login')
}
}
function requireLogin() {
const { locals } = getRequestEvent()
if (!locals.user) {
return redirect(302, '/demo/lucia/login')
}
return locals.user
}

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms'
import type { PageServerData } from './$types'
let { data }: { data: PageServerData } = $props()
</script>
<h1>Hi, {data.user.username}!</h1>
<p>Your user ID is {data.user.id}.</p>
<form method="post" action="?/logout" use:enhance>
<button>Sign out</button>
</form>

View File

@@ -1,107 +0,0 @@
import { hash, verify } from '@node-rs/argon2'
import { encodeBase32LowerCase } from '@oslojs/encoding'
import { fail, redirect } from '@sveltejs/kit'
import { eq } from 'drizzle-orm'
import * as auth from '$lib/server/auth'
import { db } from '$lib/server/db'
import { user } from '$lib/server/db/user'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async (event) => {
if (event.locals.user) {
return redirect(302, '/demo/lucia')
}
return {}
}
export const actions: Actions = {
login: async (event) => {
const formData = await event.request.formData()
const username = formData.get('username')
const password = formData.get('password')
if (!validateUsername(username)) {
return fail(400, {
message: 'Invalid username (min 3, max 31 characters, alphanumeric only)'
})
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password (min 6, max 255 characters)' })
}
const results = await db.select().from(user).where(eq(user.username, username))
const existingUser = results.at(0)
if (!existingUser) {
return fail(400, { message: 'Incorrect username or password' })
}
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
})
if (!validPassword) {
return fail(400, { message: 'Incorrect username or password' })
}
const sessionToken = auth.generateSessionToken()
const session = await auth.createSession(sessionToken, existingUser.id)
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt)
return redirect(302, '/demo/lucia')
},
register: async (event) => {
const formData = await event.request.formData()
const username = formData.get('username')
const password = formData.get('password')
if (!validateUsername(username)) {
return fail(400, { message: 'Invalid username' })
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password' })
}
const userId = generateUserId()
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
})
try {
await db.insert(user).values({ id: userId, username, passwordHash })
const sessionToken = auth.generateSessionToken()
const session = await auth.createSession(sessionToken, userId)
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt)
} catch {
return fail(500, { message: 'An error has occurred' })
}
return redirect(302, '/demo/lucia')
}
}
function generateUserId() {
// ID with 120 bits of entropy, or about the same as UUID v4.
const bytes = crypto.getRandomValues(new Uint8Array(15))
const id = encodeBase32LowerCase(bytes)
return id
}
function validateUsername(username: unknown): username is string {
return (
typeof username === 'string' &&
username.length >= 3 &&
username.length <= 31 &&
/^[a-z0-9_-]+$/.test(username)
)
}
function validatePassword(password: unknown): password is string {
return typeof password === 'string' && password.length >= 6 && password.length <= 255
}

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms'
import type { ActionData } from './$types'
let { form }: { form: ActionData } = $props()
</script>
<h1>Login/Register</h1>
<form method="post" action="?/login" use:enhance>
<label>
Username
<input
name="username"
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
<label>
Password
<input
type="password"
name="password"
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Login</button
>
<button
formaction="?/register"
class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Register</button
>
</form>
<p style="color: red">{form?.message ?? ''}</p>

View File

@@ -1,17 +0,0 @@
<script lang="ts">
import { setLocale } from '$lib/paraglide/runtime'
import { m } from '$lib/paraglide/messages.js'
</script>
<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>
<div>
<button onclick={() => setLocale('en')}>en</button>
<button onclick={() => setLocale('es')}>es</button>
<button onclick={() => setLocale('de-de')}>de-de</button>
</div>
<p>
If you use VSCode, install the <a
href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension"
target="_blank">Sherlock i18n extension</a
> for a better i18n experience.
</p>