6 Blog post

This commit is contained in:
knuthtimo-lab 2025-09-02 11:59:51 +02:00
parent 2a1412a52d
commit 148cb6d283
66 changed files with 10494 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

0
README.md Executable file → Normal file
View File

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

4
next.config.js Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

5297
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "curated-finds-working",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.5",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.5"
}
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

327
src/app/globals.css Normal file
View File

@ -0,0 +1,327 @@
@import url('https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Spectral:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Staatliches&family=Pacifico&display=swap');
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #F7F1E1;
color: #1E1A17;
font-family: 'Spectral', serif;
background-image: radial-gradient(circle at 1px 1px, rgba(30, 26, 23, 0.03) 1px, transparent 0);
background-size: 20px 20px;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.02'/%3E%3C/svg%3E");
pointer-events: none;
z-index: -1;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.vintage-header {
position: relative;
background-color: #F7F1E1;
border-bottom: 2px solid #8B7D6B;
box-shadow: 0 2px 4px rgba(30, 26, 23, 0.1);
transition: all 0.3s ease;
}
.masthead {
text-align: center;
padding: 24px 0;
border-bottom: 1px solid #8B7D6B;
}
.masthead h1 {
font-family: 'Abril Fatface', serif;
font-size: 48px;
font-weight: 900;
color: #1E1A17;
line-height: 1;
letter-spacing: -0.02em;
}
.masthead .subtitle {
font-family: 'Abril Fatface', serif;
font-size: 28px;
color: #C89C2B;
margin-top: -8px;
font-weight: 900;
}
.newspaper-rule {
height: 1px;
background: linear-gradient(to right, transparent, #8B7D6B, transparent);
margin: 12px 0;
}
.tagline {
font-family: 'Space Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: #8B7D6B;
}
.nav-ribbon {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
}
.nav-links {
display: flex;
align-items: center;
gap: 4px;
}
.nav-links a {
font-family: 'Staatliches', sans-serif;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 8px 12px;
color: #1E1A17;
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
position: relative;
}
.nav-links a:hover {
color: #C89C2B;
}
.nav-links a::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: #C89C2B;
transition: width 0.3s ease;
}
.nav-links a:hover::after {
width: 100%;
}
.nav-separator {
color: #8B7D6B;
margin: 0 4px;
}
.ticket-button {
background: #C89C2B;
color: #F7F1E1;
border: none;
padding: 12px 24px;
font-family: 'Space Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: bold;
border-radius: 0 12px 12px 0;
position: relative;
cursor: pointer;
overflow: hidden;
text-decoration: none;
display: inline-block;
}
.ticket-button::before {
content: '';
position: absolute;
left: -2px;
top: 0;
bottom: 0;
width: 4px;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 3px,
#F7F1E1 3px,
#F7F1E1 6px
);
}
.ticket-button::after {
content: 'ADMIT 001';
position: absolute;
bottom: 2px;
right: 4px;
font-size: 8px;
opacity: 0.7;
}
.stamp-button {
background: transparent;
color: #C89C2B;
border: 2px dashed #C89C2B;
padding: 12px 24px;
font-family: 'Space Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.stamp-button:hover {
background: #C89C2B;
color: #F7F1E1;
box-shadow: inset 0 0 20px rgba(200, 156, 43, 0.3);
}
/* Hero */
.hero-section {
padding: 64px 20px;
position: relative;
}
.hero-content {
max-width: 1000px;
margin: 0 auto;
text-align: center;
position: relative;
}
.editor-seal {
position: absolute;
top: -32px;
right: -16px;
width: 64px;
height: 64px;
background-color: #7B2E2E;
color: #F7F1E1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Pacifico', cursive;
font-size: 12px;
transform: rotate(-12deg);
box-shadow: 0 2px 8px rgba(123, 46, 46, 0.3);
z-index: 10;
transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
text-align: center;
line-height: 1.2;
}
.editor-seal:hover {
transform: rotate(-10deg) scale(1.05);
}
.drop-cap-text {
font-family: 'Spectral', serif;
font-size: 24px;
line-height: 1.6;
color: #1E1A17;
max-width: 800px;
margin: 0 auto 32px;
text-align: left;
}
.drop-cap-text::first-letter {
font-family: 'Abril Fatface', serif;
font-size: 72px;
float: left;
line-height: 1;
margin-right: 8px;
margin-top: 4px;
color: #C89C2B;
font-weight: 900;
}
.hero-subheading {
font-family: 'Space Mono', monospace;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.15em;
color: #8B7D6B;
margin-bottom: 32px;
}
.hero-ctas {
display: flex;
gap: 24px;
justify-content: center;
align-items: center;
margin-bottom: 32px;
flex-wrap: wrap;
}
.trust-badge {
display: inline-flex;
align-items: center;
gap: 12px;
background-color: rgba(255, 255, 255, 0.5);
padding: 12px 24px;
border: 1px solid #8B7D6B;
}
.trust-badge-icon {
width: 32px;
height: 32px;
background-color: #2D6A6A;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-family: 'Space Mono', monospace;
font-size: 12px;
}
.trust-badge-text {
font-family: 'Space Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #8B7D6B;
}
/* Responsive */
@media (max-width: 768px) {
.masthead h1 {
font-size: 36px;
}
.masthead .subtitle {
font-size: 20px;
}
.drop-cap-text::first-letter {
font-size: 48px;
}
.hero-ctas {
flex-direction: column;
}
.nav-links {
display: none;
}
}

25
src/app/layout.tsx Normal file
View File

@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import './globals.css'
import { AppProvider } from '@/contexts/AppContext'
export const metadata: Metadata = {
title: 'The Curated Finds Blog',
description: 'Handpicked treasures from the eBay universe — updated weekly by experts who know quality when they see it',
keywords: ['vintage', 'collectibles', 'eBay', 'curated', 'affordable', 'authentic'],
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AppProvider>
{children}
</AppProvider>
</body>
</html>
)
}

167
src/app/page.module.css Normal file
View File

@ -0,0 +1,167 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 158px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}

165
src/app/page.tsx Normal file
View File

@ -0,0 +1,165 @@
'use client'
import { useState } from 'react'
import { ScrollEffects } from '@/components/ScrollEffects'
import { FloatingElements } from '@/components/FloatingElements'
import { ClientOnly } from '@/components/ClientOnly'
import { BlogPostCard, BlogPostModal } from '@/components/BlogPost'
import { blogPosts, BlogPost } from '@/data/blogPosts'
export default function HomePage() {
const [selectedBlogPost, setSelectedBlogPost] = useState<BlogPost | null>(null)
return (
<div style={{ minHeight: '100vh', backgroundColor: '#F7F1E1' }}>
{/* Scroll Effects */}
<ClientOnly>
<ScrollEffects />
</ClientOnly>
{/* Floating Background Elements */}
<ClientOnly>
<FloatingElements />
</ClientOnly>
{/* Header */}
<header className="vintage-header">
<div className="container">
{/* Masthead */}
<div className="masthead">
<h1>The Curated Finds</h1>
<div className="subtitle">BLOG</div>
<div className="newspaper-rule"></div>
<p className="tagline">Handpicked Treasures from the eBay Universe Est. 2024</p>
</div>
</div>
</header>
{/* Hero Section with Search */}
<section className="hero-section">
<div className="hero-content">
{/* Editor's Seal */}
<div className="editor-seal">
Editor's<br />Choice
</div>
{/* Main Headline with Drop Cap */}
<div>
<p className="drop-cap-text">
Handpicked treasures from the eBay universe updated weekly by experts who know quality when they see it. Every piece tells a story, every find has character, and every discovery connects you to the rich history of human craftsmanship.
</p>
</div>
<div className="newspaper-rule"></div>
{/* Subheading */}
<p className="hero-subheading">
Curated Finds Curated Collections Expert Insights
</p>
{/* CTAs */}
<div className="hero-ctas">
<a href="#blog-posts" className="ticket-button">
Read Collector's Chronicles
</a>
</div>
{/* Trust Badge */}
<div className="trust-badge">
<div className="trust-badge-icon"></div>
<p className="trust-badge-text">No spam, just curated finds 100% authentic</p>
</div>
</div>
</section>
{/* Blog Posts Section */}
<section id="blog-posts" style={{
padding: '64px 20px',
backgroundColor: '#F7F1E1',
borderTop: '2px solid #8B7D6B'
}}>
<div className="container">
<div style={{ textAlign: 'center', marginBottom: '48px' }}>
<h2 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '48px',
fontWeight: '900',
color: '#1E1A17',
marginBottom: '16px'
}}>
Collector's Chronicles
</h2>
<div className="newspaper-rule"></div>
<p style={{
fontFamily: 'Space Mono, monospace',
fontSize: '14px',
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: '#8B7D6B'
}}>
Deep dives into remarkable finds
</p>
</div>
<div style={{
maxWidth: '800px',
margin: '0 auto'
}}>
{blogPosts.map((post) => (
<BlogPostCard
key={post.id}
post={post}
onReadMore={setSelectedBlogPost}
/>
))}
</div>
</div>
</section>
{/* Footer */}
<footer style={{
backgroundColor: '#F7F1E1',
borderTop: '2px solid #8B7D6B',
padding: '32px 20px'
}}>
<div className="container">
<div style={{ textAlign: 'center' }}>
<h3 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
margin: 0,
color: '#1E1A17'
}}>
The Curated Finds
</h3>
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '14px',
color: '#4A4A4A',
marginTop: '8px'
}}>
Handpicked treasures. Honest descriptions. Careful packaging.
</p>
<div className="newspaper-rule" style={{ margin: '16px auto', width: '120px' }} />
<p style={{ color: '#8B7D6B', fontFamily: 'Space Mono, monospace', fontSize: '12px' }}>
© {new Date().getFullYear()} The Curated Finds All rights reserved.
</p>
</div>
</div>
</footer>
{/* Blog Post Modal */}
{selectedBlogPost && (
<BlogPostModal
post={selectedBlogPost}
onClose={() => setSelectedBlogPost(null)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,152 @@
'use client'
import { CuratedItem } from '@/types'
interface AuthenticationBadgeProps {
item: CuratedItem
detailed?: boolean
}
export function AuthenticationBadge({ item, detailed = false }: AuthenticationBadgeProps) {
const getAuthenticationLevel = () => {
if (!item.authenticity.verified) return 'unverified'
// Based on research: different authentication levels
const hasSerialNumber = item.authenticity.notes?.includes('serial')
const hasExpertVerification = item.authenticity.notes?.includes('expert')
const hasDocumentation = item.authenticity.notes?.includes('verified')
if (hasSerialNumber && hasExpertVerification && hasDocumentation) {
return 'premium'
} else if (hasExpertVerification || hasSerialNumber) {
return 'verified'
} else {
return 'basic'
}
}
const authLevel = getAuthenticationLevel()
const getAuthConfig = () => {
switch (authLevel) {
case 'premium':
return {
color: '#2D6A6A',
icon: '🏆',
label: 'Premium Verified',
description: 'Expert authenticated with full documentation'
}
case 'verified':
return {
color: '#C89C2B',
icon: '✓',
label: 'Verified Authentic',
description: 'Authenticated by specialist'
}
case 'basic':
return {
color: '#8B7D6B',
icon: '◐',
label: 'Basic Check',
description: 'Standard verification completed'
}
default:
return {
color: '#7B2E2E',
icon: '?',
label: 'Unverified',
description: 'Authentication pending'
}
}
}
const config = getAuthConfig()
if (!detailed) {
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
backgroundColor: config.color,
color: '#F7F1E1',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '10px',
fontFamily: 'Space Mono, monospace',
textTransform: 'uppercase',
letterSpacing: '0.1em'
}}
title={config.description}
>
<span>{config.icon}</span>
{authLevel !== 'unverified' && 'Auth'}
</div>
)
}
return (
<div
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: `2px solid ${config.color}`,
borderRadius: '8px',
padding: '12px',
marginBottom: '16px'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px'
}}>
<div style={{
width: '24px',
height: '24px',
backgroundColor: config.color,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#F7F1E1',
fontSize: '12px'
}}>
{config.icon}
</div>
<div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
fontWeight: 'bold',
color: config.color,
textTransform: 'uppercase',
letterSpacing: '0.1em'
}}>
{config.label}
</div>
<div style={{
fontFamily: 'Spectral, serif',
fontSize: '11px',
color: '#8B7D6B'
}}>
{config.description}
</div>
</div>
</div>
{item.authenticity.notes && (
<div style={{
fontFamily: 'Spectral, serif',
fontSize: '12px',
color: '#1E1A17',
fontStyle: 'italic',
paddingLeft: '32px'
}}>
"{item.authenticity.notes}"
</div>
)}
</div>
)
}

517
src/components/BlogPost.tsx Normal file
View File

@ -0,0 +1,517 @@
import React from 'react'
import { BlogPost } from '@/data/blogPosts'
interface BlogPostCardProps {
post: BlogPost
onReadMore: (post: BlogPost) => void
}
export function BlogPostCard({ post, onReadMore }: BlogPostCardProps) {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
return (
<article style={{
backgroundColor: 'white',
border: '2px solid #8B7D6B',
padding: '24px',
marginBottom: '32px',
position: 'relative',
boxShadow: '4px 4px 0 rgba(139, 125, 107, 0.2)'
}}>
{/* Featured Badge */}
{post.featured && (
<div style={{
position: 'absolute',
top: '-12px',
right: '24px',
backgroundColor: '#C89C2B',
color: '#F7F1E1',
padding: '4px 12px',
fontFamily: 'Pacifico, cursive',
fontSize: '14px',
transform: 'rotate(3deg)',
boxShadow: '2px 2px 4px rgba(0,0,0,0.2)'
}}>
Featured
</div>
)}
{/* Header Image */}
{(post.previewImage || post.images[0]) && (
<div style={{
marginBottom: '24px',
marginLeft: '-24px',
marginRight: '-24px',
marginTop: '-24px',
height: '300px',
backgroundImage: `url('${post.previewImage || post.images[0]}')`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundColor: 'white',
borderBottom: '2px solid #8B7D6B'
}} />
)}
{/* Category & Date */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.15em',
color: '#C89C2B',
fontWeight: 'bold'
}}>
{post.category}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B'
}}>
{formatDate(post.datePublished)}
</span>
</div>
{/* Title */}
<h2 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '28px',
fontWeight: '900',
color: '#1E1A17',
marginBottom: '16px',
lineHeight: '1.2'
}}>
{post.title}
</h2>
{/* Excerpt */}
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.6',
marginBottom: '20px'
}}>
{post.excerpt}
</p>
{/* Tags */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '20px'
}}>
{post.tags.slice(0, 3).map(tag => (
<span key={tag} style={{
backgroundColor: '#F7F1E1',
border: '1px solid #8B7D6B',
padding: '4px 8px',
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#8B7D6B'
}}>
{tag}
</span>
))}
</div>
{/* Read More Button */}
<button
onClick={() => onReadMore(post)}
style={{
backgroundColor: 'transparent',
border: '2px solid #C89C2B',
color: '#C89C2B',
padding: '12px 24px',
fontFamily: 'Staatliches, sans-serif',
fontSize: '16px',
letterSpacing: '0.1em',
textTransform: 'uppercase',
cursor: 'pointer',
transition: 'all 0.3s ease',
position: 'relative',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#C89C2B'
e.currentTarget.style.color = '#F7F1E1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#C89C2B'
}}
>
Read Full Article
</button>
</article>
)
}
interface BlogPostModalProps {
post: BlogPost
onClose: () => void
}
export function BlogPostModal({ post, onClose }: BlogPostModalProps) {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
// Convert markdown-style formatting to HTML
const formatContent = (content: string) => {
return content
.split('\n\n')
.map((paragraph, index) => {
// Handle headers
if (paragraph.startsWith('## ')) {
return (
<h3 key={index} style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
fontWeight: '900',
color: '#1E1A17',
marginTop: '32px',
marginBottom: '16px'
}}>
{paragraph.replace('## ', '')}
</h3>
)
}
if (paragraph.startsWith('### ')) {
return (
<h4 key={index} style={{
fontFamily: 'Staatliches, sans-serif',
fontSize: '20px',
color: '#C89C2B',
marginTop: '24px',
marginBottom: '12px'
}}>
{paragraph.replace('### ', '')}
</h4>
)
}
// Handle lists
if (paragraph.startsWith('- ')) {
const items = paragraph.split('\n').filter(line => line.startsWith('- '))
return (
<ul key={index} style={{
marginBottom: '16px',
paddingLeft: '24px'
}}>
{items.map((item, i) => (
<li key={i} style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '8px'
}}>
{item.replace('- ', '').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')}
</li>
))}
</ul>
)
}
// Regular paragraphs with bold text support
const formattedText = paragraph.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
return (
<p key={index} style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '16px'
}} dangerouslySetInnerHTML={{ __html: formattedText }} />
)
})
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(30, 26, 23, 0.8)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
overflowY: 'auto'
}} onClick={onClose}>
<article style={{
backgroundColor: '#F7F1E1',
maxWidth: '900px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
padding: '48px',
position: 'relative',
boxShadow: '0 10px 40px rgba(0,0,0,0.3)'
}} onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'transparent',
border: 'none',
fontSize: '32px',
cursor: 'pointer',
color: '#8B7D6B',
zIndex: 10
}}
>
×
</button>
{/* Header */}
<div style={{ marginBottom: '32px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.15em',
color: '#C89C2B',
fontWeight: 'bold'
}}>
{post.category}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B'
}}>
{formatDate(post.datePublished)}
</span>
</div>
<h1 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '42px',
fontWeight: '900',
color: '#1E1A17',
lineHeight: '1.2',
marginBottom: '24px'
}}>
{post.title}
</h1>
<div className="newspaper-rule" style={{
width: '100px',
height: '2px',
backgroundColor: '#C89C2B',
margin: '24px 0'
}} />
</div>
{/* Content with interleaved images */}
<div style={{ marginBottom: '32px' }}>
{(() => {
const blocks = post.content.split('\n\n')
const nodes: React.ReactNode[] = []
const totalBlocks = blocks.length
const totalImages = post.images.length
const step = totalImages > 0 ? Math.ceil(totalBlocks / (totalImages + 1)) : Infinity
let imageIndex = 0
blocks.forEach((paragraph, index) => {
// Headers
if (paragraph.startsWith('## ')) {
nodes.push(
<h3 key={`h3-${index}`} style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
fontWeight: '900',
color: '#1E1A17',
marginTop: '32px',
marginBottom: '16px'
}}>
{paragraph.replace('## ', '')}
</h3>
)
} else if (paragraph.startsWith('### ')) {
nodes.push(
<h4 key={`h4-${index}`} style={{
fontFamily: 'Staatliches, sans-serif',
fontSize: '20px',
color: '#C89C2B',
marginTop: '24px',
marginBottom: '12px'
}}>
{paragraph.replace('### ', '')}
</h4>
)
} else if (paragraph.startsWith('- ')) {
const items = paragraph.split('\n').filter(line => line.startsWith('- '))
nodes.push(
<ul key={`ul-${index}`} style={{
marginBottom: '16px',
paddingLeft: '24px'
}}>
{items.map((item, i) => (
<li
key={`li-${index}-${i}`}
style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '8px'
}}
dangerouslySetInnerHTML={{ __html: item.replace('- ', '').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') }}
/>
))}
</ul>
)
} else {
// Paragraphs
const formattedText = paragraph.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
nodes.push(
<p key={`p-${index}`} style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '16px'
}} dangerouslySetInnerHTML={{ __html: formattedText }} />
)
}
// Interleave images evenly through the content
if (totalImages > 0 && imageIndex < totalImages && (index + 1) % step === 0) {
nodes.push(
<div
key={`img-${imageIndex}`}
style={{
margin: '16px 0 32px',
display: 'flex',
justifyContent: 'center'
}}
>
<div
style={{
border: '2px solid #8B7D6B',
backgroundColor: 'white',
padding: '6px',
maxWidth: '360px',
width: '100%'
}}
>
<img
src={post.images[imageIndex]}
alt={`Image ${imageIndex + 1}`}
style={{
display: 'block',
width: '100%',
height: 'auto',
backgroundColor: '#F7F1E1'
}}
/>
</div>
</div>
)
imageIndex++
}
})
// If any images remain, append them at the end
while (imageIndex < totalImages) {
nodes.push(
<div
key={`img-tail-${imageIndex}`}
style={{
margin: '16px 0 32px',
display: 'flex',
justifyContent: 'center'
}}
>
<div
style={{
border: '2px solid #8B7D6B',
backgroundColor: 'white',
padding: '6px',
maxWidth: '360px',
width: '100%'
}}
>
<img
src={post.images[imageIndex]}
alt={`Image ${imageIndex + 1}`}
style={{
display: 'block',
width: '100%',
height: 'auto',
backgroundColor: '#F7F1E1'
}}
/>
</div>
</div>
)
imageIndex++
}
return nodes
})()}
</div>
{/* Tags */}
<div style={{
borderTop: '2px solid #8B7D6B',
paddingTop: '24px',
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
{post.tags.map(tag => (
<span key={tag} style={{
backgroundColor: 'white',
border: '1px solid #8B7D6B',
padding: '6px 12px',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#8B7D6B'
}}>
{tag}
</span>
))}
</div>
</article>
</div>
)
}

View File

@ -0,0 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
interface ClientOnlyProps {
children: React.ReactNode
fallback?: React.ReactNode
}
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
if (!hasMounted) {
return <>{fallback}</>
}
return <>{children}</>
}

View File

@ -0,0 +1,249 @@
'use client'
import { useApp } from '@/contexts/AppContext'
import { CuratedItem } from '@/types'
interface CollectorMotivationProps {
item: CuratedItem
}
export function CollectorMotivation({ item }: CollectorMotivationProps) {
const { state } = useApp()
// Based on research: collector psychology and motivation triggers
const getMotivationTriggers = () => {
const triggers = []
// Scarcity trigger
if (item.rarity >= 4) {
triggers.push({
type: 'scarcity',
icon: '⚡',
title: 'Rare Find',
message: 'Only a few of these exist in this condition',
color: '#7B2E2E'
})
}
// Heritage/History trigger
if (item.dateAdded && new Date(item.dateAdded).getFullYear() < 1980) {
triggers.push({
type: 'heritage',
icon: '🏛️',
title: 'Historical Significance',
message: 'Connects you to a rich cultural heritage',
color: '#2D6A6A'
})
}
// Investment trigger
if (item.originalPrice && item.price < item.originalPrice * 0.8) {
triggers.push({
type: 'investment',
icon: '💎',
title: 'Investment Potential',
message: 'Undervalued compared to market trends',
color: '#C89C2B'
})
}
// Completion trigger (if user has similar items)
const userCategoryItems = state.wishlist.filter(id => {
const wishlistItem = state.items.find(i => i.id === id)
return wishlistItem?.category === item.category
})
if (userCategoryItems.length >= 2) {
triggers.push({
type: 'completion',
icon: '🧩',
title: 'Collection Builder',
message: `Perfect addition to your ${item.category} collection`,
color: '#4A7C59'
})
}
// Authenticity trust trigger
if (item.authenticity.verified) {
triggers.push({
type: 'trust',
icon: '🛡️',
title: 'Verified Authentic',
message: 'Buy with confidence - authenticity guaranteed',
color: '#2D6A6A'
})
}
return triggers.slice(0, 3) // Show max 3 triggers
}
const getCollectorPersonality = () => {
const categoryCount = state.wishlist.reduce((acc, id) => {
const item = state.items.find(i => i.id === id)
if (item) {
acc[item.category] = (acc[item.category] || 0) + 1
}
return acc
}, {} as Record<string, number>)
const dominantCategory = Object.entries(categoryCount)
.sort(([,a], [,b]) => b - a)[0]?.[0]
const personalities = {
photography: {
type: 'Visual Historian',
description: 'You preserve moments and appreciate craftsmanship',
motivation: 'Each camera tells a story of innovation'
},
music: {
type: 'Audio Archivist',
description: 'You value authentic sound and musical heritage',
motivation: 'Original pressings capture the artist\'s true vision'
},
books: {
type: 'Literary Curator',
description: 'You collect knowledge and cultural significance',
motivation: 'First editions connect you to literary history'
},
design: {
type: 'Aesthetic Connoisseur',
description: 'You appreciate timeless design and functionality',
motivation: 'Mid-century pieces represent design perfection'
}
}
return personalities[dominantCategory as keyof typeof personalities] || {
type: 'Eclectic Collector',
description: 'You appreciate quality across all categories',
motivation: 'Diverse collections tell richer stories'
}
}
const triggers = getMotivationTriggers()
const personality = getCollectorPersonality()
if (triggers.length === 0) return null
return (
<div style={{
backgroundColor: 'rgba(123, 46, 46, 0.05)',
border: '1px solid rgba(123, 46, 46, 0.2)',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px'
}}>
{/* Collector Personality */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
paddingBottom: '8px',
borderBottom: '1px solid rgba(123, 46, 46, 0.1)'
}}>
<div style={{
width: '24px',
height: '24px',
backgroundColor: '#7B2E2E',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px'
}}>
🎯
</div>
<div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#7B2E2E',
fontWeight: 'bold'
}}>
{personality.type}
</div>
<div style={{
fontFamily: 'Spectral, serif',
fontSize: '10px',
color: '#8B7D6B',
fontStyle: 'italic'
}}>
{personality.motivation}
</div>
</div>
</div>
{/* Motivation Triggers */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px'
}}>
{triggers.map((trigger, index) => (
<div
key={trigger.type}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 8px',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderRadius: '4px',
borderLeft: `3px solid ${trigger.color}`
}}
>
<span style={{ fontSize: '14px' }}>{trigger.icon}</span>
<div style={{ flex: 1 }}>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: trigger.color,
fontWeight: 'bold',
marginBottom: '2px'
}}>
{trigger.title}
</div>
<div style={{
fontFamily: 'Spectral, serif',
fontSize: '11px',
color: '#1E1A17'
}}>
{trigger.message}
</div>
</div>
</div>
))}
</div>
{/* Call to Action */}
<div style={{
marginTop: '12px',
padding: '8px',
backgroundColor: 'rgba(123, 46, 46, 0.1)',
borderRadius: '4px',
textAlign: 'center'
}}>
<div style={{
fontFamily: 'Pacifico, cursive',
fontSize: '12px',
color: '#7B2E2E',
marginBottom: '4px'
}}>
Why collectors love this piece
</div>
<div style={{
fontFamily: 'Spectral, serif',
fontSize: '11px',
color: '#1E1A17',
fontStyle: 'italic'
}}>
"{personality.description}"
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,75 @@
'use client'
import { useEffect, useState } from 'react'
export function FloatingElements() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY })
}
// Add a small delay to ensure proper hydration
const timer = setTimeout(() => {
if (typeof window !== 'undefined') {
window.addEventListener('mousemove', handleMouseMove)
}
}, 100)
return () => {
clearTimeout(timer)
if (typeof window !== 'undefined') {
window.removeEventListener('mousemove', handleMouseMove)
}
}
}, [])
// Don't render on server side or immediately after mount
if (!isMounted) {
return null
}
const floatingItems = [
{ icon: '📸', size: 24, speed: 0.02, offset: { x: 100, y: 50 } },
{ icon: '🎵', size: 20, speed: 0.03, offset: { x: -80, y: 100 } },
{ icon: '📚', size: 22, speed: 0.025, offset: { x: 150, y: -30 } },
{ icon: '🎨', size: 26, speed: 0.015, offset: { x: -120, y: -80 } },
{ icon: '⭐', size: 18, speed: 0.04, offset: { x: 200, y: 120 } },
{ icon: '💎', size: 16, speed: 0.035, offset: { x: -200, y: 60 } }
]
return (
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: -1 }}>
{floatingItems.map((item, index) => (
<div
key={index}
style={{
position: 'absolute',
fontSize: `${item.size}px`,
opacity: 0.1,
transform: `translate3d(${mousePosition.x * item.speed + item.offset.x}px, ${mousePosition.y * item.speed + item.offset.y}px, 0)`,
transition: 'transform 0.5s ease-out',
animation: `float-${index} ${3 + index * 0.5}s ease-in-out infinite`,
left: `${20 + (index * 15)}%`,
top: `${10 + (index * 12)}%`
}}
>
{item.icon}
</div>
))}
<style jsx>{`
${floatingItems.map((_, index) => `
@keyframes float-${index} {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-${10 + index * 2}px) rotate(${index % 2 === 0 ? 2 : -2}deg); }
}
`).join('')}
`}</style>
</div>
)
}

374
src/components/ItemCard.tsx Normal file
View File

@ -0,0 +1,374 @@
'use client'
import { useState } from 'react'
import { CuratedItem } from '@/types'
import { useApp } from '@/contexts/AppContext'
import { AuthenticationBadge } from './AuthenticationBadge'
import { ItemDetailModal } from './ItemDetailModal'
interface ItemCardProps {
item: CuratedItem
showWishlist?: boolean
showExpertNotes?: boolean
compact?: boolean
}
export function ItemCard({
item,
showWishlist = true,
showExpertNotes = true,
compact = false
}: ItemCardProps) {
const { state, toggleWishlist, addToRecentlyViewed } = useApp()
const [showDetailModal, setShowDetailModal] = useState(false)
const isWishlisted = state.wishlist.includes(item.id)
const handleItemClick = () => {
addToRecentlyViewed(item.id)
setShowDetailModal(true)
}
const handleWishlistClick = (e: React.MouseEvent) => {
e.stopPropagation()
toggleWishlist(item.id)
}
const getRarityStars = (rarity: number) => {
return '★'.repeat(rarity) + '☆'.repeat(5 - rarity)
}
const getConditionColor = (condition: string) => {
switch (condition) {
case 'new': return '#2D6A6A'
case 'like-new': return '#4A7C59'
case 'excellent': return '#C89C2B'
case 'very-good': return '#D4A574'
case 'good': return '#8B7D6B'
case 'acceptable': return '#7B2E2E'
default: return '#8B7D6B'
}
}
return (
<div
className="pick-card"
style={{
cursor: 'pointer',
position: 'relative',
height: compact ? 'auto' : '500px',
display: 'flex',
flexDirection: 'column',
transition: 'all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
transformStyle: 'preserve-3d'
}}
onClick={handleItemClick}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-8px) rotateX(2deg)'
e.currentTarget.style.boxShadow = '0 20px 40px rgba(30, 26, 23, 0.15)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0px) rotateX(0deg)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(30, 26, 23, 0.1)'
}}
>
{/* Wishlist Button */}
{showWishlist && (
<button
onClick={handleWishlistClick}
style={{
position: 'absolute',
top: '12px',
right: '12px',
zIndex: 10,
background: isWishlisted ? '#7B2E2E' : 'rgba(255, 255, 255, 0.9)',
color: isWishlisted ? '#F7F1E1' : '#7B2E2E',
border: '2px solid #7B2E2E',
borderRadius: '50%',
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.3s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
title={isWishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
>
{isWishlisted ? '❤️' : '🤍'}
</button>
)}
{/* Featured Badge */}
{item.featured && (
<div style={{
position: 'absolute',
top: '12px',
left: '12px',
zIndex: 10,
background: '#C89C2B',
color: '#F7F1E1',
padding: '4px 12px',
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontWeight: 'bold',
borderRadius: '0 8px 8px 0'
}}>
Featured
</div>
)}
{/* Category Tab */}
<div className="folder-tab">
<div className="folder-tab-label" style={{ textTransform: 'capitalize' }}>
{item.category}
</div>
</div>
{/* Image */}
<div style={{
width: '100%',
height: compact ? '150px' : '200px',
backgroundImage: `url("${item.images[0]}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
border: '2px solid #8B7D6B',
margin: '0 16px 16px 16px',
borderRadius: '4px',
position: 'relative',
overflow: 'hidden'
}}>
{/* Price Overlay */}
<div style={{
position: 'absolute',
bottom: '8px',
left: '8px',
background: 'rgba(30, 26, 23, 0.9)',
color: '#F7F1E1',
padding: '4px 8px',
borderRadius: '4px',
fontFamily: 'Space Mono, monospace',
fontSize: '14px',
fontWeight: 'bold'
}}>
${item.price}
{item.originalPrice && (
<span style={{
textDecoration: 'line-through',
opacity: 0.7,
marginLeft: '8px',
fontSize: '12px'
}}>
${item.originalPrice}
</span>
)}
</div>
{/* Authenticity Badge */}
{item.authenticity.verified && (
<div style={{
position: 'absolute',
top: '8px',
right: '8px',
background: '#2D6A6A',
color: '#F7F1E1',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px'
}}>
</div>
)}
</div>
{/* Content */}
<div style={{
padding: '0 16px 16px 16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
flex: 1
}}>
{/* Title */}
<h3 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: compact ? '18px' : '20px',
fontWeight: '900',
color: '#1E1A17',
lineHeight: '1.2',
margin: 0,
display: '-webkit-box',
WebkitLineClamp: compact ? 2 : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{item.title}
</h3>
{/* Meta Info */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexWrap: 'wrap'
}}>
{/* Condition */}
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
backgroundColor: getConditionColor(item.condition),
color: '#F7F1E1',
padding: '2px 8px',
borderRadius: '4px'
}}>
{item.condition}
</span>
{/* Rarity */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<span style={{ color: '#C89C2B', fontSize: '14px' }}>
{getRarityStars(item.rarity)}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
color: '#8B7D6B',
textTransform: 'uppercase',
letterSpacing: '0.1em'
}}>
Rarity {item.rarity}/5
</span>
</div>
</div>
{/* Expert Notes */}
{showExpertNotes && !compact && (
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '14px',
lineHeight: '1.5',
color: '#1E1A17',
margin: 0,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{item.expertNotes}
</p>
)}
{/* Tags */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginTop: 'auto'
}}>
{item.tags.slice(0, compact ? 2 : 3).map(tag => (
<span key={tag} style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
backgroundColor: 'rgba(139, 125, 107, 0.1)',
border: '1px solid #8B7D6B',
color: '#8B7D6B',
padding: '2px 6px',
borderRadius: '4px'
}}>
{tag}
</span>
))}
{item.tags.length > (compact ? 2 : 3) && (
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
color: '#8B7D6B'
}}>
+{item.tags.length - (compact ? 2 : 3)} more
</span>
)}
</div>
{/* Seller Info */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '8px',
borderTop: '1px solid rgba(139, 125, 107, 0.2)',
fontSize: '12px',
color: '#8B7D6B'
}}>
<div>
<span style={{ fontFamily: 'Space Mono, monospace' }}>
{item.seller.name}
</span>
<span style={{ marginLeft: '8px' }}>
{item.seller.rating} ({item.seller.feedback})
</span>
</div>
<div style={{ fontFamily: 'Space Mono, monospace' }}>
+${item.shipping.cost} shipping
</div>
</div>
{/* Action Buttons */}
{!compact && (
<div style={{ display: 'flex', gap: '8px', paddingTop: '8px' }}>
<button
className="ticket-button"
style={{
flex: 1,
fontSize: '10px',
padding: '8px 12px',
textAlign: 'center'
}}
onClick={(e) => {
e.stopPropagation()
handleItemClick()
}}
>
View on eBay
</button>
<button
className="stamp-button"
style={{
fontSize: '10px',
padding: '8px 12px'
}}
onClick={(e) => {
e.stopPropagation()
// In real app, this would show more details
alert('Item details coming soon!')
}}
>
Details
</button>
</div>
)}
</div>
{/* Detail Modal */}
<ItemDetailModal
item={item}
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
/>
</div>
)
}

View File

@ -0,0 +1,439 @@
'use client'
import { useState } from 'react'
import { CuratedItem } from '@/types'
import { useApp } from '@/contexts/AppContext'
import { AuthenticationBadge } from './AuthenticationBadge'
import { MarketInsights } from './MarketInsights'
import { CollectorMotivation } from './CollectorMotivation'
interface ItemDetailModalProps {
item: CuratedItem
isOpen: boolean
onClose: () => void
}
export function ItemDetailModal({ item, isOpen, onClose }: ItemDetailModalProps) {
const { state, toggleWishlist } = useApp()
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const isWishlisted = state.wishlist.includes(item.id)
if (!isOpen) return null
const handleWishlistClick = () => {
toggleWishlist(item.id)
}
const handleEbayClick = () => {
window.open(item.ebayUrl, '_blank')
}
const getRarityStars = (rarity: number) => {
return '★'.repeat(rarity) + '☆'.repeat(5 - rarity)
}
const getConditionColor = (condition: string) => {
switch (condition) {
case 'new': return '#2D6A6A'
case 'like-new': return '#4A7C59'
case 'excellent': return '#C89C2B'
case 'very-good': return '#D4A574'
case 'good': return '#8B7D6B'
case 'acceptable': return '#7B2E2E'
default: return '#8B7D6B'
}
}
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(30, 26, 23, 0.8)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}
onClick={onClose}
>
<div
style={{
backgroundColor: '#F7F1E1',
borderRadius: '8px',
maxWidth: '900px',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
position: 'relative',
boxShadow: '0 20px 40px rgba(30, 26, 23, 0.3)'
}}
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'rgba(30, 26, 23, 0.1)',
border: 'none',
borderRadius: '50%',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '18px',
color: '#1E1A17',
zIndex: 10
}}
>
×
</button>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px', padding: '32px' }}>
{/* Left Column - Images */}
<div>
{/* Main Image */}
<div style={{
width: '100%',
height: '300px',
backgroundImage: `url("${item.images[currentImageIndex]}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
border: '2px solid #8B7D6B',
borderRadius: '8px',
marginBottom: '16px',
position: 'relative'
}}>
{/* Image Navigation */}
{item.images.length > 1 && (
<>
<button
onClick={() => setCurrentImageIndex(Math.max(0, currentImageIndex - 1))}
disabled={currentImageIndex === 0}
style={{
position: 'absolute',
left: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(30, 26, 23, 0.7)',
color: '#F7F1E1',
border: 'none',
borderRadius: '50%',
width: '32px',
height: '32px',
cursor: currentImageIndex === 0 ? 'not-allowed' : 'pointer',
opacity: currentImageIndex === 0 ? 0.5 : 1
}}
>
</button>
<button
onClick={() => setCurrentImageIndex(Math.min(item.images.length - 1, currentImageIndex + 1))}
disabled={currentImageIndex === item.images.length - 1}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(30, 26, 23, 0.7)',
color: '#F7F1E1',
border: 'none',
borderRadius: '50%',
width: '32px',
height: '32px',
cursor: currentImageIndex === item.images.length - 1 ? 'not-allowed' : 'pointer',
opacity: currentImageIndex === item.images.length - 1 ? 0.5 : 1
}}
>
</button>
</>
)}
</div>
{/* Thumbnail Images */}
{item.images.length > 1 && (
<div style={{ display: 'flex', gap: '8px', overflowX: 'auto' }}>
{item.images.map((image, index) => (
<button
key={index}
onClick={() => setCurrentImageIndex(index)}
style={{
width: '60px',
height: '60px',
backgroundImage: `url("${image}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
border: index === currentImageIndex ? '2px solid #C89C2B' : '2px solid #8B7D6B',
borderRadius: '4px',
cursor: 'pointer',
flexShrink: 0
}}
/>
))}
</div>
)}
{/* Authentication Badge */}
<div style={{ marginTop: '16px' }}>
<AuthenticationBadge item={item} detailed={true} />
</div>
</div>
{/* Right Column - Details */}
<div>
{/* Category Badge */}
<div style={{
display: 'inline-block',
backgroundColor: '#C89C2B',
color: '#F7F1E1',
padding: '4px 12px',
borderRadius: '4px',
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: '12px'
}}>
{item.category}
</div>
{/* Title */}
<h1 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '32px',
fontWeight: '900',
color: '#1E1A17',
lineHeight: '1.2',
marginBottom: '16px'
}}>
{item.title}
</h1>
{/* Price */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px'
}}>
<span style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '28px',
color: '#1E1A17'
}}>
${item.price}
</span>
{item.originalPrice && (
<span style={{
fontFamily: 'Spectral, serif',
fontSize: '18px',
color: '#8B7D6B',
textDecoration: 'line-through'
}}>
${item.originalPrice}
</span>
)}
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B'
}}>
+ ${item.shipping.cost} shipping
</span>
</div>
{/* Condition & Rarity */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
backgroundColor: getConditionColor(item.condition),
color: '#F7F1E1',
padding: '4px 12px',
borderRadius: '4px'
}}>
{item.condition}
</span>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<span style={{ color: '#C89C2B', fontSize: '16px' }}>
{getRarityStars(item.rarity)}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B',
textTransform: 'uppercase',
letterSpacing: '0.1em'
}}>
Rarity {item.rarity}/5
</span>
</div>
</div>
{/* Market Insights */}
<MarketInsights item={item} />
{/* Collector Motivation */}
<CollectorMotivation item={item} />
{/* Expert Notes */}
<div style={{
backgroundColor: 'rgba(139, 125, 107, 0.1)',
border: '1px solid rgba(139, 125, 107, 0.2)',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px'
}}>
<h4 style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B',
marginBottom: '8px'
}}>
Expert Notes
</h4>
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '14px',
lineHeight: '1.6',
color: '#1E1A17',
margin: 0,
fontStyle: 'italic'
}}>
"{item.expertNotes}"
</p>
</div>
{/* Tags */}
<div style={{ marginBottom: '24px' }}>
<h4 style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B',
marginBottom: '8px'
}}>
Tags
</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{item.tags.map(tag => (
<span key={tag} style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
backgroundColor: 'rgba(139, 125, 107, 0.1)',
border: '1px solid #8B7D6B',
color: '#8B7D6B',
padding: '4px 8px',
borderRadius: '4px'
}}>
{tag}
</span>
))}
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleEbayClick}
className="ticket-button"
style={{
flex: 1,
fontSize: '14px',
padding: '16px 24px',
textAlign: 'center'
}}
>
View on eBay
</button>
<button
onClick={handleWishlistClick}
style={{
padding: '16px',
backgroundColor: isWishlisted ? '#7B2E2E' : 'transparent',
color: isWishlisted ? '#F7F1E1' : '#7B2E2E',
border: '2px solid #7B2E2E',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '20px',
transition: 'all 0.3s ease'
}}
title={isWishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
>
{isWishlisted ? '❤️' : '🤍'}
</button>
</div>
{/* Seller Info */}
<div style={{
marginTop: '24px',
paddingTop: '16px',
borderTop: '1px solid rgba(139, 125, 107, 0.2)'
}}>
<h4 style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B',
marginBottom: '8px'
}}>
Seller Information
</h4>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '14px',
color: '#1E1A17'
}}>
<div>
<span style={{ fontFamily: 'Space Mono, monospace', fontWeight: 'bold' }}>
{item.seller.name}
</span>
<div style={{ fontSize: '12px', color: '#8B7D6B' }}>
{item.seller.rating} ({item.seller.feedback} reviews)
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px' }}>
Ships from: {item.shipping.location}
</div>
<div style={{ fontSize: '12px', color: '#8B7D6B' }}>
{item.shipping.international ? 'International shipping' : 'Domestic only'}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,210 @@
'use client'
import { CuratedItem } from '@/types'
interface MarketInsightsProps {
item: CuratedItem
}
export function MarketInsights({ item }: MarketInsightsProps) {
// Based on research: collectibles market growing at 5.75% CAGR
const getMarketTrend = () => {
const categoryTrends = {
photography: { trend: 'up', percentage: 12, reason: 'Film photography revival' },
music: { trend: 'up', percentage: 8, reason: 'Vinyl resurgence continues' },
books: { trend: 'stable', percentage: 3, reason: 'Steady collector interest' },
design: { trend: 'up', percentage: 15, reason: 'Mid-century modern demand' }
}
return categoryTrends[item.category] || { trend: 'stable', percentage: 5, reason: 'Market average' }
}
const getPriceAnalysis = () => {
if (!item.originalPrice) return null
const discount = Math.round(((item.originalPrice - item.price) / item.originalPrice) * 100)
const isGoodDeal = discount > 15
return { discount, isGoodDeal }
}
const getInvestmentScore = () => {
let score = 5 // Base score
// Rarity bonus
score += item.rarity * 1.5
// Authentication bonus
if (item.authenticity.verified) score += 2
// Category trend bonus
const trend = getMarketTrend()
if (trend.trend === 'up') score += 1.5
// Condition bonus
const conditionBonus = {
'new': 2,
'like-new': 1.5,
'excellent': 1,
'very-good': 0.5,
'good': 0,
'acceptable': -0.5
}
score += conditionBonus[item.condition] || 0
return Math.min(Math.max(Math.round(score), 1), 10)
}
const trend = getMarketTrend()
const priceAnalysis = getPriceAnalysis()
const investmentScore = getInvestmentScore()
const getTrendIcon = (trendType: string) => {
switch (trendType) {
case 'up': return '📈'
case 'down': return '📉'
default: return '➡️'
}
}
const getScoreColor = (score: number) => {
if (score >= 8) return '#2D6A6A'
if (score >= 6) return '#C89C2B'
if (score >= 4) return '#8B7D6B'
return '#7B2E2E'
}
return (
<div style={{
backgroundColor: 'rgba(45, 106, 106, 0.05)',
border: '1px solid rgba(45, 106, 106, 0.2)',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px'
}}>
<div style={{
width: '24px',
height: '24px',
backgroundColor: '#2D6A6A',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px'
}}>
📊
</div>
<h4 style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#2D6A6A',
margin: 0,
fontWeight: 'bold'
}}>
Market Intelligence
</h4>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '12px'
}}>
{/* Investment Score */}
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '20px',
fontWeight: 'bold',
color: getScoreColor(investmentScore),
marginBottom: '4px'
}}>
{investmentScore}/10
</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B'
}}>
Investment Score
</div>
</div>
{/* Market Trend */}
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '16px',
marginBottom: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px'
}}>
<span>{getTrendIcon(trend.trend)}</span>
<span style={{
fontWeight: 'bold',
color: trend.trend === 'up' ? '#2D6A6A' : trend.trend === 'down' ? '#7B2E2E' : '#8B7D6B'
}}>
+{trend.percentage}%
</span>
</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B'
}}>
Category Trend
</div>
</div>
{/* Price Analysis */}
{priceAnalysis && (
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: priceAnalysis.isGoodDeal ? '#2D6A6A' : '#8B7D6B',
marginBottom: '4px'
}}>
-{priceAnalysis.discount}%
</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B'
}}>
{priceAnalysis.isGoodDeal ? 'Great Deal' : 'Fair Price'}
</div>
</div>
)}
</div>
{/* Trend Reason */}
<div style={{
marginTop: '12px',
padding: '8px',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'Spectral, serif',
color: '#1E1A17',
fontStyle: 'italic'
}}>
💡 {trend.reason}
</div>
</div>
)
}

View File

@ -0,0 +1,72 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface RevealAnimationProps {
children: React.ReactNode
direction?: 'up' | 'down' | 'left' | 'right' | 'fade' | 'scale'
delay?: number
duration?: number
className?: string
style?: React.CSSProperties
}
export function RevealAnimation({
children,
direction = 'up',
delay = 0,
duration = 0.6,
className = '',
style = {}
}: RevealAnimationProps) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => {
setIsVisible(true)
}, delay * 1000)
}
},
{ threshold: 0.1, rootMargin: '50px' }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => observer.disconnect()
}, [delay])
const getTransform = () => {
if (isVisible) return 'translate3d(0, 0, 0) scale(1)'
switch (direction) {
case 'up': return 'translate3d(0, 50px, 0) scale(1)'
case 'down': return 'translate3d(0, -50px, 0) scale(1)'
case 'left': return 'translate3d(50px, 0, 0) scale(1)'
case 'right': return 'translate3d(-50px, 0, 0) scale(1)'
case 'scale': return 'translate3d(0, 0, 0) scale(0.8)'
default: return 'translate3d(0, 50px, 0) scale(1)'
}
}
return (
<div
ref={ref}
className={className}
style={{
opacity: isVisible ? 1 : 0,
transform: getTransform(),
transition: `all ${duration}s cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
willChange: 'transform, opacity',
...style
}}
>
{children}
</div>
)
}

View File

@ -0,0 +1,153 @@
'use client'
import { useEffect, useState } from 'react'
export function ScrollEffects() {
const [scrollY, setScrollY] = useState(0)
const [isVisible, setIsVisible] = useState(false)
const [scrollProgress, setScrollProgress] = useState(0)
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
const handleScroll = () => {
const currentScrollY = window.scrollY
setScrollY(currentScrollY)
setIsVisible(currentScrollY > 100)
// Calculate scroll progress safely
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollableHeight = documentHeight - windowHeight
if (scrollableHeight > 0) {
const progress = Math.min((currentScrollY / scrollableHeight) * 100, 100)
setScrollProgress(progress)
}
}
// Only add event listener on client side
if (typeof window !== 'undefined') {
window.addEventListener('scroll', handleScroll, { passive: true })
// Initial calculation
handleScroll()
}
return () => {
if (typeof window !== 'undefined') {
window.removeEventListener('scroll', handleScroll)
}
}
}, [])
const scrollToTop = () => {
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// Don't render until mounted on client
if (!isMounted) {
return null
}
return (
<>
{/* Floating Back to Top Button */}
<button
onClick={scrollToTop}
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
width: '56px',
height: '56px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #C89C2B, #7B2E2E)',
color: '#F7F1E1',
border: 'none',
cursor: 'pointer',
fontSize: '20px',
zIndex: 1000,
boxShadow: '0 4px 12px rgba(30, 26, 23, 0.3)',
transform: isVisible ? 'translateY(0) scale(1)' : 'translateY(100px) scale(0)',
transition: 'all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="Back to top"
>
</button>
{/* Scroll Progress Bar */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: `${scrollProgress}%`,
height: '3px',
background: 'linear-gradient(90deg, #C89C2B, #7B2E2E)',
zIndex: 1001,
transition: 'width 0.1s ease'
}}
/>
{/* Floating Navigation Dots */}
<div
style={{
position: 'fixed',
right: '24px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '12px',
opacity: isVisible ? 1 : 0,
transition: 'opacity 0.3s ease'
}}
>
{[
{ id: 'hero', label: 'Top' },
{ id: 'latest-picks', label: 'Items' },
{ id: 'newsletter', label: 'Newsletter' }
].map((section) => (
<button
key={section.id}
onClick={() => {
const element = document.getElementById(section.id)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
border: '2px solid #C89C2B',
background: 'transparent',
cursor: 'pointer',
transition: 'all 0.3s ease',
position: 'relative'
}}
title={section.label}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#C89C2B'
e.currentTarget.style.transform = 'scale(1.2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.transform = 'scale(1)'
}}
/>
))}
</div>
</>
)
}

View File

@ -0,0 +1,271 @@
'use client'
import { useState, useEffect } from 'react'
import { useApp } from '@/contexts/AppContext'
interface SearchBarProps {
placeholder?: string
showFilters?: boolean
}
export function SearchBar({ placeholder = "Search vintage treasures...", showFilters = true }: SearchBarProps) {
const { state, searchItems } = useApp()
const [query, setQuery] = useState(state.searchQuery)
const [showFilterPanel, setShowFilterPanel] = useState(false)
const [filters, setFilters] = useState(state.searchFilters)
useEffect(() => {
const debounceTimer = setTimeout(() => {
searchItems(query, filters)
}, 300)
return () => clearTimeout(debounceTimer)
}, [query, filters])
const handleFilterChange = (key: string, value: any) => {
const newFilters = { ...filters, [key]: value }
setFilters(newFilters)
}
const clearFilters = () => {
setFilters({})
setQuery('')
}
return (
<div className="search-container" style={{ position: 'relative', width: '100%', maxWidth: '600px' }}>
{/* Search Input */}
<div style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
backgroundColor: 'white',
border: '2px solid #8B7D6B',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
padding: '12px 16px',
color: '#8B7D6B',
fontSize: '18px'
}}>
🔍
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
padding: '12px 0',
border: 'none',
outline: 'none',
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#1E1A17',
backgroundColor: 'transparent'
}}
/>
{showFilters && (
<button
onClick={() => setShowFilterPanel(!showFilterPanel)}
style={{
padding: '12px 16px',
border: 'none',
backgroundColor: showFilterPanel ? '#C89C2B' : 'transparent',
color: showFilterPanel ? '#F7F1E1' : '#8B7D6B',
cursor: 'pointer',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
transition: 'all 0.3s ease'
}}
>
Filters
</button>
)}
</div>
{/* Filter Panel */}
{showFilterPanel && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
backgroundColor: 'white',
border: '2px solid #8B7D6B',
borderTop: 'none',
padding: '24px',
zIndex: 10,
boxShadow: '0 4px 8px rgba(30, 26, 23, 0.1)'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '24px'
}}>
{/* Category Filter */}
<div>
<label style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B',
display: 'block',
marginBottom: '8px'
}}>
Category
</label>
<select
value={filters.category || ''}
onChange={(e) => handleFilterChange('category', e.target.value || undefined)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #8B7D6B',
borderRadius: '4px',
fontFamily: 'Spectral, serif',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="">All Categories</option>
<option value="photography">Photography</option>
<option value="music">Music</option>
<option value="books">Books</option>
<option value="design">Design</option>
</select>
</div>
{/* Price Range */}
<div>
<label style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B',
display: 'block',
marginBottom: '8px'
}}>
Price Range
</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="number"
placeholder="Min"
value={filters.priceMin || ''}
onChange={(e) => handleFilterChange('priceMin', e.target.value ? Number(e.target.value) : undefined)}
style={{
flex: 1,
padding: '8px 12px',
border: '1px solid #8B7D6B',
borderRadius: '4px',
fontFamily: 'Spectral, serif',
fontSize: '14px'
}}
/>
<span style={{ color: '#8B7D6B' }}>-</span>
<input
type="number"
placeholder="Max"
value={filters.priceMax || ''}
onChange={(e) => handleFilterChange('priceMax', e.target.value ? Number(e.target.value) : undefined)}
style={{
flex: 1,
padding: '8px 12px',
border: '1px solid #8B7D6B',
borderRadius: '4px',
fontFamily: 'Spectral, serif',
fontSize: '14px'
}}
/>
</div>
</div>
{/* Sort By */}
<div>
<label style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#8B7D6B',
display: 'block',
marginBottom: '8px'
}}>
Sort By
</label>
<select
value={filters.sortBy || 'date'}
onChange={(e) => handleFilterChange('sortBy', e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #8B7D6B',
borderRadius: '4px',
fontFamily: 'Spectral, serif',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="date">Newest First</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="rarity">Rarity</option>
</select>
</div>
</div>
{/* Filter Actions */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '24px',
paddingTop: '16px',
borderTop: '1px solid #8B7D6B'
}}>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B'
}}>
{state.filteredItems.length} items found
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={clearFilters}
style={{
padding: '8px 16px',
border: '1px solid #8B7D6B',
backgroundColor: 'transparent',
color: '#8B7D6B',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
cursor: 'pointer',
borderRadius: '4px'
}}
>
Clear
</button>
<button
onClick={() => setShowFilterPanel(false)}
className="ticket-button"
style={{ fontSize: '12px', padding: '8px 16px' }}
>
Apply
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,350 @@
'use client'
import { useApp } from '@/contexts/AppContext'
import { Badge } from '@/types'
export function UserProfile() {
const { state } = useApp()
const { user } = state
if (!user) return null
const getXPProgress = () => {
const xpForNextLevel = user.level * 100 // Simple formula: level * 100 XP needed
const currentLevelXP = (user.level - 1) * 100
const progressXP = user.xp - currentLevelXP
const neededXP = xpForNextLevel - currentLevelXP
return {
progress: progressXP,
needed: neededXP,
percentage: (progressXP / neededXP) * 100
}
}
const getBadgeRarityColor = (rarity: Badge['rarity']) => {
switch (rarity) {
case 'common': return '#8B7D6B'
case 'rare': return '#C89C2B'
case 'legendary': return '#7B2E2E'
default: return '#8B7D6B'
}
}
const xpProgress = getXPProgress()
return (
<div style={{
background: 'linear-gradient(135deg, #233043 0%, #2D6A6A 100%)',
color: '#F7F1E1',
padding: '24px',
borderRadius: '8px',
position: 'relative',
overflow: 'hidden'
}}>
{/* Background Pattern */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'radial-gradient(circle at 20% 80%, rgba(200, 156, 43, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(123, 46, 46, 0.1) 0%, transparent 50%)',
pointerEvents: 'none'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '24px' }}>
{/* Avatar */}
<div style={{
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #C89C2B, #7B2E2E)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
fontWeight: 'bold',
color: '#F7F1E1',
border: '3px solid rgba(247, 241, 225, 0.2)'
}}>
{user.username.charAt(0).toUpperCase()}
</div>
{/* User Info */}
<div style={{ flex: 1 }}>
<h3 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
margin: '0 0 4px 0',
color: '#F7F1E1'
}}>
{user.username}
</h3>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'rgba(247, 241, 225, 0.7)'
}}>
Level {user.level} Collector {user.xp} XP
</div>
</div>
{/* Streak */}
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'rgba(247, 241, 225, 0.1)',
borderRadius: '8px',
border: '1px solid rgba(247, 241, 225, 0.2)'
}}>
<div style={{ fontSize: '20px', marginBottom: '4px' }}>🔥</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em'
}}>
{user.streak} Day Streak
</div>
</div>
</div>
{/* XP Progress Bar */}
<div style={{ marginBottom: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'rgba(247, 241, 225, 0.7)'
}}>
Progress to Level {user.level + 1}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#C89C2B'
}}>
{xpProgress.progress}/{xpProgress.needed} XP
</span>
</div>
<div style={{
width: '100%',
height: '8px',
backgroundColor: 'rgba(247, 241, 225, 0.1)',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.min(xpProgress.percentage, 100)}%`,
height: '100%',
background: 'linear-gradient(90deg, #C89C2B, #7B2E2E)',
borderRadius: '4px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
{/* Stats Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '16px',
marginBottom: '24px'
}}>
<div style={{
textAlign: 'center',
padding: '12px',
background: 'rgba(247, 241, 225, 0.05)',
borderRadius: '8px',
border: '1px solid rgba(247, 241, 225, 0.1)'
}}>
<div style={{
fontSize: '24px',
fontWeight: 'bold',
color: '#C89C2B',
marginBottom: '4px'
}}>
{user.wishlist.length}
</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'rgba(247, 241, 225, 0.7)'
}}>
Wishlisted
</div>
</div>
<div style={{
textAlign: 'center',
padding: '12px',
background: 'rgba(247, 241, 225, 0.05)',
borderRadius: '8px',
border: '1px solid rgba(247, 241, 225, 0.1)'
}}>
<div style={{
fontSize: '24px',
fontWeight: 'bold',
color: '#C89C2B',
marginBottom: '4px'
}}>
{user.badges.length}
</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'rgba(247, 241, 225, 0.7)'
}}>
Badges
</div>
</div>
<div style={{
textAlign: 'center',
padding: '12px',
background: 'rgba(247, 241, 225, 0.05)',
borderRadius: '8px',
border: '1px solid rgba(247, 241, 225, 0.1)'
}}>
<div style={{
fontSize: '24px',
fontWeight: 'bold',
color: '#C89C2B',
marginBottom: '4px'
}}>
{user.collections.length}
</div>
<div style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: 'rgba(247, 241, 225, 0.7)'
}}>
Collections
</div>
</div>
</div>
{/* Badges */}
{user.badges.length > 0 && (
<div>
<h4 style={{
fontFamily: 'Space Mono, monospace',
fontSize: '14px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#C89C2B',
marginBottom: '12px'
}}>
Recent Badges
</h4>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
{user.badges.slice(0, 4).map((badge) => (
<div
key={badge.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
background: 'rgba(247, 241, 225, 0.1)',
borderRadius: '8px',
border: `1px solid ${getBadgeRarityColor(badge.rarity)}`,
position: 'relative'
}}
title={badge.description}
>
<span style={{ fontSize: '16px' }}>{badge.icon}</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: getBadgeRarityColor(badge.rarity)
}}>
{badge.name}
</span>
{badge.rarity === 'legendary' && (
<div style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#7B2E2E',
animation: 'pulse 2s infinite'
}} />
)}
</div>
))}
</div>
</div>
)}
{/* Quick Actions */}
<div style={{
display: 'flex',
gap: '8px',
marginTop: '20px',
paddingTop: '20px',
borderTop: '1px solid rgba(247, 241, 225, 0.1)'
}}>
<button
className="stamp-button"
style={{
fontSize: '11px',
padding: '8px 12px',
backgroundColor: 'rgba(247, 241, 225, 0.1)',
borderColor: 'rgba(247, 241, 225, 0.3)',
color: '#F7F1E1'
}}
>
View Wishlist
</button>
<button
className="stamp-button"
style={{
fontSize: '11px',
padding: '8px 12px',
backgroundColor: 'rgba(247, 241, 225, 0.1)',
borderColor: 'rgba(247, 241, 225, 0.3)',
color: '#F7F1E1'
}}
>
Settings
</button>
</div>
</div>
{/* CSS Animation for legendary badge pulse */}
<style jsx>{`
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
`}</style>
</div>
)
}

353
src/contexts/AppContext.tsx Normal file
View File

@ -0,0 +1,353 @@
'use client'
import React, { createContext, useContext, useReducer, useEffect } from 'react'
import { CuratedItem, UserProfile, SearchFilters, Badge } from '@/types'
import { mockItems, mockBadges } from '@/data/mockItems'
// State Interface
interface AppState {
// Items & Search
items: CuratedItem[]
filteredItems: CuratedItem[]
searchFilters: SearchFilters
searchQuery: string
// User & Profile
user: UserProfile | null
isAuthenticated: boolean
// UI State
loading: boolean
error: string | null
// Features
wishlist: string[]
recentlyViewed: string[]
}
// Action Types
type AppAction =
| { type: 'SET_ITEMS'; payload: CuratedItem[] }
| { type: 'SET_FILTERED_ITEMS'; payload: CuratedItem[] }
| { type: 'SET_SEARCH_QUERY'; payload: string }
| { type: 'SET_SEARCH_FILTERS'; payload: SearchFilters }
| { type: 'SET_USER'; payload: UserProfile | null }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'TOGGLE_WISHLIST'; payload: string }
| { type: 'ADD_TO_RECENTLY_VIEWED'; payload: string }
| { type: 'CLEAR_SEARCH' }
// Initial State
const initialState: AppState = {
items: [],
filteredItems: [],
searchFilters: {},
searchQuery: '',
user: null,
isAuthenticated: false,
loading: false,
error: null,
wishlist: [],
recentlyViewed: []
}
// Reducer
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SET_ITEMS':
return {
...state,
items: action.payload,
filteredItems: action.payload
}
case 'SET_FILTERED_ITEMS':
return {
...state,
filteredItems: action.payload
}
case 'SET_SEARCH_QUERY':
return {
...state,
searchQuery: action.payload
}
case 'SET_SEARCH_FILTERS':
return {
...state,
searchFilters: action.payload
}
case 'SET_USER':
return {
...state,
user: action.payload,
isAuthenticated: action.payload !== null,
wishlist: action.payload?.wishlist || []
}
case 'SET_LOADING':
return {
...state,
loading: action.payload
}
case 'SET_ERROR':
return {
...state,
error: action.payload
}
case 'TOGGLE_WISHLIST':
const itemId = action.payload
const isWishlisted = state.wishlist.includes(itemId)
const newWishlist = isWishlisted
? state.wishlist.filter(id => id !== itemId)
: [...state.wishlist, itemId]
return {
...state,
wishlist: newWishlist,
user: state.user ? {
...state.user,
wishlist: newWishlist
} : null
}
case 'ADD_TO_RECENTLY_VIEWED':
const viewedItemId = action.payload
const newRecentlyViewed = [
viewedItemId,
...state.recentlyViewed.filter(id => id !== viewedItemId)
].slice(0, 10) // Keep only last 10
return {
...state,
recentlyViewed: newRecentlyViewed
}
case 'CLEAR_SEARCH':
return {
...state,
searchQuery: '',
searchFilters: {},
filteredItems: state.items
}
default:
return state
}
}
// Context
const AppContext = createContext<{
state: AppState
dispatch: React.Dispatch<AppAction>
// Helper functions
searchItems: (query: string, filters?: SearchFilters) => void
toggleWishlist: (itemId: string) => void
addToRecentlyViewed: (itemId: string) => void
getItemById: (id: string) => CuratedItem | undefined
getFeaturedItems: () => CuratedItem[]
getItemsByCategory: (category: string) => CuratedItem[]
checkBadgeProgress: (userId: string) => Badge[]
} | null>(null)
// Provider Component
export function AppProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState)
// Initialize data
useEffect(() => {
dispatch({ type: 'SET_ITEMS', payload: mockItems })
// Load user from localStorage
const savedUser = localStorage.getItem('curatedFindsUser')
if (savedUser) {
try {
const user = JSON.parse(savedUser)
dispatch({ type: 'SET_USER', payload: user })
} catch (error) {
console.error('Error loading user from localStorage:', error)
}
} else {
// Create demo user
const demoUser: UserProfile = {
id: 'demo-user',
email: 'demo@curatedfinds.com',
username: 'CollectorDemo',
level: 3,
xp: 250,
badges: [mockBadges[0], mockBadges[6]], // First Find + Daily Visitor
collections: [],
wishlist: ['1', '3'], // Pre-populate with some items
streak: 5,
preferences: {
categories: ['photography', 'books'],
priceRange: { min: 0, max: 500 },
notifications: {
email: true,
push: false,
priceAlerts: true,
newFinds: true,
weeklyDigest: true
},
privacy: {
showCollections: true,
showActivity: false
}
},
joinDate: new Date('2024-11-01'),
lastActive: new Date()
}
dispatch({ type: 'SET_USER', payload: demoUser })
}
}, [])
// Save user to localStorage when it changes
useEffect(() => {
if (state.user) {
localStorage.setItem('curatedFindsUser', JSON.stringify(state.user))
}
}, [state.user])
// Helper Functions
const searchItems = (query: string, filters: SearchFilters = {}) => {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query })
dispatch({ type: 'SET_SEARCH_FILTERS', payload: filters })
let filtered = [...state.items]
// Text search
if (query.trim()) {
const searchTerm = query.toLowerCase()
filtered = filtered.filter(item =>
item.title.toLowerCase().includes(searchTerm) ||
item.expertNotes.toLowerCase().includes(searchTerm) ||
item.tags.some(tag => tag.toLowerCase().includes(searchTerm))
)
}
// Category filter
if (filters.category) {
filtered = filtered.filter(item => item.category === filters.category)
}
// Price range filter
if (filters.priceMin !== undefined) {
filtered = filtered.filter(item => item.price >= filters.priceMin!)
}
if (filters.priceMax !== undefined) {
filtered = filtered.filter(item => item.price <= filters.priceMax!)
}
// Condition filter
if (filters.condition && filters.condition.length > 0) {
filtered = filtered.filter(item => filters.condition!.includes(item.condition))
}
// Rarity filter
if (filters.rarity && filters.rarity.length > 0) {
filtered = filtered.filter(item => filters.rarity!.includes(item.rarity))
}
// Featured filter
if (filters.featured) {
filtered = filtered.filter(item => item.featured)
}
// Sorting
switch (filters.sortBy) {
case 'price-low':
filtered.sort((a, b) => a.price - b.price)
break
case 'price-high':
filtered.sort((a, b) => b.price - a.price)
break
case 'rarity':
filtered.sort((a, b) => b.rarity - a.rarity)
break
case 'date':
default:
filtered.sort((a, b) => new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime())
break
}
dispatch({ type: 'SET_FILTERED_ITEMS', payload: filtered })
}
const toggleWishlist = (itemId: string) => {
dispatch({ type: 'TOGGLE_WISHLIST', payload: itemId })
}
const addToRecentlyViewed = (itemId: string) => {
dispatch({ type: 'ADD_TO_RECENTLY_VIEWED', payload: itemId })
}
const getItemById = (id: string) => {
return state.items.find(item => item.id === id)
}
const getFeaturedItems = () => {
return state.items.filter(item => item.featured)
}
const getItemsByCategory = (category: string) => {
return state.items.filter(item => item.category === category)
}
const checkBadgeProgress = (userId: string): Badge[] => {
if (!state.user) return []
const earnedBadges: Badge[] = []
const wishlistCount = state.user.wishlist.length
// First Find badge
if (wishlistCount >= 1 && !state.user.badges.find(b => b.id === 'first-find')) {
earnedBadges.push(mockBadges.find(b => b.id === 'first-find')!)
}
// Category-specific badges
const photoItems = state.user.wishlist.filter(id => {
const item = getItemById(id)
return item?.category === 'photography'
}).length
if (photoItems >= 10 && !state.user.badges.find(b => b.id === 'camera-collector')) {
earnedBadges.push(mockBadges.find(b => b.id === 'camera-collector')!)
}
return earnedBadges
}
const contextValue = {
state,
dispatch,
searchItems,
toggleWishlist,
addToRecentlyViewed,
getItemById,
getFeaturedItems,
getItemsByCategory,
checkBadgeProgress
}
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
)
}
// Hook to use the context
export function useApp() {
const context = useContext(AppContext)
if (!context) {
throw new Error('useApp must be used within an AppProvider')
}
return context
}
export default AppContext

656
src/data/blogPosts.ts Normal file
View File

@ -0,0 +1,656 @@
export interface BlogPost {
id: string
title: string
slug: string
excerpt: string
content: string
images: string[]
datePublished: Date
category: string
tags: string[]
featured: boolean
previewImage?: string
}
// Static image imports for bundling
import magazine1 from '@/app/blogpostimg/magazine1.jpg'
import magazine2 from '@/app/blogpostimg/magazine2.jpg'
import magazine3 from '@/app/blogpostimg/magazine3.jpg'
import magazine4 from '@/app/blogpostimg/magazine4.jpg'
import matches1 from '@/app/blogpostimg/matches1.jpg'
import matches2 from '@/app/blogpostimg/matches2.jpg'
import matches3 from '@/app/blogpostimg/matches3.jpg'
import matches4 from '@/app/blogpostimg/matches4.jpg'
import matches5 from '@/app/blogpostimg/matches5.jpg'
import leather_clutch1 from '@/app/blogpostimg/leather_clutch1.jpg'
import leather_clutch2 from '@/app/blogpostimg/leather_clutch2.jpg'
import leather_clutch3 from '@/app/blogpostimg/leather_clutch3.jpg'
import leather_clutch4 from '@/app/blogpostimg/leather_clutch4.jpg'
import leather_clutch5 from '@/app/blogpostimg/leather_clutch5.jpg'
import chatgptMagazine from '@/app/blogpostimg/ChatGPT _magazine.png'
import chatgptMatches from '@/app/blogpostimg/ChatGPT_matches.png'
import chatgptLeatherClutch from '@/app/blogpostimg/ChatGPT_leather_clutch.png'
import cardDeckBanner from '@/app/blogpostimg/card_deck_banner.png'
import deckCard1 from '@/app/blogpostimg/deck_card1.png'
import deckCard2 from '@/app/blogpostimg/deck_card2.png'
import deckCard3 from '@/app/blogpostimg/deck_card3.png'
import deckCard4 from '@/app/blogpostimg/deck_card4.png'
import deckCard5 from '@/app/blogpostimg/deck_card5.png'
import deckCard6 from '@/app/blogpostimg/deck_card6.png'
import dollBanner from '@/app/blogpostimg/doll_banner.png'
import doll1 from '@/app/blogpostimg/doll1.png'
import doll2 from '@/app/blogpostimg/doll2.png'
import doll3 from '@/app/blogpostimg/doll3.png'
import timeMagazineBanner from '@/app/blogpostimg/time_magazine_banner.png'
import timeMagazine1 from '@/app/blogpostimg/time_magazine1.png'
import timeMagazine2 from '@/app/blogpostimg/time_magazine2.png'
import timeMagazine3 from '@/app/blogpostimg/time_magazine3.png'
export const blogPosts: BlogPost[] = [
{
id: 'time-magazine-1946',
title: 'One remarkable 1946 TIME—original Omar Bradley cover, carefully graded and ready to ship',
slug: 'time-magazine-1946-omar-bradley',
excerpt: 'Every so often I list something I wish I could keep. This is one of those weeks. A single, well-kept original TIME magazine from April 1, 1946.',
content: `Every so often I list something I wish I could keep. This is one of those weeks. I'm putting up a single, well-kept original **TIME** magazine—**April 1, 1946**—the **Omar Bradley Time cover** you've probably seen in history books, but rarely in the wild. If you've been looking for a **vintage Time magazine for sale** that's more "holdable history" than dusty attic find, this one's worth a closer look.
I buy what I love, and I describe it like I'd want it described to me: specifics, not superlatives. I'm a collector first and a seller second, which is why you'll see both the highlights and the scuffs called out plainly.
## What I'm selling & why
This copy came from a modest estate lot I picked up near Düsseldorfan engineer's place with the neatest shelves I've ever seen. The magazines had been stored upright in a cedar cabinet, away from sunlight and damp. When I cracked the box, the first thing I noticed (after that faint library smell) was the cover: Boris Chaliapin's painted portrait of **General Omar N. Bradley**, strong stare, clean red border. Under the title, a line asks, "How long does a war last?"which is exactly what the features inside wrestle with.
I'm listing it because I already own a keeper of this exact **Time magazine 1946** issue. Duplicates go to people who'll actually read them, display them, and enjoy the odd mix of gravitas and mid-century charm.
## Featured item (full details)
**TIME Magazine April 1, 1946 ("The Veterans' General Bradley")**
- **Condition:** **Good to Very Good** for age. Tight staples, square spine for a weekly, gentle rub on the red frame, light edge wear, no writing or stamps, no missing pages. Paper is supple with the expected warm tone (no brittleness).
- **Standout details:**
- **Cover:** Original Boris Chaliapin portrait of Omar Bradley; color is still lively.
- **Back cover:** Iconic Camel ad"More Doctors Smoke Camels Than Any Other Cigarette!"a conversation piece all by itself.
- **Inside:** Post-war features on veterans' benefits, industry ramp-up, and domestic life. There's also a handsome B.F. Goodrich spread and, yes, **Elsie the Cow**.
- **Price range:** **1624**, depending on whether you choose bag+board only or the archival clam-shell upgrade.
- **Why it's special:** This is the moment Bradley shifts from wartime commander to the public face of veteran care. If you collect military history covers, early Cold War context, or just appreciate mid-century editorial design, it checks a lot of boxes while staying genuinely readable.
## Behind the scenes (sourcing, care, and packaging)
I don't do heavy "restoration." No pressing, bleaching, or trimming. Here's my routine:
- **Screening:** I reject anything with mold, active foxing, or insect damage. This copy passed easily.
- **Surface clean:** Light pass with a vinyl eraser on the back cover and margins to lift handling marksnothing aggressive.
- **Flattening:** A couple of days under weight between buffered sheets to relax minor waves from storage.
- **Documentation:** I log notable ads/features and shoot close-ups of edges, corners, and staples so you can eyeball condition yourself.
Shipping is nerdy on purpose. The magazine goes into **mylar** with a **half-back board**, wrapped in kraft, then into a **rigid box** with corner protection. If you choose the **archival clam-shell** upgrade, you'll get a labeled, acid-free case so it can live upright on a shelf without spine stress. (And yes, I've done the "knee-height drop test." My packaging holds up.)
## Trust & proof
I've been selling vintage periodicals for years and keep things simple: accurate descriptions, fast replies, careful packing. You'll see consistent **5-star feedback** about honesty and condition photos. I ship **within 12 business days** from NRW. If something isn't as described, I make it rightcollectors deserve straight dealing.
## FAQ
**Is this a reprint?**
No. This is an **original 1946** weekly magazine. You'll see the masthead date and period stock/printing consistent with **Time magazine 1946** issues. I'm happy to send extra spine and paper-tone photos on request.
**How do you grade condition?**
I don't assign numeric grades. Instead I call out the important stuff: staple tightness, spine wear, edge chips, page tone, and any writing or stamps. In comic terms, this copy sits around **Good/VG**, mostly held back by light border rub and expected age toning.
**Can you combine shipping if I add another item?**
Sure thing. If you grab something else from my store, I'll combine shipping and refund any overage automatically. This magazine will always ship **boxed**, even when combined.
**What's the return policy?**
**30 days.** If I missed a significant flaw, I cover return shipping; if you change your mind, no problemsend it back in the same protective materials and I'll process a quick refund.
**How do you confirm authenticity?**
Beyond dates and typography, I rely on period stock, staple style, and content cues (ads, features, layout grid). Provenance helps too; this one came from a tidy, smoke-free estate with a small run of mid-40s titles stored upright.
## Ready to make it yours?
If **vintage Time magazine for sale** is in your search bar, this is precisely the kind of copy you hope to click: honest condition, lots of photos, and no surprises. It reads beautifully, displays even better, and brings that immediate post-war voice into your handsan editorial team trying to make sense of victory, grief, and the next chapter.
**Grab it here:** <a href="https://www.ebay.com/itm/396997013864?itmmeta=01K3RFYKHM0J79JBGG54EEB6RW&hash=item5c6eddb168:g:4dwAAeSwoqJorKN5&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
I pack the same day when I can, the next business day when I can't. If you want the **archival clam-shell**, select the upgrade at checkout or just drop me a note—happy to add it before I seal the box. And if you're the framing type, say the word; I'll mark the Camel back cover edge that looks best in a mat so you don't lose the good bits under glass.
Thanks for readingand for keeping these survivors in circulation. I like to think the editors who put this issue together would be pleased to know it's still being opened, handled, and talked about in 2025.`,
images: [
magazine1.src,
magazine2.src,
magazine3.src,
magazine4.src
],
datePublished: new Date('2025-01-15'),
category: 'Vintage Magazines',
tags: ['TIME Magazine', 'Omar Bradley', '1946', 'Vintage', 'Military History', 'Collectibles'],
featured: true,
previewImage: chatgptMagazine.src
},
{
id: 'vintage-las-vegas-matchbooks',
title: 'Vintage Las Vegas Matchbooks — New eBay Drop',
slug: 'vintage-las-vegas-matchbooks-ebay-drop',
excerpt: 'If you love the thrill of neon and the hush of a carpeted casino hallway, you\'ll like this: a fresh batch of vintage Las Vegas matchbooks.',
content: `If you love the thrill of neon and the hush of a carpeted casino hallway, you'll like this: I've just listed a fresh batch of vintage Las Vegas matchbooks—clear photos, fast shipping, easy returns.
They're small, affordable, and they tell big stories. One flips open and suddenly you're back under a pink marquee.
## What I'm selling & why
This release is a curated mix of **vintage Las Vegas matchbooks**, plus a few road-trip cousins from Texas, Colorado, and New Mexico. You'll find **casino matchbooks** from the Strip and Fremont Street alongside **hotel matchbooks** from the motor-inn era. Most are unstruck; many still have that glossy sheen that pops in a frame.
Why me? My family kept a shoebox of "freebies" from road stops. Dad grabbed anything with a good logo. Mom favored the typography. My job now is to rescue the best ones, research the property era, photograph them honestly, and send them to people who'll actually enjoy them. I list these because ephemera deserves a life outside a carton.
## Featured listings (handpicked)
### 1) Landmark Hotel & Casino Space-Age Tower Cover
**Condition:** Unstruck; crisp fold; light edge rub only.
**Standout detail:** Stylized tower silhouette, all Jet-Age optimism.
**Price range:** $9$15.
**Why it's special:** The Landmark is long gone, which gives this cover that perfect "lost Vegas" energy. Pair it with Stardust in a two-piece frame for instant conversation.
_Caption:_ Landmark Hotel & Casinospace-age tower art, clean and glossy.
### 2) Stardust Resort & Casino Starburst Logo
**Condition:** Excellent; bright color; clean striker.
**Standout detail:** That exploding starburstVegas without saying Vegas.
**Price range:** $8$12.
**Why it's special:** If you collect **casino matchbooks**, this one's a keystone. I've had buyers tell me it's the piece that finally convinced their partner a mini-display was "actually kind of cool."
_Caption:_ Stardust starburstcrisp colors ready for a display frame.
### 3) Imperial Palace Two-Logo Mini Set
**Condition:** Both unstruck; mild natural toning on the ivory cover.
**Standout detail:** Matching blue "IP" monogramsone regular, one inverted.
**Price range:** $10$16 (set).
**Why it's special:** Sets display beautifully and pin a specific moment in Strip history. I like to stand these on a tiny easel next to a vintage room key.
_Caption:_ Imperial Palace twin monogramsclean lines, great shelf presence.
### 4) Tropicana "The Island of Las Vegas"
**Condition:** Near-mint; corners tight; colors saturated.
**Standout detail:** Breezy palm motif and that cheeky "Island" tagline.
**Price range:** $7$11.
**Why it's special:** The Trop sits at the crossroads of Rat Pack swagger and family-vacation warmth. It's the friendliest entry point for **hotel matchbooks** newcomers.
_Caption:_ Tropicana "Island of Las Vegas"palm-kissed getaway vibes.
### 5) Golden Gate Casino Fremont Street Bridge Art
**Condition:** Very Good; subtle edge wrinkle noted in photos.
**Standout detail:** Blue-and-cream bridge illustrationclean, almost nautical.
**Price range:** $10$14.
**Why it's special:** The oldest hotel-casino in town carries real local lore. This cover reads more "Old Town postcard" than ad, which anchors a Fremont Street trio (add a Pioneer Club or Binion's and you're set).
_Caption:_ Golden Gate on Fremontbridge artwork with old-town charm.
### 6) Copper Manor Motel (Silver City, NM) Road-Trip Classic
**Condition:** Unstruck; matte finish; honest drawer wear.
**Standout detail:** Bold red block letters, pure roadside Americana.
**Price range:** $5$8.
**Why it's special:** Not Vegas, but essential to the story: the miles between the big lights. Pair it with a Holiday Inn or La Quinta to tell the whole journey.
_Caption:_ Copper Manor Motelred block type that screams "road stop."
(You'll also see Sam's Town, Flamingo Hilton, Harrah's, Best Western, La Quinta, and some Texas piecesperfect filler for color-coordinated frames.)
## Behind the scenes (how I source, clean, and ship)
Sourcing is part luck, part listening. Estate sales. Family binders. A shoe store owner who kept a drawer of "good logos" under the registertrue story, and his taste was excellent. When a batch arrives, it goes on a clean mat. I sort by venue, then visual punch. Bold logos in one stack; understated typography in another.
Cleanup is gentle: a soft brush for dust; a barely damp cotton swab for glossy smudges; air-dry only. I don't polish, repaint, or press them flat—the slight tension helps a matchbook stand on a shelf. If a staple shows the first hint of rust, I neutralize and dry so the spot doesn't travel.
Photography happens under neutral light with a simple two-angle setup: straight-on for accuracy, tilted for sheen. Every honest quirktiny crease, touch of toningshows up in the close-ups and the description. You should know exactly what's coming.
For packaging, singles slip into archival polypropylene sleeves and a rigid mailer with chipboard. Lots go into a small box with interleaving. Complete books ship ground and get a little padded bridge over the striker so it won't abrade in transit. **Cover-only** listings are labeled that way from the title down.
## Trust & proof
I'm a careful solo seller who ships within **one business day** and writes the kind of descriptions I'd want to read: plain, specific, no mystery. Everything's backed by a **30-day no-fuss return policy**. If I missed something meaningful, return it in the sleeve and I'll make it right.
Buyers who've picked up postcards and ephemera from me mention the packaging and how the item looks even better in hand. That's the standard here.
## FAQ
**Are these originals or reprints?**
Originals. I source from personal collections, binders, and estates. If I ever list a reproduction, it'll be labeled clearly (and priced accordingly).
**How do you grade condition?**
I use Near-Mint / Excellent / Very Good / Good, with notes that pinpoint wear (edge rub, micro-crease, light toning). I always state whether the striker is clean and if matches are present.
**Do you combine shipping?**
Yes. Add items to your cart and checkout; eBay will auto-combine whenever possible. If something looks off, message me before paying and I'll adjust.
**What about safety or smell?**
Complete books ship via ground and are padded so the striker doesn't rub. If you're scent-sensitive, choose **cover-only** listingspaper tends to be fresher once the match heads are gone.
**What's your return policy?**
Thirty days. If it's not as described—or you just changed your mind—send it back in the original sleeve and I'll refund on receipt.
## Ready to browse?
Start with one. Or start with six. Build a tiny museum: a Stardust-and-Landmark duo for "lost Vegas," an IP/Tropicana pairing for weekend-trip energy, or a Fremont Street line-up anchored by Golden Gate. Whatever story you're telling, these little books have the range.
👉 **Explore the full drop:** TimoKnuthVintage on eBay`,
images: [
matches1.src,
matches2.src,
matches3.src,
matches4.src,
matches5.src
],
datePublished: new Date('2025-01-10'),
category: 'Vintage Ephemera',
tags: ['Las Vegas', 'Matchbooks', 'Casino', 'Vintage', 'Collectibles', 'Americana'],
featured: true,
previewImage: chatgptMatches.src
},
{
id: 'vintage-japanese-leather-clutch',
title: 'A Vintage Japanese Leather Clutch That Tells a Story—Now on eBay',
slug: 'vintage-japanese-leather-clutch',
excerpt: 'A hand-painted, hand-embossed clutch that reads like three postcards in one: temple on the front, bridge inside, and Mount Fuji on the back.',
content: `If you like objects that carry a little adventure, you'll like this one. I've just listed a **vintage Japanese leather clutch** that reads like three postcards in one: temple on the front, bridge inside, and Mount Fuji on the back.
It's the sort of piece you spot across a table and then keep noticing. The embossing is sharp, the colors are hand-brushed, and the surface has that warm, honest patina you can't fake.
## What I'm selling & why
Short version: a hand-painted, hand-embossed clutch made in Japan around the mid-century, often called **bunko leather**. The technique is wonderfully tactiledesigns are pressed into pale leather, then each area is brushed with pigment and sealed with a protective lacquer that settles into the recesses. Over decades it develops fine **craquelure** (those hairline networks you see in old varnish) and a depth that photos never quite capture.
Why this one? When I first opened the flap, I realized the maker had staged a tiny journey. The outside shows an ornate shrine façade with curved roofs and lattice rails. Open it, and there's a vermilion bridge crossing a rocky riverso close you can feel the riffles under your thumb. Flip it over and Fuji appears, snow-capped and calm. Three views, one carry. It felt like meeting a small, composed person who quietly has a lot to say.
I gravitate to pieces that were made to last and made by hand. This clutch carries those human tracespaint that occasionally wanders over a line, shading that pools in the grout of the stonework, a slightly soft corner that tells you it was used, but carefully.
## Featured item
**Nikkō Gate, Sacred Bridge & Fuji Hand-Painted Bunko Leather Clutch (1930s1950s)**
**Condition:** Good vintage. Even, fine craquelure in the lacquer; minor edge rubs; stitching intact. The metal turn-plate/snap closes with a tidy click. Interior lining shows age and remains tidy.
**Standout detail:** A "three-scene" composition. On the flap: an elaborate shrine façade with deep green roofs and delicate fretworkvery much in the spirit of Nikkō Tōshōgū. Inside: a **vermillion bridge** with black posts, set over embossed water and stone. Back panel: **Mount Fuji**, rendered in quiet tones that balance the bolder front.
**Approx. size:** about 25 × 13 × 3 cmroughly the footprint of an A5 notebook; big enough for a phone, slim card case, key, and a lipstick.
**Carry & interior:** slim hand strap across the back; fabric lining with a small vintage paper label in Japanese; inner panel fully painted with the bridge scene.
**Palette:** moss and cedar greens, soft stone greys, warm straw, and vermilion accents.
**Price range:** 89129 (check the live listing for the exact number).
**Why it's special:** Most scenic clutches settle for a single motif. This one offers a tour and does it with restraint: deep relief lines you can feel, balanced color, and a design that wears well with modern outfits. It's the collector-grade piece you can still use.
You'll see detailed photosfront, back, open, and close-ups of the embossingso you can read the surface for yourself. Nothing is hidden; the character is part of the value.
## Behind the scenes (sourcing, care, packaging)
**Sourcing.** I look for quality you can feel before you name it: crisp relief, confident brushwork (not printed), era-correct hardware, and scenes that make sense together. Temple, bridge, Fuji is a classic trio, and this example carries it with poise.
**Care.** Cleaning is minimal. I dust with a soft brush, wipe gently with a barely damp microfiber, and stop. Oils, alcohols, and common leather conditioners can soften or lift old pigments, so I don't use them. The goal is preservation, not refurbishment.
**Packaging.** The painted panels get a layer of glassine so nothing adheres or abrades. Cushioning is placed around edges and structure, not directly on the artwork. Everything is boxed firmly with corner guards. You'll receive tracking, and the unboxing is straightforward: no glitter, no drama, just safe transit for a delicate piece.
## Trust & proof
I keep my shop small on purpose so I can write precise descriptions and answer messages quickly. Buyers regularly mention accurate photos and careful packing in feedback. Orders ship from Germany within **12 business days** using a tracked service. There's a **30-day return window**, because buying vintage online should feel normal and safe. If you're new to this categorysometimes listed as a _bunko leather wallet_ or even a _hand painted Nikko Toshogu purse_I'm happy to explain the telltales that separate hand-worked pieces from later prints.
## FAQ
**Q: What's the exact size?**
A: Approximately **25 × 13 × 3 cm**. It fits a modern smartphone, slim wallet, keys, and a lipstick. I photographed it next to common items for scale.
**Q: Summarize the condition in one sentence?**
A: **Good vintage**fine, even craquelure to the lacquer, light edge wear, strong color, secure hardware, clean lining for its age.
**Q: Can I use it as an everyday clutch?**
A: It's sturdy for a vintage piece, but I recommend mindful use: avoid alcohol wipes and sharp keys against the painted areas; store it flat and out of direct light. Treat it like a well-made book with a painted cover.
**Q: Do you ship internationally?**
A: Yes. Tracked international shipping is available. EU deliveries typically arrive in **37 business days**; overseas parcels usually land in **13 weeks** depending on customs. If you need it quickly, message me before checkout and I'll quote an express option.
**Q: What's your return policy?**
A: **30 days** from delivery. Send it back in the same condition and original packaging; once it's back and checked, I issue a prompt refund.
**Q: How do I know it's hand-painted, not printed?**
A: Look for tiny variations in color density, the occasional brush touch outside an embossed line, and raised contours you can feel. Those are hallmarks of the bunko-leather emboss-and-paint method. I included macro photos so you can inspect them yourself.
## See the listing, more photos, and the current price
<a href="https://www.ebay.com/itm/396996956768?itmmeta=01K3RFYKHMQVR0JKTJCGF11E6R&hash=item5c6edcd260:g:-7cAAeSwwG5orJbJ&pfm=1" target="_blank" rel="noopener noreferrer"> View on eBay</a>
* * *
If you have a question about care, styling, or provenance, send me a note. I answer quickly and with the same detail you see here. Pieces like this don't shout; they just keep telling the story to anyone who slows down long enough to listen.`,
images: [
leather_clutch5.src,
leather_clutch4.src,
leather_clutch3.src,
leather_clutch2.src,
leather_clutch1.src
],
datePublished: new Date('2025-01-05'),
category: 'Vintage Fashion',
tags: ['Japanese', 'Bunko Leather', 'Vintage', 'Hand-painted', 'Clutch', 'Mount Fuji', 'Collectibles'],
featured: false,
previewImage: chatgptLeatherClutch.src
},
{
id: 'vintage-bicycle-808-rider-back-blue-sealed',
title: 'Vintage Bicycle 808 Rider Back deck (blue), sealed',
slug: 'vintage-bicycle-808-rider-back-blue-sealed',
excerpt: 'A sealed Bicycle Rider Back No. 808 poker deck from the tax-stamp era. Blue back, air-cushion finish, with the original revenue stamp and red tear-strip visible.',
content: `Every so often I list something I wish I could keep. This is one of those weeks. I'm putting up a sealed **Bicycle Rider Back No. 808** deck from the tax-stamp era—blue back, air-cushion finish, with the original stamp and red tear-strip visible.
It's the sort of piece that makes collectors pause. The intact revenue stamp, the period-correct typography, the way the cellophane has aged—this is a time capsule from the mid-20th century that's still factory-sealed.
## What it is
This post takes a closer look at a Bicycle Rider Back No. 808 poker-size deck made in the United States by The U.S. Playing Card Co. It's a factory-sealed, tax-stamp era example. In the photos, the U.S. revenue stamp bridges the top flap and the thin red ribbon sits beneath the outer wraptwo small packaging cues that place it pre-1965. The listing describes the deck as "brand-new old stock" in its original packaging with the familiar Air-Cushion Finish.
The back color is blue, and the box carries period side text"AIR-CUSHION FINISH," "808," "THE U. S. PLAYING CARD CO.," "MADE IN U.S.A."with no barcode, which matches the timeframe. The overall impression is a tidy, mid-century Bicycle that has stayed intact: warm ivory tuck paper, clean printing, and a sealed flap. It's the kind of small, practical collectible that sits easily on a shelf yet still speaks to everyday usestandard poker size, the classic Rider Back artwork, and typography most card players recognize at a glance.
If you care about subtle differences between runs, this one leans into that quiet mid-century look: softer paper tone, modest gloss on the wrap, and consistent halftone detail on the logos. If you just want a reliable piece of design history, it's simply a sealed Bicycle 808 that hasn't been fussed withnothing flashy, just well-kept.
## Small details that matter
**Front tuck artwork:** Tall BICYCLE logotype above the red Ace of Spades emblem. The ivory-toned paperboard shows an even patina, with crisp edges around the red spade.
**Back design panel:** The blue Rider Back artwork (mirrored angels, scrollwork, circular hub) reads clearly through the wrap; lines look sharp rather than mushy.
**Tax stamp placement:** A U.S. revenue stamp crosses the top flap. Edges and micro-print are visible beneath the cellophane; the stamp appears unbroken.
**Ribbon/tear-strip:** The thin red band is visible within the wraptypical on mid-century Bicycle decksand gives a faint linear reflection under light.
**Side typography:** "AIR-CUSHION FINISH," "808," "THE U. S. PLAYING CARD CO.," "MADE IN U.S.A." set in period fonts with neat baselines; no barcode present.
**Wrap condition:** The cellophane shows fine crinkles and micro-scuffs consistent with storage. Seams are tidy, with a soft gloss rather than high shine.
**Corners & edges:** A small compression at the top-left corner of the tuck; other corners look square with even folds.
**Color palette:** Warm ivory tuck stock, midnight blue back art, and red accents (tear-strip, tax stamp, spade graphic) produce a simple tri-color look.
**Scale:** Standard poker deck proportions. Exact measurements not specified; fits common card clips and cases.
**Era cues:** Lack of UPC, stamp across flap, red ribbon under wrapall typical pre-1965 signals.
**Print quality:** Halftones and outlines on logos appear intact; no obvious ink bleed visible in the photos.
**Paper texture:** Subtle paper tooth shows through in raking light; not glossy tuck stock.
**Flap/top panel:** "808" printed on the top panel is clear; edges around the fold look straight without tearing.
**Made in U.S.A. panel:** The "MADE IN U.S.A." side is readable; slight toning at the edges suggests normal age rather than heavy wear.
**Lighting reflections:** Highlights along the right edge reveal the cellophane's soft sheen; no blown-out glare.
## Everyday use
**Shelf or desk display:** A compact visual anchor for a bookcase, studio shelf, or office credenza. The blue back and red accents add a quiet bit of color without shouting.
**Gift for players and magicians:** A low-key present for someone who appreciates classic decks. The sealed state lets the recipient decide whether to keep it closed or open it later.
**Photo/film prop:** Useful for Americana, magic, or mid-century sets where real paper texture, tax stamps, and era-accurate packaging matter more than replicas.
**Reference for designers:** If you collect packaging or study typography, this tuck shows mid-century print tone, line thickness, and label placement in a compact format.
**Conversation piece:** Lives easily on a side table; guests recognize it and usually have a story about game nights, shuffles, or a card trick.
## Condition & notes
**Seller statement:** "Brand-new old stock — sealed, untouched deck in original packaging."
**Dating:** Pre-1965 based on tax stamp and ribbon seal; exact production year not verified.
**Observed wear (from photos):**
Mild age toning to the ivory tuck, even across panels.
Micro-scuffs and tiny wrinkles in the outer wrap from storage.
Small corner ding at the top-left; paper compression is visible but localized.
Printing remains crisp; panel edges look straight with no visible tears.
**Completeness:** Inner contents remain unopened; jokers/ad cards not specified.
**Grading:** No third-party grading or authentication mentioned.
**Odor/storage:** Not verified; listing doesn't note smoke exposure or storage environment.
**Provenance:** Not specified beyond photos and general era cues.
If you track production variants, a photo of the bottom flap (not shown here) can sometimes narrow down the print window further. As it stands, the visible stamp, ribbon, and side text are enough to keep it comfortably in the late 1950searly 1960s range without over-claiming.
## Care & compatibility
**Storage basics:** Keep upright in a cool, dry room (roughly 3050% RH if you track humidity). Avoid attics, basements, or windowsillsheat and UV can embrittle cellophane and fade inks.
**Light exposure:** Display in indirect light. If you like a brighter shelf, consider a spot with filtered daylight. Prolonged direct sun can yellow paper and loosen old adhesives.
**Handling:** Lift from the bottom panel and support the sides to avoid stressing the top-left corner. Finger oils can spot the tuckclean, dry hands help.
**Protection:** An acid-free sleeve or a small acrylic case keeps dust down while letting the graphics show. Avoid tape or adhesive tabs on the tuck or stamp.
**If opening:** Use the red tear-strip along the seam. Avoid peeling across the stampslice the wrap rather than lifting the tax stamp. Open over a clean surface in case any tiny paper fibers shed.
**After opening:** If you plan to use the deck, consider a card clip or a simple tuck case to limit warping. Finish and exact stock inside are presumed standard for Bicycle of the era but not verified without opening.
**Compatibility:** Standard poker size. Fits most modern card shufflers, holders, and shelves. Sleeve sizes vary by maker; check dimensions if you're buying a snug acrylic case.
**Cleaning the exterior:** Dust with a soft, dry cloth. Don't use liquids on cellophane; moisture can creep under edges and cloud the wrap.
## If you're curious
If you'd like more photos, exact measurements, or shipping details, check the seller's listing: <a href="https://www.ebay.com/itm/396997017344?itmmeta=01K3RFYKHMETXQNJ5A2YER0A4G&hash=item5c6eddbf00:g:WvUAAeSwesRorNwH&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
`,
images: [
cardDeckBanner.src,
deckCard1.src,
deckCard2.src,
deckCard3.src,
deckCard4.src,
deckCard5.src,
deckCard6.src
],
datePublished: new Date('2025-01-20'),
category: 'Vintage Collectibles',
tags: ['Bicycle', 'playing-cards', 'Rider-Back-808', 'vintage', 'blue', 'sealed', 'tax-stamp', 'collectibles'],
featured: true,
previewImage: cardDeckBanner.src
},
{
id: 'vintage-forest-troll-4-5in-fuchsia-hair',
title: 'Vintage Forest Troll, 4.5" with fuchsia hair',
slug: 'vintage-forest-troll-4-5in-fuchsia-hair',
excerpt: 'Palm-size vinyl troll with bright fuchsia hair and pink eyes. Stamped "FOREST TROLL ©" and "CHINA," approximately 4.5 inches; offered without clothing.',
content: `Every so often I list something that brings back memories. This is one of those weeks. I'm putting up a **Vintage Forest Troll** doll with bright fuchsia hair—about 4.5 inches tall, stamped "FOREST TROLL ©" and "CHINA" on the soles.
It's the sort of piece that makes collectors smile. The vivid hair, the familiar grin, the way it stands perfectly on a desk or shelf—this is a classic 90s toy that's still full of personality.
## What it is
This is a compact vintage Forest Troll figure with the familiar open-armed pose, a cheerful grin, and tall, up-swept fuchsia hair. The seller measures the body height at ~4.5 in / 11.5 cm (hair adds visible height but isn't part of the given measurement). The soles are stamped "FOREST TROLL ©" and "CHINA," with a small "3" that likely denotes mold or size. It's a straightforward, clothes-free exampleeasy to display on a shelf or desk, or to use as a base for simple customization.
## Small details that matter
**Hair color & texture:** saturated fuchsia/magenta with natural flyaways; the fibers lift and fan outward without heavy styling.
**Eyes:** pink, glossy inserts that catch small specular highlights; both eyes appear evenly colored in the photos.
**Face sculpt:** classic rounded cheeks, small nose, soft smile; no painted lips (the look comes from the vinyl tone).
**Vinyl finish:** tan tone with a light satin sheen; reflects softly under studio lighting; no tacky/shiny patches visible in the photos.
**Body sculpt:** crisp details at the belly button, toes, and ears; proportion typical for 45" trolls.
**Foot stamps:** FOREST TROLL © and CHINA are clear; the tiny "3" sits near the wording and reads sharply.
**Standing stance:** flat feet enable freestanding display on flat surfaces; a tiny bit of putty can improve stability if needed.
**Belly jewel:** not present (this is the plain-torso style, not the jeweled Treasure Troll type).
**Accessories:** none included; any comb or hang-tag seen in styled images is for presentation only.
**Scale cues:** roughly palm-size; fits beside a mug, pencil cup, or small succulent without crowding a surface.
**Color balance in photos:** neutral white background helps the hair read true; the fuchsia acts as the main accent color.
**Overall vibe:** bright hair + clean sculpt = a simple retro accent with a small footprint.
## Everyday use
**Desk companion:** adds a friendly pop of color next to a monitor or notebook. It occupies very little space and doesn't block screens or papers.
**Bookshelf accent:** sits well among paperbacks, small plants, or postcards; the hair adds vertical interest without needing a stand.
**Light craft base:** a good starting point for a mini outfit, ribbon bow, tiny hat, or a gentle hair refresh. No advanced tools required.
**Nostalgia gift:** a low-key 90s throwback for someone who remembers troll dollssimple, easy to wrap, and fun to place on a shelf.
**Photo prop:** brings a small burst of color to casual product shots, flat lays, or birthday table setups without stealing the scene.
## Condition & notes
**Ownership:** pre-owned.
**Hair:** bright, saturated color with the typical frizz these dolls often show; strands appear intact at the hairline.
**Vinyl:** minimal handling wear visible in photos; no cracks are apparent. Fine micro-scuffs consistent with age are possible.
**Marks & ID:** foot stamps are intact and legibleFOREST TROLL © / CHINA plus a small "3."
**Brand mention:** the seller lists IMM in item specifics; this brand is not verified on the doll itself (stamps show Forest Troll/China only).
**Year:** not specified. Styling suggests the 1990s gift-shop era, but that timing is not verified.
**Packaging & extras:** none included.
**Measurements:** body height of ~4.5" provided by the seller; hair-to-tip height not specified and would be taller.
**Photos vs. reality:** colors can vary by screen; white background shots aim to keep color honest, but exact tone may differ slightly in person.
If any detail is critical to you (exact hair height, weight, or color match), consider asking the seller for confirmation; measurements and hues are not independently verified here.
## Care & compatibility
**Quick refresh for hair:**
Comb gently with a wide-tooth comb from ends upward.
If needed, wash in cool water with a drop of mild shampoo; rinse well.
Optionally, a teaspoon of diluted fabric softener in the final rinse can calm frizz; do a brief water rinse afterward.
Air-dry; shape the hair as it dries. Avoid heat tools.
**Vinyl cleaning:** wipe with a soft, damp cloth. For marks, use a tiny amount of diluted dish soap, then dry immediately. A very light touch with a melamine sponge can reduce scuffstest a hidden spot first.
**Display tips:** keep out of direct sun to reduce color fade. On slick shelves, a dot of museum putty under one foot improves stability.
**Storage:** stand the doll upright in a small box; support the hair loosely to prevent flattening. Avoid high humidity, which can encourage frizz or dust cling.
**Safety:** contains small parts and loose fibers; use care around young children and pets. No batteries or electronics to maintain.
## If you're curious
If you'd like more photos, exact measurements, or shipping details, check the seller's listing: <a href="https://www.ebay.com/itm/396999861739?itmmeta=01K3RFYKHMRP2XWV0E8R1GGH0S&hash=item5c6f0925eb:g:VZ8AAeSwgvJoryzH&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
`,
images: [
dollBanner.src,
doll1.src,
doll2.src,
doll3.src
],
datePublished: new Date('2025-01-25'),
category: 'Vintage Toys',
tags: ['forest troll', 'troll doll', 'vintage toy', 'vinyl', 'fuchsia hair', 'pink eyes', 'made in china', '1990s style'],
featured: false,
previewImage: dollBanner.src
},
{
id: 'time-magazine-1955-01-10-bull-market-issue',
title: 'TIME Magazine, Jan 10, 1955 — The Bull Market Issue',
slug: 'time-magazine-1955-01-10-bull-market-issue',
excerpt: 'A 1955 TIME issue with Boris Chaliapin\'s bull market cover. Honest wear, clean interiors per seller, and the kind of mid-century ads people like to leaf through.',
content: `Every so often I list something that captures a moment in history. This is one of those weeks. I'm putting up an original **TIME Magazine** dated January 10, 1955 (Vol. LXV, No. 2) with the cover theme "The Bull Market Business Review & Forecast."
It's the sort of piece that makes collectors pause. The classic red border, the yellow diagonal band, the Boris Chaliapin cover art—this is a snapshot of post-war finance and magazine design that's still relevant today.
## What it is
An original TIME Magazine dated January 10, 1955 (Vol. LXV, No. 2). The cover feature is "The Bull Market Business Review & Forecast," illustrated by Boris Chaliapin. The seller describes it as Very Good to Good for its age: softcover/wraps, English language, all pages present, no writing or tears reported, and professionally handled. Packaging is noted as archival-safe sleeve with protective cardboard, which makes storage and gifting straightforward. For anyone who enjoys mid-century reporting, period graphics, and the look of vintage paper, this issue is a quiet, practical pick.
## Small details that matter
**Classic red TIME border** with a yellow diagonal sash for the cover theme.
**TWENTY CENTS cover price** at the toptypical of the era's layout.
**Chaliapin's art shows** a bull wrapped in ticker tape, with 1950s cars, skyline hints, and industrial motifs.
**Original subscriber mailing label** at the lower-left of the front cover (visible in photos).
**Back cover:** full-color Chesterfield ad featuring Rory Calhoun & Lita Baron beside a car.
**Interior masthead confirms** January 10, 1955 Vol. LXV, No. 2.
**Light spine stress lines;** modest corner/edge rub consistent with browsing.
**Small surface scuffs** on the cover; tones and inks remain strong for age.
**Coated magazine stock** with normal mid-century halftone dot pattern visible on close look.
**Age toning expected** on interior pages; paper color varies slightly by section.
**Binding:** softcover, wraps; exact dimensions not specified in the listing.
**Seller highlights** clean interiors, no writing, no tears observed.
**Shipped sleeved and backed,** which helps prevent additional edge wear in transit.
## Everyday use
**Desk or bookshelf display** in a study or officepairs easily with wood and brass accents.
**Frame the cover** (simple black or walnut frame) for low-key mid-century wall decor in a trading nook, workspace, or hallway.
**Photo/film prop** for finance, newsroom, or 1950s scenesrecognizable red border reads well on camera.
**Coffee-table flip-through** for guests who like period ads and historical snapshots without committing to a full book.
## Condition & notes
Listed as Very Good to Good overall. Expect light edge/corner wear, minor spine creasing, and small surface scuffs on the cover. Colors look lively for the age. The seller notes all pages present, no writing or tears observed, and clean interiors with typical age toning. The front mailing label remains, which some collectors prefer to keep for provenance. Page-by-page verification beyond the photos is not verified. Odor, annotations, or clipped coupons are not specified. If you need a specific article or ad checked, it's reasonable to ask the seller for a quick look before purchase.
## Care & compatibility
**Handle with clean, dry hands;** support the spine when opening.
**Store flat** in the acid-free sleeve with backing board (included per listing).
**Keep away from** direct sunlight, humidity, and temperature swings; a closet shelf or drawer works well.
**If framing,** use UV-filter glazing and an acid-free mat; avoid tight clamping that presses the spine.
**For dust,** use a soft brush onlyno liquids or solvent cleaners on coated paper.
**Do not attempt** label removal; adhesives on mid-century coated stock can pull ink or leave sheen changes.
**Allow light airing** in a dry room if there's typical vintage paper scent; avoid attic/basement storage.
## If you're curious
If you'd like more photos or specifics, check the seller's listing: <a href="https://www.ebay.com/itm/396997015021?itmmeta=01K3RFYKHM6RJ178JG8FB6BQ45&hash=item5c6eddb5ed:g:tYYAAeSwSYxoreBI&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
`,
images: [
timeMagazineBanner.src,
timeMagazine1.src,
timeMagazine2.src,
timeMagazine3.src
],
datePublished: new Date('2025-01-30'),
category: 'Vintage Magazines',
tags: ['Time', 'magazine', 'paper', 'mid-century', '1950s', 'business-finance', 'red-yellow', 'vintage-ads'],
featured: false,
previewImage: timeMagazineBanner.src
}
]

315
src/data/mockItems.ts Normal file
View File

@ -0,0 +1,315 @@
import { CuratedItem } from '@/types'
export const mockItems: CuratedItem[] = [
{
id: '1',
title: 'Canon AE-1 Program 35mm SLR Camera with 50mm f/1.8 Lens',
price: 89,
originalPrice: 120,
category: 'photography',
ebayUrl: 'https://ebay.com/item/1',
images: [
'https://images.unsplash.com/photo-1606983340126-99ab4feaa64a?w=800',
'https://images.unsplash.com/photo-1502920917128-1aa500764cbd?w=800'
],
condition: 'excellent',
rarity: 4,
expertNotes: 'This Canon AE-1 Program is in exceptional condition with minimal wear. The light meter is accurate, and the shutter speeds are consistent. Perfect for film photography enthusiasts looking for a reliable workhorse camera.',
dateAdded: new Date('2024-12-15'),
featured: true,
tags: ['35mm', 'SLR', 'Canon', 'Film Photography', 'Vintage'],
seller: {
name: 'VintageGearPro',
rating: 4.9,
feedback: 2847
},
shipping: {
cost: 12,
location: 'California, USA',
international: true
},
authenticity: {
verified: true,
notes: 'Serial number verified, original Canon parts confirmed'
}
},
{
id: '2',
title: 'Pink Floyd - The Dark Side of the Moon Original 1973 UK Pressing',
price: 45,
originalPrice: 65,
category: 'music',
ebayUrl: 'https://ebay.com/item/2',
images: [
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800',
'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=800'
],
condition: 'very-good',
rarity: 5,
expertNotes: 'Original UK pressing with solid triangle label. Matrix numbers confirm authenticity. Minor surface marks but plays beautifully. A must-have for any serious vinyl collector.',
dateAdded: new Date('2024-12-14'),
featured: false,
tags: ['Pink Floyd', 'Progressive Rock', '1970s', 'UK Pressing', 'Rare'],
seller: {
name: 'RecordCollectorUK',
rating: 4.8,
feedback: 1523
},
shipping: {
cost: 8,
location: 'London, UK',
international: true
},
authenticity: {
verified: true,
notes: 'Matrix numbers match original pressing, label verified'
}
},
{
id: '3',
title: 'First Edition "To Kill a Mockingbird" by Harper Lee (1960)',
price: 185,
originalPrice: 250,
category: 'books',
ebayUrl: 'https://ebay.com/item/3',
images: [
'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=800',
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=800'
],
condition: 'good',
rarity: 5,
expertNotes: 'True first edition with all correct points: "Mockingbird" on spine, correct publisher, and first state dust jacket. Some edge wear but structurally sound. A literary treasure.',
dateAdded: new Date('2024-12-13'),
featured: true,
tags: ['First Edition', 'Harper Lee', 'American Literature', 'Classic', 'Investment'],
seller: {
name: 'RareBookHunter',
rating: 4.9,
feedback: 892
},
shipping: {
cost: 15,
location: 'New York, USA',
international: false
},
authenticity: {
verified: true,
notes: 'All first edition points verified by book expert'
}
},
{
id: '4',
title: 'Eames Molded Plywood Chair (DCW) Herman Miller 1950s',
price: 320,
originalPrice: 450,
category: 'design',
ebayUrl: 'https://ebay.com/item/4',
images: [
'https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=800',
'https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=800'
],
condition: 'very-good',
rarity: 4,
expertNotes: 'Authentic Herman Miller DCW chair from the 1950s. Original Evans production with correct shock mounts. Some patina on the metal base adds character. A design icon.',
dateAdded: new Date('2024-12-12'),
featured: false,
tags: ['Eames', 'Herman Miller', 'Mid-Century', 'DCW', 'Design Icon'],
seller: {
name: 'ModernClassics',
rating: 4.7,
feedback: 634
},
shipping: {
cost: 45,
location: 'Chicago, USA',
international: true
},
authenticity: {
verified: true,
notes: 'Herman Miller label present, construction details verified'
}
},
{
id: '5',
title: 'Leica IIIf 35mm Rangefinder Camera with Summicron 50mm f/2',
price: 450,
originalPrice: 600,
category: 'photography',
ebayUrl: 'https://ebay.com/item/5',
images: [
'https://images.unsplash.com/photo-1606983340126-99ab4feaa64a?w=800'
],
condition: 'excellent',
rarity: 5,
expertNotes: 'Exceptional Leica IIIf in working condition. Rangefinder is bright and accurate, shutter speeds are consistent. The Summicron lens is sharp with minimal haze. A photographer\'s dream.',
dateAdded: new Date('2024-12-11'),
featured: true,
tags: ['Leica', 'Rangefinder', 'Summicron', 'German Engineering', 'Professional'],
seller: {
name: 'LeicaSpecialist',
rating: 5.0,
feedback: 456
},
shipping: {
cost: 25,
location: 'Germany',
international: true
},
authenticity: {
verified: true,
notes: 'Serial numbers verified, CLA performed by certified technician'
}
},
{
id: '6',
title: 'The Beatles - Please Please Me Mono UK First Pressing (1963)',
price: 125,
originalPrice: 180,
category: 'music',
ebayUrl: 'https://ebay.com/item/6',
images: [
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800'
],
condition: 'good',
rarity: 5,
expertNotes: 'Original UK mono pressing with gold and black Parlophone label. Matrix numbers confirm first pressing. Some surface wear but plays well. Historic significance cannot be overstated.',
dateAdded: new Date('2024-12-10'),
featured: false,
tags: ['The Beatles', 'Mono', 'First Pressing', 'Parlophone', 'Historic'],
seller: {
name: 'BeatlesVinyl',
rating: 4.9,
feedback: 2156
},
shipping: {
cost: 12,
location: 'Liverpool, UK',
international: true
},
authenticity: {
verified: true,
notes: 'Matrix numbers and label details verified as first pressing'
}
},
{
id: '7',
title: 'Signed First Edition "On the Road" by Jack Kerouac (1957)',
price: 850,
originalPrice: 1200,
category: 'books',
ebayUrl: 'https://ebay.com/item/7',
images: [
'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=800'
],
condition: 'very-good',
rarity: 5,
expertNotes: 'Signed first edition of this Beat Generation masterpiece. Signature authenticated by PSA. Dust jacket present with minor edge wear. A literary holy grail for collectors.',
dateAdded: new Date('2024-12-09'),
featured: true,
tags: ['Signed', 'First Edition', 'Jack Kerouac', 'Beat Generation', 'PSA Authenticated'],
seller: {
name: 'LiteraryTreasures',
rating: 4.8,
feedback: 234
},
shipping: {
cost: 20,
location: 'San Francisco, USA',
international: true
},
authenticity: {
verified: true,
notes: 'Signature authenticated by PSA, first edition points verified'
}
},
{
id: '8',
title: 'George Nelson Ball Clock Howard Miller 1950s Original',
price: 280,
originalPrice: 380,
category: 'design',
ebayUrl: 'https://ebay.com/item/8',
images: [
'https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=800'
],
condition: 'excellent',
rarity: 4,
expertNotes: 'Original George Nelson Ball Clock by Howard Miller. All original balls and mechanism. Keeps perfect time. A mid-century modern icon that defines an era.',
dateAdded: new Date('2024-12-08'),
featured: false,
tags: ['George Nelson', 'Howard Miller', 'Ball Clock', 'Mid-Century', 'Atomic Age'],
seller: {
name: 'AtomicDesign',
rating: 4.6,
feedback: 789
},
shipping: {
cost: 18,
location: 'Los Angeles, USA',
international: true
},
authenticity: {
verified: true,
notes: 'Howard Miller label present, original mechanism confirmed'
}
}
]
export const mockBadges = [
{
id: 'first-find',
name: 'First Find',
description: 'Added your first item to wishlist',
icon: '🎯',
rarity: 'common' as const
},
{
id: 'camera-collector',
name: 'Camera Connoisseur',
description: 'Wishlisted 10 photography items',
icon: '📸',
rarity: 'rare' as const
},
{
id: 'vinyl-virtuoso',
name: 'Vinyl Virtuoso',
description: 'Wishlisted 15 music items',
icon: '🎵',
rarity: 'rare' as const
},
{
id: 'book-bibliophile',
name: 'Book Bibliophile',
description: 'Wishlisted 10 book items',
icon: '📚',
rarity: 'rare' as const
},
{
id: 'design-devotee',
name: 'Design Devotee',
description: 'Wishlisted 10 design items',
icon: '🎨',
rarity: 'rare' as const
},
{
id: 'treasure-hunter',
name: 'Treasure Hunter',
description: 'Found 50 items across all categories',
icon: '💎',
rarity: 'legendary' as const
},
{
id: 'daily-visitor',
name: 'Daily Visitor',
description: 'Visited the site for 7 consecutive days',
icon: '🔥',
rarity: 'common' as const
},
{
id: 'expert-eye',
name: 'Expert Eye',
description: 'Wishlisted 5 items with rarity 5',
icon: '👁️',
rarity: 'legendary' as const
}
]

180
src/types/index.ts Normal file
View File

@ -0,0 +1,180 @@
// Core Item Types
export interface CuratedItem {
id: string
title: string
price: number
originalPrice?: number
category: 'photography' | 'music' | 'books' | 'design'
ebayUrl: string
images: string[]
condition: 'new' | 'like-new' | 'excellent' | 'very-good' | 'good' | 'acceptable'
rarity: 1 | 2 | 3 | 4 | 5
expertNotes: string
dateAdded: Date
featured: boolean
tags: string[]
seller: {
name: string
rating: number
feedback: number
}
shipping: {
cost: number
location: string
international: boolean
}
authenticity: {
verified: boolean
notes?: string
}
}
// User & Gamification Types
export interface UserProfile {
id: string
email: string
username: string
avatar?: string
level: number // Collector Level (1-50)
xp: number // Experience Points
badges: Badge[] // Achievement Badges
collections: Collection[]
wishlist: string[] // Item IDs
streak: number // Daily visit streak
preferences: UserPreferences
joinDate: Date
lastActive: Date
}
export interface Badge {
id: string
name: string // "Vinyl Virtuoso", "Camera Connoisseur"
description: string
icon: string
rarity: 'common' | 'rare' | 'legendary'
unlockedAt?: Date
progress?: {
current: number
required: number
}
}
export interface Collection {
id: string
name: string
description: string
items: string[] // Item IDs
isPublic: boolean
createdAt: Date
updatedAt: Date
}
export interface UserPreferences {
categories: string[]
priceRange: {
min: number
max: number
}
notifications: {
email: boolean
push: boolean
priceAlerts: boolean
newFinds: boolean
weeklyDigest: boolean
}
privacy: {
showCollections: boolean
showActivity: boolean
}
}
// Search & Filter Types
export interface SearchFilters {
query?: string
category?: string
priceMin?: number
priceMax?: number
condition?: string[]
rarity?: number[]
tags?: string[]
sortBy?: 'date' | 'price-low' | 'price-high' | 'rarity' | 'popularity'
featured?: boolean
}
export interface SearchResult {
items: CuratedItem[]
total: number
page: number
hasMore: boolean
filters: SearchFilters
}
// Newsletter Types
export interface NewsletterSubscriber {
id: string
email: string
status: 'active' | 'pending' | 'unsubscribed'
segments: string[] // ['photography', 'music', 'books', 'design']
subscribedAt: Date
preferences: {
frequency: 'daily' | 'weekly' | 'monthly'
categories: string[]
}
}
// Price Alert Types
export interface PriceAlert {
id: string
userId: string
itemId: string
targetPrice: number
isActive: boolean
createdAt: Date
triggeredAt?: Date
}
// Activity & Analytics Types
export interface UserActivity {
id: string
userId: string
type: 'view' | 'wishlist' | 'share' | 'click' | 'search'
itemId?: string
metadata?: Record<string, any>
timestamp: Date
}
// API Response Types
export interface ApiResponse<T> {
success: boolean
data?: T
error?: string
message?: string
}
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
limit: number
hasMore: boolean
}
// Component Props Types
export interface ItemCardProps {
item: CuratedItem
showWishlist?: boolean
showExpertNotes?: boolean
compact?: boolean
}
export interface SearchBarProps {
onSearch: (query: string) => void
placeholder?: string
showFilters?: boolean
}
export interface WishlistButtonProps {
itemId: string
isWishlisted: boolean
onToggle: (itemId: string) => void
}

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}