feat: added product page
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<a href="/demo/paraglide">paraglide</a>
|
||||
<a href="/demo/lucia">lucia</a>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user