Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Vinted Public API Configuration
# The user ID from the wardrobe URL (e.g., https://www.vinted.es/member/3140238239)
VINTED_USER_ID=your_vinted_user_id_here

# Vinted domain (default: www.vinted.es)
VINTED_DOMAIN=www.vinted.es
34 changes: 34 additions & 0 deletions app/components/content/shop-product.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
const props = defineProps<{
id: number
}>()

const { data } = await useAsyncData(
'shop-products',
() => $fetch('/api/shop/products'),
)

function getProductImage(product: NonNullable<typeof data.value>['products'][number]): string {
const photo = product.photos[0]
if (!photo)
return ''
const thumb = photo.thumbnails.find(t => t.type === 'thumb310x430')
return thumb?.url || photo.url
}

const product = computed(() =>
data.value?.products.find(p => p.id === props.id) ?? null,
)
</script>

<template>
<ShopProductCard
v-if="product"
:id="product.id"
:title="product.title"
:price="product.price"
:currency="product.currency"
:size="product.size"
:image-url="getProductImage(product)"
/>
</template>
68 changes: 68 additions & 0 deletions app/components/content/shop-products.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { Product } from '~~/server/api/shop/products.get'

const { paginable, perPage = 20 } = defineProps<{
paginable?: boolean
perPage?: number
}>()

const page = ref(1)

const key = computed(() => `shop-products-page-${page.value}`)

const { data, pending } = await useAsyncData(
key,
() => $fetch('/api/shop/products', {
params: {
page: page.value,
limit: perPage,
},
}),
{
watch: [page],
},
)

const isLastPage = computed(() => {
if (!data.value)
return false
const total = data.value.pagination.totalPages
return data.value.pagination.currentPage >= total
})

const products = computed<Product[]>(prev => [...(prev || []), ...(data.value?.products || [])])

function getProductImage(product: NonNullable<typeof data.value>['products'][number]): string {
const photo = product.photos[0]
if (!photo)
return ''
const thumb = photo.thumbnails.find(t => t.type === 'thumb310x430')
return thumb?.url || photo.url
}
</script>

<template>
<div class="shop-products">
<template v-if="products.length">
<ShopProductCard
v-for="product in products" :id="product.id" :key="product.id" :title="product.title"
:price="product.price" :currency="product.currency" :size="product.size"
:image-url="getProductImage(product)"
/>
</template>
</div>
<div class="mt-6 mb-6 w-full text-center">
<div v-if="pending" class="text-center text-primary">
Loading products...
</div>
<a v-if="paginable && !pending && !isLastPage" href="#" class="cursor-default" @click.prevent="page++">
Load more
</a>
</div>
</template>

<style lang="scss" scoped>
.shop-products {
@apply grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3;
}
</style>
6 changes: 3 additions & 3 deletions app/components/menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ watch(isOutside, (outside) => {
<span>Me</span>
<img src="../assets/images/menu/me.png" alt="me" draggable="false">
</div>
<div class="item" @click="push('/blog')">
<span>Blog</span>
<img src="../assets/images/menu/shop.png" alt="blog" draggable="false">
<div class="item" @click="push('/shop')">
<span>Shop</span>
<img src="../assets/images/menu/shop.png" alt="shop" draggable="false">
</div>
</div>
</div>
Expand Down
74 changes: 74 additions & 0 deletions app/components/shop-product-card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import IconArrow from '~icons/ic/twotone-arrow-outward'

const props = defineProps<{
id: number
title: string
price: string
currency: string
size?: string
imageUrl?: string
}>()

function formatPrice(amount: string, currency: string): string {
const num = Number.parseFloat(amount)
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency,
}).format(num)
}
</script>

<template>
<NuxtLink :to="`/shop/${props.id}`" class="product-card group">
<div class="cover">
<IconArrow class="arrow" />
<img v-if="props.imageUrl" :src="props.imageUrl" :alt="props.title">
</div>
<div class="info">
<span class="title">{{ props.title }}</span>
<span class="price">{{ formatPrice(props.price, props.currency) }}</span>
</div>
</NuxtLink>
</template>

<style lang="scss" scoped>
.product-card {
&:hover .cover {
.arrow {
@apply scale-100 opacity-100;
}
}

.cover {
@apply overflow-hidden bg-primary-dimmed/20 relative rounded-xl;
aspect-ratio: 1 / 1;

@screen sm {
aspect-ratio: 2 / 3;
}

.arrow {
@apply absolute top-2 right-2 text-3xl text-white mix-blend-difference;
@apply transition-all duration-300 ease-in-out;
@apply origin-top-right sm:scale-75 sm:opacity-0;
}

img {
@apply object-cover w-full h-full;
}
}

.info {
@apply mt-1 px-0.5;
}

.title {
@apply block font-inconsolata text-primary text-xs sm:text-sm line-clamp-1;
}

.price {
@apply block font-inconsolata text-primary font-bold text-sm sm:text-base;
}
}
</style>
3 changes: 1 addition & 2 deletions app/pages/projects/[slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ useSeoMeta(data.value.seo)
</script>

<template>
<div :class="{ layout: true, small: data?.size === 'small', full: data?.size === 'full' }">
<div class="layout" :class="{ small: data?.size === 'small', full: data?.size === 'full' }">
<ContentRenderer v-if="data" :value="data" class="flex-1 relative" />
</div>
</template>
Expand All @@ -26,4 +26,3 @@ useSeoMeta(data.value.seo)
@apply max-w-full mx-auto flex-1 flex;
}
</style>

177 changes: 177 additions & 0 deletions app/pages/shop/[id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<script setup lang="ts">
import IconArrow from '~icons/ic/twotone-arrow-outward'

const route = useRoute()
const productId = Number(route.params.id)

const { data, status, error } = await useAsyncData(
'shop-products:full',
() => $fetch('/api/shop/products', {
params: {
limit: 999,
},
}),
)

const product = computed(() =>
data.value?.products.find(p => p.id === productId) ?? null,
)

const { data: productContent } = await useAsyncData(`product-content-${route.params.id}`, queryCollection('products').path(route.path).first)

useSeoMeta({
title: product.value ? `${product.value.title}` : 'Product page',
description: product.value ? `Check out ${product.value.title} in my shop!` : 'Browse products in my shop.',
})

if (!error.value && status.value === 'success' && !product.value) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found',
})
}

function getImageUrl(photo: NonNullable<typeof product.value>['photos'][number]): string {
const thumb = photo.thumbnails.find(t => t.type === 'thumb428x624')
return thumb?.url || photo.url
}

function formatPrice(amount: string, currency: string): string {
const num = Number.parseFloat(amount)
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency,
}).format(num)
}
</script>

<template>
<div v-if="status === 'pending'" class="text-center text-primary">
Loading product...
</div>

<div v-else-if="error" class="text-center text-red-500">
Failed to load product.
</div>

<div v-else-if="product" class="layout">
<div class="photos">
<div v-for="photo in product.photos" :key="photo.url" class="slide">
<img :src="getImageUrl(photo)" :alt="product.title">
</div>
</div>

<div class="info">
<div class="info-inner">
<NuxtLink to="/shop" class="back">
← Back to shop
</NuxtLink>

<h1>{{ product.title }}</h1>

<div class="tags">
<span>{{ product.status }}</span>
<span v-if="product.size">{{ product.size }}</span>
<span v-if="product.isReserved" class="reserved">Reserved</span>
</div>

<div v-if="productContent" class="product-description prose my-10">
<ContentRenderer :value="productContent" />
</div>

<div class="price-block">
<span class="price">{{ formatPrice(product.price, product.currency) }}</span>
<span class="total">incl. protection: {{ formatPrice(product.totalPrice, product.currency) }}</span>
</div>

<div class="stats">
<span>{{ product.favouriteCount }} likes</span>
</div>

<a :href="product.url" target="_blank" rel="noopener noreferrer" class="buy-link">
Vinted
<IconArrow class="inline text-lg" />
</a>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
.layout {
@apply relative sm:grid sm:grid-cols-12 gap-5 min-w-0 w-full;
@apply max-w-screen-xl mx-auto flex-1;

.photos {
@apply sm:col-span-8 px-3 -mx-3 sm:px-0 sm:mx-0;

// Mobile: horizontal scroll with snap
@apply flex flex-row overflow-x-auto snap-x snap-mandatory gap-2;
@apply sm:flex-col sm:overflow-x-visible;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;

&::-webkit-scrollbar {
display: none;
}

.slide {
@apply flex-shrink-0 w-[85vw] sm:w-auto;
scroll-snap-align: center;

img {
@apply rounded-md w-full;
}
}
}

.info {
@apply sm:col-span-4;
@apply px-3 pt-4 sm:px-0 sm:pt-0;

.info-inner {
@apply sm:sticky sm:top-8;
}
}
}

.back {
@apply inline-flex items-center gap-1 text-primary-dimmed hover:text-primary font-inconsolata text-sm mb-4 transition-colors;
}

h1 {
@apply font-ppmondwest text-primary text-lg sm:text-2xl mix-blend-hard-light sm:mb-3;
}

.tags {
@apply flex flex-wrap items-center gap-2 mb-4;

span {
@apply inline-block px-2 py-0.5 rounded-full text-xs font-inconsolata bg-primary/10 text-primary;

&.reserved {
@apply bg-yellow-100 text-yellow-800;
}
}
}

.price-block {
@apply sm:mb-4;

.price {
@apply font-inconsolata text-primary text-xl sm:text-2xl font-bold;
}

.total {
@apply block font-inconsolata text-primary/50 text-xs;
}
}

.stats {
@apply flex items-center gap-3 mb-4 text-xs text-primary/50 font-inconsolata;
}

.buy-link {
@apply inline-flex items-center gap-1 text-primary-dimmed hover:text-primary font-inconsolata text-sm transition-colors underline underline-offset-2;
}
</style>
Loading