From 14ec2f71651d5d7ff80e483479565fe1afdbd5b7 Mon Sep 17 00:00:00 2001 From: Jordi Gomez Date: Sun, 29 Mar 2026 15:37:05 +0200 Subject: [PATCH 1/2] feat: adding shop page --- .env.example | 6 + app/components/content/shop-product.vue | 34 +++++ app/components/content/shop-products.vue | 64 +++++++++ app/components/menu.vue | 6 +- app/components/shop-product-card.vue | 74 ++++++++++ app/pages/projects/[slug].vue | 3 +- app/pages/shop/[id].vue | 176 +++++++++++++++++++++++ content.config.ts | 6 +- content/posts/ballar-per-mi.md | 3 +- content/shop.md | 9 ++ nuxt.config.ts | 9 +- package.json | 5 +- pnpm-lock.yaml | 13 +- server/api/shop/products.get.ts | 80 +++++++++++ server/utils/vinted-api/client.ts | 85 +++++++++++ server/utils/vinted-api/fetcher.ts | 149 +++++++++++++++++++ 16 files changed, 707 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 app/components/content/shop-product.vue create mode 100644 app/components/content/shop-products.vue create mode 100644 app/components/shop-product-card.vue create mode 100644 app/pages/shop/[id].vue create mode 100644 content/shop.md create mode 100644 server/api/shop/products.get.ts create mode 100644 server/utils/vinted-api/client.ts create mode 100644 server/utils/vinted-api/fetcher.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3963b1d --- /dev/null +++ b/.env.example @@ -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 diff --git a/app/components/content/shop-product.vue b/app/components/content/shop-product.vue new file mode 100644 index 0000000..2058704 --- /dev/null +++ b/app/components/content/shop-product.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/components/content/shop-products.vue b/app/components/content/shop-products.vue new file mode 100644 index 0000000..ec10474 --- /dev/null +++ b/app/components/content/shop-products.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/app/components/menu.vue b/app/components/menu.vue index 125e08a..8895356 100644 --- a/app/components/menu.vue +++ b/app/components/menu.vue @@ -47,9 +47,9 @@ watch(isOutside, (outside) => { Me me -
- Blog - blog +
+ Shop + shop
diff --git a/app/components/shop-product-card.vue b/app/components/shop-product-card.vue new file mode 100644 index 0000000..c897bc4 --- /dev/null +++ b/app/components/shop-product-card.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/app/pages/projects/[slug].vue b/app/pages/projects/[slug].vue index 3ddb5bc..e6de2dd 100644 --- a/app/pages/projects/[slug].vue +++ b/app/pages/projects/[slug].vue @@ -10,7 +10,7 @@ useSeoMeta(data.value.seo) @@ -26,4 +26,3 @@ useSeoMeta(data.value.seo) @apply max-w-full mx-auto flex-1 flex; } - diff --git a/app/pages/shop/[id].vue b/app/pages/shop/[id].vue new file mode 100644 index 0000000..ba26695 --- /dev/null +++ b/app/pages/shop/[id].vue @@ -0,0 +1,176 @@ + + + + + diff --git a/content.config.ts b/content.config.ts index ddec587..47588f9 100644 --- a/content.config.ts +++ b/content.config.ts @@ -17,7 +17,7 @@ export default defineContentConfig({ type: 'page', source: { include: '**/*.md', - exclude: ['projects/**/*.md', 'posts/**/*.md'], + exclude: ['projects/**/*.md', 'posts/**/*.md', 'shop/**/*.md'], prefix: '/', }, schema: z.object({ @@ -34,5 +34,9 @@ export default defineContentConfig({ createdAt: z.date(), }), }), + products: defineCollection({ + type: 'page', + source: 'shop/**/*.md', + }), }, }) diff --git a/content/posts/ballar-per-mi.md b/content/posts/ballar-per-mi.md index 1580631..cfc9e12 100644 --- a/content/posts/ballar-per-mi.md +++ b/content/posts/ballar-per-mi.md @@ -1,7 +1,8 @@ --- title: Ballar per mi +createdAt: 2026-01-08T23:00:00.000Z +description: Tinc 24 anys, ara. He passat part de la meva infància no podent ser tan elàstica com la meva millor amiga, no volent fer res amb el meu cos per evitar ser “dolenta” i sentir-me malament, i evitant fer esport perquè no m’interessava gens ser competitiva, ja que, en el meu cap, ja havia perdut abans de començar. draft: false -createdAt: 2026-01-09T00:00:00 --- Tinc 24 anys, ara. He passat part de la meva infància no podent ser tan elàstica com la meva millor amiga, no volent fer res amb el meu cos per evitar ser “dolenta” i sentir-me malament, i evitant fer esport perquè no m’interessava gens ser competitiva, ja que, en el meu cap, ja havia perdut abans de començar. diff --git a/content/shop.md b/content/shop.md new file mode 100644 index 0000000..6efba2f --- /dev/null +++ b/content/shop.md @@ -0,0 +1,9 @@ +--- +title: Shop +description: Browse my curated selection of second-hand clothing and reworked pieces. +size: default +--- + +# Shop 📦 + +:shop-products diff --git a/nuxt.config.ts b/nuxt.config.ts index 69f4284..03ba35a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,3 +1,4 @@ +import process from 'node:process' import IconsResolver from 'unplugin-icons/resolver' import ViteComponents from 'unplugin-vue-components/vite' @@ -5,6 +6,12 @@ import ViteComponents from 'unplugin-vue-components/vite' export default defineNuxtConfig({ devtools: { enabled: true }, css: ['~/assets/scss/main.scss'], + + runtimeConfig: { + vintedUserId: process.env.VINTED_USER_ID || '', + vintedDomain: process.env.VINTED_DOMAIN || 'www.vinted.es', + }, + modules: [ '@nuxt/image', '@vueuse/nuxt', @@ -50,7 +57,7 @@ export default defineNuxtConfig({ }), ], dts: true, - }), + }) as any, ], }, }) diff --git a/package.json b/package.json index b26f5c8..3eff1bb 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "@nuxt/content": "3.8.2", "@nuxt/image": "^2.0.0", "@nuxtjs/google-fonts": "^3.2.0", + "consola": "^3.4.2", "nuxt": "^4.3.1", "nuxt-studio": "1.4.0", - "unplugin-vue-components": "^30.0.0", "vue": "^3.5.30", "vue-router": "^5.0.3" }, @@ -35,6 +35,7 @@ "postcss": "^8.5.8", "sass": "^1.98.0", "tailwindcss": "3.4.19", - "unplugin-icons": "^23.0.1" + "unplugin-icons": "^23.0.1", + "unplugin-vue-components": "^30.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078f844..db04854 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,15 +17,15 @@ importers: '@nuxtjs/google-fonts': specifier: ^3.2.0 version: 3.2.0(magicast@0.5.2) + consola: + specifier: ^3.4.2 + version: 3.4.2 nuxt: specifier: ^4.3.1 version: 4.3.1(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.30)(better-sqlite3@12.4.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.10.0)(lightningcss@1.30.1)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.59.0)(sass@1.98.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.98.0)(terser@5.46.0)(yaml@2.8.2))(yaml@2.8.2) nuxt-studio: specifier: 1.4.0 version: 1.4.0(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@12.4.1))(ioredis@5.10.0)(magicast@0.5.2)(vue@3.5.30(typescript@5.9.3)) - unplugin-vue-components: - specifier: ^30.0.0 - version: 30.0.0(@babel/parser@7.29.0)(@nuxt/kit@4.3.1(magicast@0.5.2))(vue@3.5.30(typescript@5.9.3)) vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -72,6 +72,9 @@ importers: unplugin-icons: specifier: ^23.0.1 version: 23.0.1(@vue/compiler-sfc@3.5.30) + unplugin-vue-components: + specifier: ^30.0.0 + version: 30.0.0(@babel/parser@7.29.0)(@nuxt/kit@4.3.1(magicast@0.5.2))(vue@3.5.30(typescript@5.9.3)) packages: @@ -7680,8 +7683,8 @@ snapshots: '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: diff --git a/server/api/shop/products.get.ts b/server/api/shop/products.get.ts new file mode 100644 index 0000000..0c75d26 --- /dev/null +++ b/server/api/shop/products.get.ts @@ -0,0 +1,80 @@ +import type { VintedItem } from '../../utils/vinted-api/client' +import { getWardrobeItems } from '../../utils/vinted-api/client' + +export interface Product { + id: number + title: string + price: string + currency: string + brand: string + size: string + status: string + url: string + photos: VintedItem['photos'] + favouriteCount: number + totalPrice: string + serviceFee: string + isReserved: boolean + isClosed: boolean +} + +function mapItem(item: VintedItem): Product { + return { + id: item.id, + title: item.title, + price: item.price.amount, + currency: item.price.currency_code, + brand: item.brand, + size: item.size, + status: item.status, + url: item.url, + photos: item.photos, + favouriteCount: item.favourite_count, + totalPrice: item.total_item_price.amount, + serviceFee: item.service_fee.amount, + isReserved: item.is_reserved, + isClosed: item.is_closed, + } +} + +export default defineCachedEventHandler(async (event) => { + const config = useRuntimeConfig() + const userId = config.vintedUserId + + if (!userId) { + throw createError({ + statusCode: 500, + statusMessage: 'Vinted user ID not configured', + }) + } + + const query = getQuery(event) + const page = Number(query.page) || 1 + const limit = Math.min(Number(query.limit) || 20, 100) + + const response = await getWardrobeItems(userId, { + page, + perPage: limit, + order: 'relevance', + }) + + const products = response.items + .filter(item => + !item.is_draft + && !item.is_closed + && !item.is_reserved + && !item.is_hidden + && item.transaction_permitted, + ) + .map(mapItem) + + return { + products, + pagination: { + currentPage: response.pagination.current_page, + totalPages: response.pagination.total_pages, + totalItems: response.pagination.total_entries, + perPage: response.pagination.per_page, + }, + } +}) diff --git a/server/utils/vinted-api/client.ts b/server/utils/vinted-api/client.ts new file mode 100644 index 0000000..7c28f8b --- /dev/null +++ b/server/utils/vinted-api/client.ts @@ -0,0 +1,85 @@ +/** + * Public Vinted API client + * Uses the public wardrobe endpoint to fetch user items + */ +import { vintedFetch } from './fetcher' + +export interface VintedThumbnail { + type: string + url: string + width: number + height: number + original_size: boolean | null +} + +export interface VintedPhoto { + id: number + width: number + height: number + url: string + dominant_color: string + dominant_color_opaque: string + thumbnails: VintedThumbnail[] + is_main?: boolean + full_size_url: string +} + +export interface VintedPrice { + amount: string + currency_code: string +} + +export interface VintedItem { + id: number + title: string + price: VintedPrice + currency: string + brand: string + size: string + status: string + url: string + photo?: VintedPhoto + photos: VintedPhoto[] + favourite_count: number + is_draft: boolean + is_closed: boolean + is_reserved: boolean + is_hidden: boolean + transaction_permitted: boolean + service_fee: VintedPrice + total_item_price: VintedPrice +} + +export interface VintedWardrobeResponse { + items: VintedItem[] + pagination: { + current_page: number + total_pages: number + total_entries: number + per_page: number + } +} + +/** + * Get items from a user's wardrobe + */ +export async function getWardrobeItems( + userId: string, + options: { + page?: number + perPage?: number + order?: 'relevance' | 'newest_first' | 'price_low_to_high' | 'price_high_to_low' + } = {}, +): Promise { + const { page = 1, perPage = 20, order = 'relevance' } = options + + const params = new URLSearchParams({ + page: page.toString(), + per_page: perPage.toString(), + order, + }) + + return vintedFetch( + `/api/v2/wardrobe/${userId}/items?${params.toString()}`, + ) +} diff --git a/server/utils/vinted-api/fetcher.ts b/server/utils/vinted-api/fetcher.ts new file mode 100644 index 0000000..ec51870 --- /dev/null +++ b/server/utils/vinted-api/fetcher.ts @@ -0,0 +1,149 @@ +import consola from 'consola' + +const logger = consola.withTag('vinted-fetcher') + +const VINTED_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0' + +interface VintedSession { + cookies: string + expiresAt: number +} + +const SESSION_STORAGE_KEY = 'vinted:guest-session' +const SESSION_CACHE_TTL_MS = 60 * 60 * 1000 // 1h +const SESSION_REFRESH_BUFFER_MS = 5 * 60 * 1000 // 5m + +/** + * Parse Set-Cookie headers from a Response into a cookie string + */ +function extractCookies(response: Response): string { + const cookies: Record = {} + // getSetCookie() returns individual Set-Cookie header values + const setCookieHeaders = response.headers.getSetCookie?.() ?? [] + for (const header of setCookieHeaders) { + const nameValue = header.split(';')[0] + if (!nameValue) + continue + const eqIndex = nameValue.indexOf('=') + if (eqIndex > 0) { + const name = nameValue.slice(0, eqIndex).trim() + const value = nameValue.slice(eqIndex + 1).trim() + cookies[name] = value + } + } + return Object.entries(cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; ') +} + +/** + * Obtain a guest session by fetching the Vinted homepage. + * The response sets access_token_web and other required cookies. + */ +async function getGuestSession(domain: string): Promise { + const now = Date.now() + const storage = useStorage() + const storedSession = await storage.getItem(SESSION_STORAGE_KEY) + + if (storedSession && storedSession.expiresAt > now + SESSION_REFRESH_BUFFER_MS) { + logger.debug('Using cached guest session', { + expiresAt: new Date(storedSession.expiresAt).toISOString(), + now: new Date(now).toISOString(), + ttlMs: storedSession.expiresAt - now, + hasAccessTokenCookie: storedSession.cookies.includes('access_token_web'), + }) + return storedSession + } + + logger.info('Obtaining guest session...') + + const response = await fetch(`https://${domain}`, { + headers: { + 'User-Agent': VINTED_USER_AGENT, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9,es;q=0.8', + }, + redirect: 'follow', + }) + + if (!response.ok) { + throw new Error(`[Vinted] Failed to obtain guest session: ${response.status}`) + } + + const cookies = extractCookies(response) + + if (!cookies.includes('access_token_web')) { + // Consume the body to avoid leaking resources + await response.text() + throw new Error('[Vinted] Guest session did not return access_token_web cookie') + } + + // Consume the HTML body + await response.text() + + // The access token JWT has ~2h expiry, cache for 1h + const newSession: VintedSession = { + cookies, + expiresAt: Date.now() + SESSION_CACHE_TTL_MS, + } + + await useStorage().setItem(SESSION_STORAGE_KEY, newSession) + + logger.info('Guest session cached', { + expiresAt: new Date(newSession.expiresAt).toISOString(), + cacheDurationMs: SESSION_CACHE_TTL_MS, + cookieCount: newSession.cookies.split(';').length, + hasAccessTokenCookie: newSession.cookies.includes('access_token_web'), + }) + + logger.info('Guest session obtained successfully') + return newSession +} + +/** + * Fetch from the public Vinted API with guest session cookies + */ +export async function vintedFetch( + path: string, + options: RequestInit = {}, +): Promise { + const config = useRuntimeConfig() + const domain = config.vintedDomain || 'www.vinted.es' + const url = `https://${domain}${path}` + + const session = await getGuestSession(domain) + + logger.debug('Using session for fetch', { + url, + expiresAt: new Date(session.expiresAt).toISOString(), + timeUntilExpiryMs: session.expiresAt - Date.now(), + cookieSnippet: session.cookies.slice(0, 120), + }) + + logger.info(`Fetching: ${url}`) + + const response = await fetch(url, { + ...options, + headers: { + 'User-Agent': VINTED_USER_AGENT, + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9,es;q=0.8', + 'Cookie': session.cookies, + ...options.headers, + }, + }) + + if (!response.ok) { + // If we get a 401, invalidate the session and retry once + if (response.status === 401) { + logger.info('Got 401, refreshing session...') + await useStorage().removeItem(SESSION_STORAGE_KEY) + return vintedFetch(path, options) + } + const errorText = await response.text() + logger.error(`API error ${response.status}: ${errorText}`) + throw new Error(`Vinted API error: ${response.status} - ${errorText}`) + } + + return response.json() +} From d0ac458341186128efc33b9569f4e68f4fb34f88 Mon Sep 17 00:00:00 2001 From: Jordi Gomez Date: Sun, 29 Mar 2026 18:54:40 +0200 Subject: [PATCH 2/2] feat: refining shop products component --- app/components/content/shop-products.vue | 10 +++++++--- app/pages/shop/[id].vue | 7 ++++--- content/shop.md | 3 ++- tailwind.config.js | 7 ++++--- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/components/content/shop-products.vue b/app/components/content/shop-products.vue index ec10474..0dab756 100644 --- a/app/components/content/shop-products.vue +++ b/app/components/content/shop-products.vue @@ -1,7 +1,11 @@