3v324v23 commited on
Commit
0dd2082
·
0 Parent(s):

Initial commit of RedThread project

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +39 -0
  2. README.md +60 -0
  3. check_rows.js +18 -0
  4. client/.gitignore +35 -0
  5. client/README.md +18 -0
  6. client/client_live.txt +0 -0
  7. client/eslint.config.js +29 -0
  8. client/lint.txt +0 -0
  9. client/next.config.mjs +6 -0
  10. client/package-lock.json +0 -0
  11. client/package.json +28 -0
  12. client/public/favicon.svg +1 -0
  13. client/public/vite.svg +1 -0
  14. client/src/api/client.js +44 -0
  15. client/src/app/About.css +196 -0
  16. client/src/app/History.css +160 -0
  17. client/src/app/Home.css +279 -0
  18. client/src/app/Miner.css +166 -0
  19. client/src/app/NotFound.css +54 -0
  20. client/src/app/about/page.jsx +110 -0
  21. client/src/app/globals.css +139 -0
  22. client/src/app/history/page.jsx +100 -0
  23. client/src/app/layout.jsx +21 -0
  24. client/src/app/not-found.jsx +23 -0
  25. client/src/app/page.jsx +342 -0
  26. client/src/app/terms/Terms.css +71 -0
  27. client/src/app/terms/page.jsx +60 -0
  28. client/src/assets/react.svg +1 -0
  29. client/src/components/ClarificationPrompt.css +111 -0
  30. client/src/components/ClarificationPrompt.jsx +52 -0
  31. client/src/components/ErrorBoundary.css +70 -0
  32. client/src/components/ErrorBoundary.jsx +48 -0
  33. client/src/components/FilterPanel.css +182 -0
  34. client/src/components/FilterPanel.jsx +120 -0
  35. client/src/components/Header.css +199 -0
  36. client/src/components/Header.jsx +56 -0
  37. client/src/components/Providers.jsx +11 -0
  38. client/src/components/ResultCard.css +158 -0
  39. client/src/components/ResultCard.jsx +53 -0
  40. client/src/components/ResultModal.css +179 -0
  41. client/src/components/ResultModal.jsx +98 -0
  42. client/src/components/SafetyBanner.css +29 -0
  43. client/src/components/SafetyBanner.jsx +20 -0
  44. client/src/components/SearchBar.css +277 -0
  45. client/src/components/SearchBar.jsx +192 -0
  46. client/src/components/SkeletonCard.css +64 -0
  47. client/src/components/SkeletonCard.jsx +21 -0
  48. client/src/contexts/ToastContext.css +70 -0
  49. client/src/contexts/ToastContext.jsx +54 -0
  50. client/src/hooks/useGeolocation.js +44 -0
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ .next/
4
+ dist/
5
+ dist-ssr/
6
+ build/
7
+
8
+ # Environment
9
+ .env
10
+ .env.local
11
+ .env.development.local
12
+ .env.test.local
13
+ .env.production.local
14
+
15
+ # Databases and Scraping
16
+ *.sqlite
17
+ locations.sqlite
18
+ *.db
19
+ scraper_log.txt
20
+ server_run.log
21
+ build_error.log
22
+
23
+ # Logs and Debugging
24
+ logs/
25
+ *.log
26
+ npm-debug.log*
27
+ yarn-debug.log*
28
+ yarn-error.log*
29
+ .pnpm-debug.log*
30
+
31
+ # IDEs
32
+ .vscode/
33
+ .idea/
34
+ *.swp
35
+ *.swo
36
+
37
+ # OS
38
+ .DS_Store
39
+ Thumbs.db
README.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RedThread
2
+
3
+ **AI-assisted location-based recommendation platform** that combines intelligent intent parsing, safety validation, and structured data extraction.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ ultimate_spider/
9
+ ├── client/ # React + Vite frontend
10
+ │ └── src/
11
+ │ ├── api/ # Backend communication layer
12
+ │ ├── components/ # Reusable UI components
13
+ │ ├── pages/ # Page-level compositions
14
+ │ └── styles/ # Design system
15
+ └── server/ # Node.js + Express backend
16
+ └── src/
17
+ ├── config/ # Environment-driven configuration
18
+ ├── controllers/ # Request orchestration (thin)
19
+ ├── middleware/ # Rate limiting, safety guard, error handler
20
+ ├── routes/ # HTTP route definitions
21
+ ├── services/ # Business logic (AI, safety, scraper)
22
+ ├── utils/ # Logger, custom errors
23
+ └── validators/ # Request body validation
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```bash
29
+ # Backend
30
+ cd server
31
+ npm install
32
+ cp .env.example .env # Add your GROQ_API_KEY
33
+ npm run dev
34
+
35
+ # Frontend (new terminal)
36
+ cd client
37
+ npm install
38
+ npm run dev
39
+ ```
40
+
41
+ - **Frontend**: http://localhost:5173
42
+ - **Backend**: http://localhost:3001
43
+ - **Health**: http://localhost:3001/api/health
44
+
45
+ ## Tech Stack
46
+
47
+ | Layer | Tech |
48
+ |-------|------|
49
+ | Frontend | React 19, Vite |
50
+ | Backend | Express 4, Node.js |
51
+ | AI | Groq API (Llama 3.3 70B) |
52
+ | Security | Helmet, CORS, Rate Limiting, Safety Middleware |
53
+
54
+ ## Environment Variables
55
+
56
+ | Variable | Description |
57
+ |----------|-------------|
58
+ | `PORT` | Server port (default: 3001) |
59
+ | `NODE_ENV` | Environment (development/production) |
60
+ | `GROQ_API_KEY` | Groq API key for AI intent parsing |
check_rows.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { getDb } = require('./server/src/db/database');
2
+
3
+ async function checkRows() {
4
+ try {
5
+ const db = await getDb();
6
+ const count = await db.get('SELECT COUNT(*) as count FROM places');
7
+ console.log(`TOTAL ROWS: ${count.count}`);
8
+
9
+ const first = await db.get('SELECT * FROM places LIMIT 1');
10
+ console.log('FIRST ROW:', JSON.stringify(first, null, 2));
11
+ } catch (err) {
12
+ console.error('Debug failed:', err.message);
13
+ } finally {
14
+ process.exit(0);
15
+ }
16
+ }
17
+
18
+ checkRows();
client/.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # next.js
12
+ /.next/
13
+ /out/
14
+
15
+ # production
16
+ /build
17
+
18
+ # misc
19
+ .DS_Store
20
+ *.pem
21
+
22
+ # debug
23
+ npm-debug.log*
24
+ yarn-debug.log*
25
+ yarn-error.log*
26
+
27
+ # local env files
28
+ .env*.local
29
+
30
+ # vercel
31
+ .vercel
32
+
33
+ # typescript
34
+ *.tsbuildinfo
35
+ next-env.d.ts
client/README.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
13
+
14
+ Note: This will impact Vite dev & build performances.
15
+
16
+ ## Expanding the ESLint configuration
17
+
18
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
client/client_live.txt ADDED
Binary file (750 Bytes). View file
 
client/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
client/lint.txt ADDED
Binary file (1.23 kB). View file
 
client/next.config.mjs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+ };
5
+
6
+ export default nextConfig;
client/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
client/package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "client",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev -p 5173",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "next lint"
11
+ },
12
+ "dependencies": {
13
+ "next": "^15.1.0",
14
+ "react": "^19.2.0",
15
+ "react-dom": "^19.2.0"
16
+ },
17
+ "devDependencies": {
18
+ "@eslint/js": "^9.39.1",
19
+ "@types/react": "^19.2.7",
20
+ "@types/react-dom": "^19.2.3",
21
+ "babel-plugin-react-compiler": "^1.0.0",
22
+ "eslint": "^9.39.1",
23
+ "eslint-config-next": "^15.1.0",
24
+ "eslint-plugin-react-hooks": "^7.0.1",
25
+ "eslint-plugin-react-refresh": "^0.4.24",
26
+ "globals": "^16.5.0"
27
+ }
28
+ }
client/public/favicon.svg ADDED
client/public/vite.svg ADDED
client/src/api/client.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
2
+
3
+ async function request(endpoint, options = {}) {
4
+ const url = `${BASE_URL}${endpoint}`;
5
+ const config = {
6
+ headers: { 'Content-Type': 'application/json' },
7
+ ...options,
8
+ };
9
+
10
+ const response = await fetch(url, config);
11
+ const data = await response.json();
12
+
13
+ if (!response.ok) {
14
+ const error = new Error(data.error || 'Request failed');
15
+ error.status = response.status;
16
+ error.data = data;
17
+ throw error;
18
+ }
19
+
20
+ return data;
21
+ }
22
+
23
+ export function getHealth() {
24
+ return request('/health');
25
+ }
26
+
27
+ export function search(query, location, filters, userLocation, clarificationContext) {
28
+ return request('/search', {
29
+ method: 'POST',
30
+ body: JSON.stringify({ query, location, filters, userLocation, clarificationContext }),
31
+ });
32
+ }
33
+
34
+ export function getCategories() {
35
+ return request('/categories');
36
+ }
37
+
38
+ export function getSuggestions(q) {
39
+ return request(`/suggestions?q=${encodeURIComponent(q)}`);
40
+ }
41
+
42
+ export function getPlaceById(id) {
43
+ return request(`/suggestions/${id}`);
44
+ }
client/src/app/About.css ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .about {
2
+ max-width: 900px;
3
+ margin: 0 auto;
4
+ padding: 2rem 1.5rem 4rem;
5
+ }
6
+
7
+ .about-hero {
8
+ text-align: center;
9
+ padding: 2rem 0 2.5rem;
10
+ }
11
+
12
+ .about-title {
13
+ font-size: 2rem;
14
+ font-weight: 800;
15
+ letter-spacing: -0.03em;
16
+ margin-bottom: 1rem;
17
+ }
18
+
19
+ .accent-text {
20
+ background: var(--accent-gradient);
21
+ -webkit-background-clip: text;
22
+ -webkit-text-fill-color: transparent;
23
+ background-clip: text;
24
+ }
25
+
26
+ .about-lead {
27
+ font-size: 1rem;
28
+ color: var(--text-secondary);
29
+ max-width: 600px;
30
+ margin: 0 auto;
31
+ line-height: 1.7;
32
+ }
33
+
34
+ .about-section {
35
+ margin-bottom: 2.5rem;
36
+ }
37
+
38
+ .about-section h3 {
39
+ font-size: 1.1rem;
40
+ font-weight: 700;
41
+ margin-bottom: 1rem;
42
+ padding-bottom: 0.5rem;
43
+ border-bottom: 1px solid var(--border-subtle);
44
+ }
45
+
46
+ .about-pipeline {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 0.25rem;
50
+ flex-wrap: wrap;
51
+ justify-content: center;
52
+ padding: 1rem 0;
53
+ }
54
+
55
+ .pipeline-step {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 0.4rem;
59
+ }
60
+
61
+ .pipeline-num {
62
+ width: 24px;
63
+ height: 24px;
64
+ border-radius: 50%;
65
+ background: var(--accent-gradient);
66
+ color: white;
67
+ font-size: 0.7rem;
68
+ font-weight: 700;
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ flex-shrink: 0;
73
+ }
74
+
75
+ .pipeline-label {
76
+ font-size: 0.82rem;
77
+ font-weight: 500;
78
+ color: var(--text-primary);
79
+ white-space: nowrap;
80
+ }
81
+
82
+ .pipeline-arrow {
83
+ color: var(--text-muted);
84
+ margin: 0 0.25rem;
85
+ }
86
+
87
+ .about-grid {
88
+ display: grid;
89
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
90
+ gap: 0.75rem;
91
+ }
92
+
93
+ .about-card {
94
+ background: var(--bg-card);
95
+ border: 1px solid var(--border-subtle);
96
+ border-radius: var(--radius-md);
97
+ padding: 1rem 1.25rem;
98
+ transition: all var(--transition-normal);
99
+ }
100
+
101
+ .about-card:hover {
102
+ background: var(--bg-card-hover);
103
+ border-color: rgba(238, 105, 131, 0.15);
104
+ transform: translateY(-2px);
105
+ }
106
+
107
+ .about-card-icon {
108
+ font-size: 1.5rem;
109
+ display: block;
110
+ margin-bottom: 0.4rem;
111
+ }
112
+
113
+ .about-card h4 {
114
+ font-size: 0.9rem;
115
+ font-weight: 600;
116
+ margin-bottom: 0.25rem;
117
+ }
118
+
119
+ .about-card p {
120
+ font-size: 0.78rem;
121
+ color: var(--text-secondary);
122
+ line-height: 1.4;
123
+ }
124
+
125
+ .about-table-wrap {
126
+ overflow-x: auto;
127
+ }
128
+
129
+ .about-table {
130
+ width: 100%;
131
+ border-collapse: collapse;
132
+ font-size: 0.82rem;
133
+ }
134
+
135
+ .about-table th {
136
+ text-align: left;
137
+ padding: 0.6rem 0.75rem;
138
+ background: var(--bg-card);
139
+ color: var(--text-muted);
140
+ font-weight: 600;
141
+ font-size: 0.7rem;
142
+ text-transform: uppercase;
143
+ letter-spacing: 0.08em;
144
+ border-bottom: 1px solid var(--border-subtle);
145
+ }
146
+
147
+ .about-table td {
148
+ padding: 0.6rem 0.75rem;
149
+ border-bottom: 1px solid var(--border-subtle);
150
+ color: var(--text-secondary);
151
+ }
152
+
153
+ .about-table-layer {
154
+ font-weight: 600;
155
+ color: var(--accent-secondary) !important;
156
+ white-space: nowrap;
157
+ }
158
+
159
+ .about-table-interview {
160
+ font-style: italic;
161
+ font-size: 0.78rem;
162
+ }
163
+
164
+ .about-safety {
165
+ display: flex;
166
+ flex-direction: column;
167
+ gap: 0.75rem;
168
+ }
169
+
170
+ .about-safety-item {
171
+ display: flex;
172
+ align-items: flex-start;
173
+ gap: 0.75rem;
174
+ padding: 0.75rem 1rem;
175
+ background: var(--bg-card);
176
+ border: 1px solid var(--border-subtle);
177
+ border-radius: var(--radius-md);
178
+ }
179
+
180
+ .about-safety-icon {
181
+ font-size: 1.25rem;
182
+ flex-shrink: 0;
183
+ margin-top: 0.1rem;
184
+ }
185
+
186
+ .about-safety-item h4 {
187
+ font-size: 0.85rem;
188
+ font-weight: 600;
189
+ margin-bottom: 0.15rem;
190
+ }
191
+
192
+ .about-safety-item p {
193
+ font-size: 0.78rem;
194
+ color: var(--text-secondary);
195
+ line-height: 1.4;
196
+ }
client/src/app/History.css ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .history-page {
2
+ max-width: 800px;
3
+ margin: 0 auto;
4
+ padding: 2rem 1.5rem 4rem;
5
+ }
6
+
7
+ .history-header {
8
+ display: flex;
9
+ align-items: flex-start;
10
+ justify-content: space-between;
11
+ margin-bottom: 1.5rem;
12
+ }
13
+
14
+ .history-header h2 {
15
+ font-size: 1.5rem;
16
+ font-weight: 700;
17
+ }
18
+
19
+ .history-subtitle {
20
+ font-size: 0.8rem;
21
+ color: var(--text-muted);
22
+ margin-top: 0.2rem;
23
+ }
24
+
25
+ .history-clear-btn {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 0.35rem;
29
+ padding: 0.4rem 0.75rem;
30
+ background: rgba(214, 48, 49, 0.1);
31
+ border: 1px solid rgba(214, 48, 49, 0.2);
32
+ border-radius: var(--radius-sm);
33
+ color: #e17055;
34
+ font-size: 0.78rem;
35
+ font-weight: 500;
36
+ transition: all var(--transition-fast);
37
+ }
38
+
39
+ .history-clear-btn:hover {
40
+ background: rgba(214, 48, 49, 0.2);
41
+ }
42
+
43
+ .history-empty {
44
+ text-align: center;
45
+ padding: 4rem 1rem;
46
+ color: var(--text-muted);
47
+ }
48
+
49
+ .history-empty svg {
50
+ margin-bottom: 1rem;
51
+ opacity: 0.3;
52
+ }
53
+
54
+ .history-empty p {
55
+ font-size: 1rem;
56
+ font-weight: 500;
57
+ }
58
+
59
+ .history-empty-sub {
60
+ font-size: 0.82rem !important;
61
+ font-weight: 400 !important;
62
+ color: var(--text-muted);
63
+ margin-top: 0.25rem;
64
+ }
65
+
66
+ .history-list {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 0.5rem;
70
+ }
71
+
72
+ .history-item {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ gap: 1rem;
77
+ padding: 0.75rem 1rem;
78
+ background: var(--bg-card);
79
+ border: 1px solid var(--border-subtle);
80
+ border-radius: var(--radius-md);
81
+ transition: all var(--transition-fast);
82
+ }
83
+
84
+ .history-item:hover {
85
+ background: var(--bg-card-hover);
86
+ border-color: rgba(238, 105, 131, 0.15);
87
+ }
88
+
89
+ .history-item-main {
90
+ flex: 1;
91
+ min-width: 0;
92
+ }
93
+
94
+ .history-item-query {
95
+ font-size: 0.9rem;
96
+ font-weight: 500;
97
+ color: var(--text-primary);
98
+ margin-bottom: 0.3rem;
99
+ white-space: nowrap;
100
+ overflow: hidden;
101
+ text-overflow: ellipsis;
102
+ }
103
+
104
+ .history-item-meta {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 0.4rem;
108
+ flex-wrap: wrap;
109
+ }
110
+
111
+ .history-chip {
112
+ font-size: 0.65rem;
113
+ font-weight: 600;
114
+ text-transform: uppercase;
115
+ letter-spacing: 0.05em;
116
+ padding: 0.1rem 0.4rem;
117
+ border-radius: 4px;
118
+ background: rgba(238, 105, 131, 0.12);
119
+ color: var(--accent-secondary);
120
+ }
121
+
122
+ .history-item-results,
123
+ .history-item-time {
124
+ font-size: 0.7rem;
125
+ color: var(--text-muted);
126
+ }
127
+
128
+ .history-item-time::before {
129
+ content: '·';
130
+ margin-right: 0.4rem;
131
+ }
132
+
133
+ .history-item-actions {
134
+ display: flex;
135
+ gap: 0.25rem;
136
+ flex-shrink: 0;
137
+ }
138
+
139
+ .history-rerun-btn,
140
+ .history-remove-btn {
141
+ width: 30px;
142
+ height: 30px;
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ border-radius: var(--radius-sm);
147
+ background: transparent;
148
+ color: var(--text-muted);
149
+ transition: all var(--transition-fast);
150
+ }
151
+
152
+ .history-rerun-btn:hover {
153
+ background: rgba(238, 105, 131, 0.15);
154
+ color: var(--accent-secondary);
155
+ }
156
+
157
+ .history-remove-btn:hover {
158
+ background: rgba(214, 48, 49, 0.15);
159
+ color: #e17055;
160
+ }
client/src/app/Home.css ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .home {
2
+ flex: 1;
3
+ max-width: 1200px;
4
+ width: 100%;
5
+ margin: 0 auto;
6
+ padding: 0 1.5rem 3rem;
7
+ }
8
+
9
+ .home-hero {
10
+ position: relative;
11
+ text-align: center;
12
+ padding: 3.5rem 0 2rem;
13
+ }
14
+
15
+ .home-hero-glow {
16
+ position: absolute;
17
+ top: -40px;
18
+ left: 50%;
19
+ transform: translateX(-50%);
20
+ width: 400px;
21
+ height: 400px;
22
+ background: radial-gradient(circle, rgba(238, 105, 131, 0.12) 0%, transparent 70%);
23
+ pointer-events: none;
24
+ }
25
+
26
+ .home-headline {
27
+ font-size: 2.5rem;
28
+ font-weight: 800;
29
+ letter-spacing: -0.03em;
30
+ line-height: 1.15;
31
+ margin-bottom: 1rem;
32
+ color: var(--text-primary);
33
+ text-shadow: none;
34
+ filter: none;
35
+ }
36
+
37
+ .home-headline-accent {
38
+ background: linear-gradient(90deg,
39
+ #FFC4C4 0%,
40
+ #fd79a8 25%,
41
+ #FCF5EE 50%,
42
+ #fd79a8 75%,
43
+ #FFC4C4 100%);
44
+ background-size: 200% auto;
45
+ -webkit-background-clip: text;
46
+ -webkit-text-fill-color: transparent;
47
+ background-clip: text;
48
+ animation: flowing-light 3s linear infinite;
49
+ }
50
+
51
+ @keyframes flowing-light {
52
+ to {
53
+ background-position: 200% center;
54
+ }
55
+ }
56
+
57
+ .home-subline {
58
+ font-size: 1rem;
59
+ color: var(--text-secondary);
60
+ max-width: 540px;
61
+ margin: 0 auto 2rem;
62
+ line-height: 1.6;
63
+ }
64
+
65
+ .home-error {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 0.5rem;
69
+ max-width: 720px;
70
+ margin: 1.5rem auto;
71
+ padding: 0.75rem 1rem;
72
+ background: rgba(214, 48, 49, 0.1);
73
+ border: 1px solid rgba(214, 48, 49, 0.2);
74
+ border-radius: var(--radius-md);
75
+ color: #e17055;
76
+ font-size: 0.85rem;
77
+ }
78
+
79
+ .error-action-btn {
80
+ background: var(--accent-gradient);
81
+ border: none;
82
+ padding: 0.25rem 0.75rem;
83
+ border-radius: 4px;
84
+ color: white;
85
+ font-size: 0.75rem;
86
+ font-weight: 600;
87
+ cursor: pointer;
88
+ transition: transform 0.2s ease;
89
+ margin-left: 0.5rem;
90
+ }
91
+
92
+ .error-action-btn:hover {
93
+ transform: scale(1.05);
94
+ }
95
+
96
+ .home-intent {
97
+ max-width: 720px;
98
+ margin: 1.5rem auto;
99
+ padding: 1rem 1.25rem;
100
+ background: var(--bg-card);
101
+ border: 1px solid var(--border-subtle);
102
+ border-radius: var(--radius-md);
103
+ }
104
+
105
+ .home-intent-title {
106
+ font-size: 0.7rem;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.1em;
109
+ color: var(--text-muted);
110
+ margin-bottom: 0.5rem;
111
+ }
112
+
113
+ .home-intent-chips {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ gap: 0.5rem;
117
+ }
118
+
119
+ .intent-chip {
120
+ font-size: 0.78rem;
121
+ padding: 0.25rem 0.6rem;
122
+ border-radius: 6px;
123
+ background: rgba(238, 105, 131, 0.1);
124
+ color: var(--text-secondary);
125
+ }
126
+
127
+ .intent-chip strong {
128
+ color: var(--accent-secondary);
129
+ font-weight: 600;
130
+ margin-right: 0.2rem;
131
+ }
132
+
133
+ .intent-chip.reasoning {
134
+ width: 100%;
135
+ margin-bottom: 0.25rem;
136
+ background: rgba(255, 255, 255, 0.03);
137
+ border: 1px dashed rgba(238, 105, 131, 0.3);
138
+ font-style: italic;
139
+ }
140
+
141
+ .home-results-layout {
142
+ display: grid;
143
+ grid-template-columns: 280px 1fr;
144
+ gap: 1.5rem;
145
+ margin-top: 1.5rem;
146
+ align-items: start;
147
+ }
148
+
149
+ .home-results {
150
+ min-width: 0;
151
+ }
152
+
153
+ .home-results-header {
154
+ display: flex;
155
+ align-items: baseline;
156
+ justify-content: space-between;
157
+ margin-bottom: 1rem;
158
+ }
159
+
160
+ .home-results-header h3 {
161
+ font-size: 1.1rem;
162
+ font-weight: 600;
163
+ }
164
+
165
+ .home-results-meta {
166
+ font-size: 0.75rem;
167
+ color: var(--text-muted);
168
+ }
169
+
170
+ .home-results-grid {
171
+ display: grid;
172
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
173
+ gap: 1rem;
174
+ }
175
+
176
+ .home-empty {
177
+ text-align: center;
178
+ padding: 3rem 1rem;
179
+ color: var(--text-muted);
180
+ font-size: 0.9rem;
181
+ }
182
+
183
+ .home-recent-searches {
184
+ margin-top: 1.5rem;
185
+ display: flex;
186
+ flex-direction: column;
187
+ align-items: center;
188
+ gap: 0.75rem;
189
+ }
190
+
191
+ .recent-label {
192
+ font-size: 0.75rem;
193
+ color: var(--text-muted);
194
+ font-weight: 500;
195
+ text-transform: uppercase;
196
+ letter-spacing: 0.05em;
197
+ }
198
+
199
+ .recent-links {
200
+ display: flex;
201
+ flex-wrap: wrap;
202
+ justify-content: center;
203
+ gap: 0.5rem;
204
+ }
205
+
206
+ .recent-link-btn {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 0.4rem;
210
+ padding: 0.4rem 0.8rem;
211
+ background: rgba(255, 255, 255, 0.03);
212
+ border: 1px solid var(--border-subtle);
213
+ border-radius: 20px;
214
+ color: var(--text-secondary);
215
+ font-size: 0.85rem;
216
+ transition: all var(--transition-fast);
217
+ }
218
+
219
+ .recent-link-btn:hover {
220
+ background: rgba(238, 105, 131, 0.1);
221
+ border-color: rgba(238, 105, 131, 0.3);
222
+ color: var(--accent-secondary);
223
+ transform: translateY(-1px);
224
+ }
225
+
226
+ .recent-link-btn svg {
227
+ color: var(--text-muted);
228
+ }
229
+
230
+ .home-scope-guidance {
231
+ display: flex;
232
+ gap: 1.25rem;
233
+ max-width: 720px;
234
+ margin: 1.5rem auto;
235
+ padding: 1.5rem;
236
+ background: rgba(238, 105, 131, 0.08);
237
+ border: 1px solid rgba(238, 105, 131, 0.15);
238
+ border-radius: var(--radius-md);
239
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
240
+ }
241
+
242
+ .scope-icon {
243
+ font-size: 1.5rem;
244
+ background: rgba(238, 105, 131, 0.12);
245
+ width: 45px;
246
+ height: 45px;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ border-radius: 12px;
251
+ flex-shrink: 0;
252
+ }
253
+
254
+ .scope-content h4 {
255
+ font-size: 0.95rem;
256
+ font-weight: 700;
257
+ color: var(--accent-secondary);
258
+ margin-bottom: 0.4rem;
259
+ }
260
+
261
+ .scope-content p {
262
+ font-size: 0.88rem;
263
+ line-height: 1.55;
264
+ color: var(--text-secondary);
265
+ }
266
+
267
+ @media (max-width: 768px) {
268
+ .home-headline {
269
+ font-size: 1.75rem;
270
+ }
271
+
272
+ .home-results-layout {
273
+ grid-template-columns: 1fr;
274
+ }
275
+
276
+ .home-results-grid {
277
+ grid-template-columns: 1fr;
278
+ }
279
+ }
client/src/app/Miner.css ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .miner-container {
2
+ padding: 3rem 5%;
3
+ max-width: 1200px;
4
+ margin: 0 auto;
5
+ }
6
+
7
+ .miner-header {
8
+ text-align: center;
9
+ margin-bottom: 3rem;
10
+ }
11
+
12
+ .miner-header h2 {
13
+ font-size: 2.5rem;
14
+ font-weight: 700;
15
+ margin-bottom: 0.5rem;
16
+ background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
17
+ -webkit-background-clip: text;
18
+ -webkit-text-fill-color: transparent;
19
+ }
20
+
21
+ .miner-header p {
22
+ color: var(--text-secondary);
23
+ font-size: 1.1rem;
24
+ }
25
+
26
+ .miner-content {
27
+ display: grid;
28
+ grid-template-columns: 2fr 1fr;
29
+ gap: 2rem;
30
+ align-items: start;
31
+ }
32
+
33
+ .miner-form {
34
+ background: var(--bg-card);
35
+ padding: 2.5rem;
36
+ border-radius: 16px;
37
+ border: 1px solid var(--border);
38
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
39
+ }
40
+
41
+ .form-group {
42
+ margin-bottom: 1.5rem;
43
+ }
44
+
45
+ .form-group label {
46
+ display: block;
47
+ margin-bottom: 0.5rem;
48
+ color: var(--text-secondary);
49
+ font-weight: 500;
50
+ font-size: 0.95rem;
51
+ }
52
+
53
+ .form-group input,
54
+ .form-group select {
55
+ width: 100%;
56
+ padding: 1rem;
57
+ background: var(--bg-main);
58
+ border: 1px solid var(--border);
59
+ border-radius: 8px;
60
+ color: var(--text-main);
61
+ font-family: 'Inter', sans-serif;
62
+ font-size: 1rem;
63
+ transition: all 0.2s ease;
64
+ }
65
+
66
+ .form-group input:focus,
67
+ .form-group select:focus {
68
+ outline: none;
69
+ border-color: var(--primary);
70
+ box-shadow: 0 0 0 2px rgba(108, 92, 237, 0.2);
71
+ }
72
+
73
+ .form-group input:disabled,
74
+ .form-group select:disabled {
75
+ opacity: 0.5;
76
+ cursor: not-allowed;
77
+ }
78
+
79
+ .form-row {
80
+ display: grid;
81
+ grid-template-columns: 1fr 1fr;
82
+ gap: 1.5rem;
83
+ }
84
+
85
+ .miner-btn {
86
+ width: 100%;
87
+ padding: 1rem;
88
+ font-size: 1.1rem;
89
+ margin-top: 1rem;
90
+ display: flex;
91
+ justify-content: center;
92
+ align-items: center;
93
+ gap: 10px;
94
+ }
95
+
96
+ .miner-btn.mining {
97
+ background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
98
+ cursor: wait;
99
+ }
100
+
101
+ .mining-status-text {
102
+ margin-top: 1.5rem;
103
+ padding: 1rem;
104
+ background: rgba(253, 203, 110, 0.1);
105
+ border: 1px solid rgba(253, 203, 110, 0.3);
106
+ border-radius: 8px;
107
+ color: #fdcb6e;
108
+ font-size: 0.9rem;
109
+ text-align: center;
110
+ }
111
+
112
+ .spinner {
113
+ width: 20px;
114
+ height: 20px;
115
+ border: 3px solid rgba(255, 255, 255, 0.3);
116
+ border-radius: 50%;
117
+ border-top-color: white;
118
+ animation: spin 1s ease-in-out infinite;
119
+ }
120
+
121
+ @keyframes spin {
122
+ to {
123
+ transform: rotate(360deg);
124
+ }
125
+ }
126
+
127
+ .miner-info {
128
+ background: var(--bg-card);
129
+ padding: 2rem;
130
+ border-radius: 16px;
131
+ border: 1px solid var(--border);
132
+ }
133
+
134
+ .miner-info h3 {
135
+ font-size: 1.2rem;
136
+ margin-bottom: 1.5rem;
137
+ color: var(--text-main);
138
+ }
139
+
140
+ .miner-info ul {
141
+ list-style: none;
142
+ padding: 0;
143
+ margin: 0;
144
+ }
145
+
146
+ .miner-info li {
147
+ margin-bottom: 1.5rem;
148
+ color: var(--text-secondary);
149
+ line-height: 1.6;
150
+ font-size: 0.95rem;
151
+ }
152
+
153
+ .miner-info li strong {
154
+ color: var(--text-main);
155
+ }
156
+
157
+ @media (max-width: 768px) {
158
+ .miner-content {
159
+ grid-template-columns: 1fr;
160
+ }
161
+
162
+ .form-row {
163
+ grid-template-columns: 1fr;
164
+ gap: 0;
165
+ }
166
+ }
client/src/app/NotFound.css ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .notfound-container {
2
+ min-height: calc(100vh - 64px);
3
+ display: flex;
4
+ align-items: center;
5
+ justify-content: center;
6
+ padding: 2rem;
7
+ background: var(--bg-primary);
8
+ text-align: center;
9
+ }
10
+
11
+ .notfound-content {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ gap: 1rem;
16
+ }
17
+
18
+ .notfound-title {
19
+ font-size: 6rem;
20
+ font-weight: 800;
21
+ line-height: 1;
22
+ background: var(--accent-gradient);
23
+ -webkit-background-clip: text;
24
+ -webkit-text-fill-color: transparent;
25
+ filter: drop-shadow(0 0 20px rgba(238, 105, 131, 0.3));
26
+ }
27
+
28
+ .notfound-subtitle {
29
+ font-size: 1.5rem;
30
+ color: var(--text-primary);
31
+ }
32
+
33
+ .notfound-text {
34
+ color: var(--text-secondary);
35
+ font-size: 1rem;
36
+ margin-bottom: 1rem;
37
+ }
38
+
39
+ .notfound-btn {
40
+ padding: 0.75rem 2rem;
41
+ background: var(--bg-secondary);
42
+ color: var(--text-primary);
43
+ text-decoration: none;
44
+ border: 1px solid var(--border-subtle);
45
+ border-radius: var(--radius-md);
46
+ font-weight: 600;
47
+ transition: all var(--transition-normal);
48
+ }
49
+
50
+ .notfound-btn:hover {
51
+ background: var(--bg-input);
52
+ border-color: var(--border-hover);
53
+ transform: translateY(-2px);
54
+ }
client/src/app/about/page.jsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import '../About.css';
4
+
5
+ const TECH_STACK = [
6
+ { name: 'React 19', desc: 'Component-based UI with hooks', icon: '⚛️' },
7
+ { name: 'Next.js 15', desc: 'App Router, Server Components, and optimized performance', icon: '🚀' },
8
+ { name: 'Express', desc: 'Layered REST API with middleware', icon: '🛤️' },
9
+ { name: 'Groq + Llama 3.3', desc: 'LLM intent parsing and review analysis', icon: '🧠' },
10
+ { name: 'Helmet + CORS', desc: 'Security headers and cross-origin policy', icon: '🛡️' },
11
+ { name: 'Rate Limiting', desc: 'Per-IP request throttling', icon: '⏱️' },
12
+ ];
13
+
14
+ const ARCHITECTURE = [
15
+ { layer: 'Routes', purpose: 'HTTP endpoint definitions', interview: 'Keeps routing declarative and separate from logic' },
16
+ { layer: 'Middleware', purpose: 'Cross-cutting concerns (auth, safety, rate limit)', interview: 'Runs before controller — reject early, save resources' },
17
+ { layer: 'Controllers', purpose: 'Thin orchestration layer', interview: 'Calls services in order, shapes response — no business logic here' },
18
+ { layer: 'Services', purpose: 'Business logic (AI, scraping, safety)', interview: 'Independently testable, reusable across routes' },
19
+ { layer: 'Validators', purpose: 'Request body schema validation', interview: 'Fail fast with clear messages before processing' },
20
+ ];
21
+
22
+ export default function About() {
23
+ return (
24
+ <main className="about">
25
+ <section className="about-hero fade-in-up">
26
+ <h2 className="about-title">
27
+ What is <span className="accent-text">RedThread</span>?
28
+ </h2>
29
+ <p className="about-lead">
30
+ RedThread is an AI that thinks before it searches.
31
+ By interpreting your intent and adapting to context, it removes the noise
32
+ of traditional search to find results that actually match your needs.
33
+ </p>
34
+ </section>
35
+
36
+ <section className="about-section fade-in-up stagger-1">
37
+ <h3>How It Works</h3>
38
+ <div className="about-pipeline">
39
+ {['User Query', 'Safety Check', 'AI Intent Parsing', 'Smart Scraping', 'Review Analysis', 'Structured Results'].map((step, i) => (
40
+ <div key={i} className="pipeline-step">
41
+ <span className="pipeline-num">{i + 1}</span>
42
+ <span className="pipeline-label">{step}</span>
43
+ {i < 5 && <span className="pipeline-arrow">→</span>}
44
+ </div>
45
+ ))}
46
+ </div>
47
+ </section>
48
+
49
+ <section className="about-section fade-in-up stagger-2">
50
+ <h3>Tech Stack</h3>
51
+ <div className="about-grid">
52
+ {TECH_STACK.map((t, i) => (
53
+ <div key={i} className="about-card">
54
+ <span className="about-card-icon">{t.icon}</span>
55
+ <h4>{t.name}</h4>
56
+ <p>{t.desc}</p>
57
+ </div>
58
+ ))}
59
+ </div>
60
+ </section>
61
+
62
+ <section className="about-section fade-in-up stagger-3">
63
+ <h3>Backend Architecture</h3>
64
+ <div className="about-table-wrap">
65
+ <table className="about-table">
66
+ <thead>
67
+ <tr><th>Layer</th><th>Purpose</th><th>Why It Matters</th></tr>
68
+ </thead>
69
+ <tbody>
70
+ {ARCHITECTURE.map((a, i) => (
71
+ <tr key={i}>
72
+ <td className="about-table-layer">{a.layer}</td>
73
+ <td>{a.purpose}</td>
74
+ <td className="about-table-interview">{a.interview}</td>
75
+ </tr>
76
+ ))}
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ </section>
81
+
82
+ <section className="about-section fade-in-up stagger-4">
83
+ <h3>Safety Philosophy</h3>
84
+ <div className="about-safety">
85
+ <div className="about-safety-item">
86
+ <span className="about-safety-icon">🚫</span>
87
+ <div>
88
+ <h4>Query Moderation</h4>
89
+ <p>Harmful, illegal, and adult content queries are blocked before they reach any service.</p>
90
+ </div>
91
+ </div>
92
+ <div className="about-safety-item">
93
+ <span className="about-safety-icon">🔒</span>
94
+ <div>
95
+ <h4>Platform Restrictions</h4>
96
+ <p>Scraping is limited to publicly available data. Social media and private platforms are blocked.</p>
97
+ </div>
98
+ </div>
99
+ <div className="about-safety-item">
100
+ <span className="about-safety-icon">⚖️</span>
101
+ <div>
102
+ <h4>User Responsibility</h4>
103
+ <p>Users agree to Terms of Use and accept responsibility for ethical, legal use of results.</p>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </section>
108
+ </main>
109
+ );
110
+ }
client/src/app/globals.css ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
2
+
3
+ *,
4
+ *::before,
5
+ *::after {
6
+ margin: 0;
7
+ padding: 0;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ :root {
12
+ --bg-primary: #850E35;
13
+ --bg-secondary: rgba(238, 105, 131, 0.2);
14
+ --bg-card: rgba(238, 105, 131, 0.15);
15
+ --bg-card-hover: rgba(238, 105, 131, 0.25);
16
+ --bg-input: rgba(255, 196, 196, 0.1);
17
+
18
+ --border-subtle: rgba(255, 196, 196, 0.2);
19
+ --border-focus: #FFC4C4;
20
+
21
+ --text-primary: #FCF5EE;
22
+ --text-secondary: #FFC4C4;
23
+ --text-muted: rgba(252, 245, 238, 0.65);
24
+
25
+ --accent-primary: #FFC4C4;
26
+ --accent-secondary: #FCF5EE;
27
+ --accent-tertiary: #EE6983;
28
+ --accent-gradient: linear-gradient(135deg, #FFC4C4 0%, #EE6983 100%);
29
+ --accent-glow: 0 0 20px rgba(255, 196, 196, 0.4);
30
+
31
+ --success: #2ecc71;
32
+ --warning: #f1c40f;
33
+ --danger: #e74c3c;
34
+
35
+ --radius-sm: 8px;
36
+ --radius-md: 12px;
37
+ --radius-lg: 16px;
38
+ --radius-xl: 24px;
39
+
40
+ --shadow-sm: 0 2px 8px rgba(133, 14, 53, 0.06);
41
+ --shadow-md: 0 4px 16px rgba(133, 14, 53, 0.1);
42
+ --shadow-lg: 0 8px 32px rgba(133, 14, 53, 0.15);
43
+
44
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
45
+ --transition-fast: 150ms ease;
46
+ --transition-normal: 250ms ease;
47
+ --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
48
+ }
49
+
50
+ html {
51
+ font-size: 16px;
52
+ scroll-behavior: smooth;
53
+ }
54
+
55
+ body {
56
+ font-family: var(--font-family);
57
+ background: var(--bg-primary);
58
+ color: var(--text-primary);
59
+ line-height: 1.6;
60
+ min-height: 100vh;
61
+ -webkit-font-smoothing: antialiased;
62
+ -moz-osx-font-smoothing: grayscale;
63
+ }
64
+
65
+ #root {
66
+ min-height: 100vh;
67
+ display: flex;
68
+ flex-direction: column;
69
+ }
70
+
71
+ ::selection {
72
+ background: var(--accent-primary);
73
+ color: white;
74
+ }
75
+
76
+ button {
77
+ cursor: pointer;
78
+ font-family: var(--font-family);
79
+ border: none;
80
+ outline: none;
81
+ }
82
+
83
+ input,
84
+ select,
85
+ textarea {
86
+ font-family: var(--font-family);
87
+ outline: none;
88
+ }
89
+
90
+ select {
91
+ appearance: none;
92
+ color: var(--text-primary);
93
+ background: #850E35;
94
+ }
95
+
96
+ /* Maintain visibility fix in dropdowns */
97
+ option {
98
+ background-color: #850E35;
99
+ color: var(--text-primary);
100
+ padding: 10px;
101
+ }
102
+
103
+ @keyframes fadeInUp {
104
+ from {
105
+ opacity: 0;
106
+ transform: translateY(20px);
107
+ }
108
+
109
+ to {
110
+ opacity: 1;
111
+ transform: translateY(0);
112
+ }
113
+ }
114
+
115
+ @keyframes spin {
116
+ to {
117
+ transform: rotate(360deg);
118
+ }
119
+ }
120
+
121
+ .fade-in-up {
122
+ animation: fadeInUp var(--transition-slow) both;
123
+ }
124
+
125
+ .stagger-1 {
126
+ animation-delay: 0.1s;
127
+ }
128
+
129
+ .stagger-2 {
130
+ animation-delay: 0.2s;
131
+ }
132
+
133
+ .stagger-3 {
134
+ animation-delay: 0.3s;
135
+ }
136
+
137
+ .stagger-4 {
138
+ animation-delay: 0.4s;
139
+ }
client/src/app/history/page.jsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import useSearchHistory from '../../hooks/useSearchHistory';
5
+ import { useToast } from '../../contexts/ToastContext';
6
+ import '../History.css';
7
+
8
+ export default function History() {
9
+ const { history, clearHistory, removeEntry } = useSearchHistory();
10
+ const router = useRouter();
11
+ const { addToast } = useToast();
12
+
13
+ function handleRerun(query) {
14
+ // Next.js App Router doesn't have state in navigate, using query params instead.
15
+ router.push(`/?rerunQuery=${encodeURIComponent(query)}`);
16
+ }
17
+
18
+ function handleClear() {
19
+ clearHistory();
20
+ addToast('Search history cleared', 'success');
21
+ }
22
+
23
+ function handleRemove(id) {
24
+ removeEntry(id);
25
+ addToast('Entry removed from history', 'info');
26
+ }
27
+
28
+ function formatTime(iso) {
29
+ const d = new Date(iso);
30
+ const now = new Date();
31
+ const diffMs = now - d;
32
+ const diffMins = Math.floor(diffMs / 60000);
33
+ if (diffMins < 1) return 'Just now';
34
+ if (diffMins < 60) return `${diffMins}m ago`;
35
+ const diffHours = Math.floor(diffMins / 60);
36
+ if (diffHours < 24) return `${diffHours}h ago`;
37
+ return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
38
+ }
39
+
40
+ return (
41
+ <main className="history-page">
42
+ <div className="history-header fade-in-up">
43
+ <div>
44
+ <h2>Search History</h2>
45
+ <p className="history-subtitle">{history.length} search{history.length !== 1 ? 'es' : ''} saved locally</p>
46
+ </div>
47
+ {history.length > 0 && (
48
+ <button className="history-clear-btn" onClick={handleClear}>
49
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
50
+ <polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
51
+ </svg>
52
+ Clear All
53
+ </button>
54
+ )}
55
+ </div>
56
+
57
+ {history.length === 0 ? (
58
+ <div className="history-empty fade-in-up stagger-1">
59
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
60
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
61
+ </svg>
62
+ <p>No searches yet.</p>
63
+ <p className="history-empty-sub">Your searches will appear here once you start exploring.</p>
64
+ </div>
65
+ ) : (
66
+ <div className="history-list">
67
+ {history.map((entry, i) => (
68
+ <div key={entry.id} className={`history-item fade-in-up stagger-${(i % 4) + 1}`}>
69
+ <div className="history-item-main">
70
+ <p className="history-item-query">"{entry.query}"</p>
71
+ <div className="history-item-meta">
72
+ {entry.intent?.category && (
73
+ <span className="history-chip">{entry.intent.category}</span>
74
+ )}
75
+ {entry.intent?.location && (
76
+ <span className="history-chip">{entry.intent.location}</span>
77
+ )}
78
+ <span className="history-item-results">{entry.resultCount} result{entry.resultCount !== 1 ? 's' : ''}</span>
79
+ <span className="history-item-time">{formatTime(entry.timestamp)}</span>
80
+ </div>
81
+ </div>
82
+ <div className="history-item-actions">
83
+ <button className="history-rerun-btn" onClick={() => handleRerun(entry.query)} title="Search again">
84
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
85
+ <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
86
+ </svg>
87
+ </button>
88
+ <button className="history-remove-btn" onClick={() => handleRemove(entry.id)} title="Remove">
89
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
90
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
91
+ </svg>
92
+ </button>
93
+ </div>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ )}
98
+ </main>
99
+ );
100
+ }
client/src/app/layout.jsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './globals.css';
2
+ import Header from '../components/Header';
3
+ import { Providers } from '../components/Providers';
4
+
5
+ export const metadata = {
6
+ title: 'RedThread - AI-assisted location-based recommendations',
7
+ description: 'Find the perfect spot, powered by AI.',
8
+ };
9
+
10
+ export default function RootLayout({ children }) {
11
+ return (
12
+ <html lang="en">
13
+ <body suppressHydrationWarning={true}>
14
+ <Providers>
15
+ <Header />
16
+ {children}
17
+ </Providers>
18
+ </body>
19
+ </html>
20
+ );
21
+ }
client/src/app/not-found.jsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import './NotFound.css';
5
+
6
+ export default function NotFound() {
7
+ return (
8
+ <main className="not-found fade-in-up">
9
+ <div className="not-found-content">
10
+ <div className="not-found-icon">
11
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
12
+ <circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
13
+ </svg>
14
+ </div>
15
+ <h2>404 - Page Not Found</h2>
16
+ <p>The page you're looking for doesn't exist or has been moved.</p>
17
+ <Link href="/" className="btn-primary">
18
+ Return Home
19
+ </Link>
20
+ </div>
21
+ </main>
22
+ );
23
+ }
client/src/app/page.jsx ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import SearchBar from '../components/SearchBar';
6
+ import ResultCard from '../components/ResultCard';
7
+ import SafetyBanner from '../components/SafetyBanner';
8
+ import FilterPanel from '../components/FilterPanel';
9
+ import SkeletonCard from '../components/SkeletonCard';
10
+ import ResultModal from '../components/ResultModal';
11
+ import ClarificationPrompt from '../components/ClarificationPrompt';
12
+ import useSearchHistory from '../hooks/useSearchHistory';
13
+ import { useToast } from '../contexts/ToastContext';
14
+ import { search } from '../api/client';
15
+ import { useGeolocation } from '../hooks/useGeolocation';
16
+ import './Home.css';
17
+
18
+ const DEFAULT_FILTERS = { category: 'all', maxBudget: '', features: [], sortBy: 'relevance' };
19
+
20
+ export default function Home() {
21
+ const [results, setResults] = useState([]);
22
+ const [intent, setIntent] = useState(null);
23
+ const [loading, setLoading] = useState(false);
24
+ const [error, setError] = useState(null);
25
+ const [meta, setMeta] = useState(null);
26
+ const [loadingMsg, setLoadingMsg] = useState('Parsing intent...');
27
+ const [hasSearched, setHasSearched] = useState(false);
28
+ const [filters, setFilters] = useState({});
29
+ const [dynamicFilters, setDynamicFilters] = useState([]);
30
+ const [selectedResult, setSelectedResult] = useState(null);
31
+ const [scopeMessage, setScopeMessage] = useState(null);
32
+ const [clarification, setClarification] = useState(null);
33
+
34
+ const { addEntry, history } = useSearchHistory();
35
+ const { addToast } = useToast();
36
+ const searchParams = useSearchParams();
37
+ const { location: userLocation, error: locationError, isLoading: locationLoading, requestLocation, clearLocation } = useGeolocation();
38
+
39
+ useEffect(() => {
40
+ const rerunQuery = searchParams.get('rerunQuery');
41
+ if (rerunQuery) {
42
+ handleSearch(rerunQuery);
43
+ // In Next.js, updating the URL without reloading is done via router.replace
44
+ // but for now, we'll just handle the search.
45
+ }
46
+ }, [searchParams]);
47
+
48
+ const handleClarificationSubmit = (answer) => {
49
+ if (clarification) {
50
+ const context = {
51
+ originalQuery: clarification.originalQuery,
52
+ question: clarification.question,
53
+ answer: answer
54
+ };
55
+ setClarification(null);
56
+ handleSearch(clarification.originalQuery, context);
57
+ }
58
+ };
59
+
60
+ const handleClarificationCancel = () => {
61
+ setClarification(null);
62
+ };
63
+
64
+ async function handleSearch(query, clarificationContext = null) {
65
+ setLoading(true);
66
+ setLoadingMsg('Searching...');
67
+ setError(null);
68
+ setScopeMessage(null);
69
+ setHasSearched(true);
70
+
71
+ // Show auto-scraping message if backend takes longer than 3 seconds
72
+ const timeoutId = setTimeout(() => {
73
+ setLoadingMsg('Scraping fresh data from the web (this may take 10-15s)...');
74
+ }, 3000);
75
+
76
+ try {
77
+ // Include `userLocation` dynamically if the user has requested it
78
+ const data = await search(query, null, filters, userLocation, clarificationContext);
79
+ clearTimeout(timeoutId);
80
+
81
+ if (data.isOutOfScope) {
82
+ setScopeMessage(data.scopeMessage);
83
+ setResults([]);
84
+ setIntent(data.intent || null);
85
+ setMeta(null);
86
+ setLoading(false);
87
+ return;
88
+ }
89
+
90
+ if (data.needsClarification) {
91
+ setClarification({ originalQuery: query, question: data.clarificationQuestion });
92
+ setResults([]);
93
+ setIntent(null);
94
+ setMeta(null);
95
+ setLoading(false);
96
+ return;
97
+ }
98
+
99
+ setClarification(null);
100
+
101
+ const res = data.results || [];
102
+ setResults(res);
103
+ setIntent(data.intent || null);
104
+ setMeta(data.meta || null);
105
+ setDynamicFilters(data.dynamicFilters || []);
106
+ setFilters({});
107
+ addEntry(query, data.intent, res.length);
108
+ } catch (err) {
109
+ clearTimeout(timeoutId);
110
+ const errorMsg = err.data?.error || err.message || 'Something went wrong';
111
+ setError(errorMsg);
112
+ addToast(errorMsg, 'error');
113
+ setResults([]);
114
+ setIntent(null);
115
+ } finally {
116
+ setLoading(false);
117
+ }
118
+ }
119
+
120
+ const filteredResults = useMemo(() => {
121
+ let filtered = [...results];
122
+
123
+ dynamicFilters.forEach(schema => {
124
+ const val = filters[schema.id];
125
+ if (!val) return;
126
+
127
+ if (schema.type === 'range') {
128
+ const max = parseInt(val, 10);
129
+ filtered = filtered.filter(r => {
130
+ const match = r.priceRange?.match(/([₹$£€])(\d+)/);
131
+ return match ? parseInt(match[2], 10) <= max : true;
132
+ });
133
+ } else if (schema.type === 'select') {
134
+ if (val.length > 0) {
135
+ filtered = filtered.filter(r =>
136
+ val.every(selectedOpt => {
137
+ const optL = selectedOpt.toLowerCase();
138
+ const inFeatures = (r.features || []).some(f => f.toLowerCase().includes(optL));
139
+ const inName = r.name?.toLowerCase().includes(optL);
140
+ const inSummary = r.reviewSummary?.toLowerCase().includes(optL);
141
+ const inCat = r.category?.toLowerCase().includes(optL);
142
+ return inFeatures || inName || inSummary || inCat;
143
+ })
144
+ );
145
+ }
146
+ } else if (schema.type === 'sort') {
147
+ if (val === 'rating') {
148
+ filtered.sort((a, b) => (b.rating === 'N/A' ? 0 : parseFloat(b.rating)) - (a.rating === 'N/A' ? 0 : parseFloat(a.rating)));
149
+ } else if (val === 'price_low') {
150
+ filtered.sort((a, b) => {
151
+ const pa = parseInt(a.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
152
+ const pb = parseInt(b.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
153
+ if (!pa) return 1; if (!pb) return -1;
154
+ return pa - pb;
155
+ });
156
+ } else if (val === 'price_high') {
157
+ filtered.sort((a, b) => {
158
+ const pa = parseInt(a.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
159
+ const pb = parseInt(b.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
160
+ return pb - pa;
161
+ });
162
+ }
163
+ }
164
+ });
165
+
166
+ return filtered;
167
+ }, [results, filters, dynamicFilters]);
168
+
169
+ return (
170
+ <main className="home">
171
+ <section className="home-hero">
172
+ <div className="home-hero-glow"></div>
173
+ <h2 className="home-headline fade-in-up">
174
+ AI that <span className="home-headline-accent">thinks</span><br />
175
+ before it searches
176
+ </h2>
177
+ <p className="home-subline fade-in-up stagger-1">
178
+ Describe what you want naturally — RedThread interprets your intent,
179
+ adapts to context, and finds results that actually match.
180
+ </p>
181
+ <div className="fade-in-up stagger-2" style={{ position: 'relative', zIndex: 10 }}>
182
+ <SearchBar
183
+ onSearch={handleSearch}
184
+ userLocation={userLocation}
185
+ locationLoading={locationLoading}
186
+ locationError={locationError}
187
+ requestLocation={requestLocation}
188
+ clearLocation={clearLocation}
189
+ loading={loading}
190
+ />
191
+ </div>
192
+ <div className="fade-in-up stagger-3" style={{ position: 'relative', zIndex: 1, marginTop: '0.75rem' }}>
193
+ <SafetyBanner />
194
+ </div>
195
+ {!hasSearched && history.length > 0 && (
196
+ <div className="home-recent-searches fade-in-up stagger-4">
197
+ <span className="recent-label">Your Recent Searches:</span>
198
+ <div className="recent-links">
199
+ {history.slice(0, 3).map((entry) => (
200
+ <button
201
+ key={entry.id}
202
+ className="recent-link-btn"
203
+ onClick={() => handleSearch(entry.query)}
204
+ >
205
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
206
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
207
+ </svg>
208
+ {entry.query}
209
+ </button>
210
+ ))}
211
+ </div>
212
+ </div>
213
+ )}
214
+ {clarification && !loading && (
215
+ <div className="home-clarification-wrapper fade-in-up stagger-2" style={{ position: 'relative', zIndex: 20 }}>
216
+ <ClarificationPrompt
217
+ question={clarification.question}
218
+ onSubmit={handleClarificationSubmit}
219
+ onCancel={handleClarificationCancel}
220
+ />
221
+ </div>
222
+ )}
223
+ </section>
224
+
225
+ {scopeMessage && !loading && (
226
+ <div className="home-scope-guidance fade-in-up">
227
+ <div className="scope-icon">💡</div>
228
+ <div className="scope-content">
229
+ <h4>Platform Guidance</h4>
230
+ <p>{scopeMessage}</p>
231
+ </div>
232
+ </div>
233
+ )}
234
+
235
+ {error && (
236
+ <div className="home-error fade-in-up">
237
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
238
+ <circle cx="12" cy="12" r="10" />
239
+ <line x1="15" y1="9" x2="9" y2="15" />
240
+ <line x1="9" y1="9" x2="15" y2="15" />
241
+ </svg>
242
+ <span style={{ flex: 1 }}>{error}</span>
243
+ {error.toLowerCase().includes('location') && !userLocation && (
244
+ <button
245
+ type="button"
246
+ className="error-action-btn"
247
+ onClick={requestLocation}
248
+ >
249
+ Use my location
250
+ </button>
251
+ )}
252
+ </div>
253
+ )}
254
+
255
+ {intent && !error && (
256
+ <section className="home-intent fade-in-up">
257
+ <h4 className="home-intent-title">Parsed Intent</h4>
258
+ <div className="home-intent-chips">
259
+ <span className="intent-chip reasoning">
260
+ <strong>AI Reasoning:</strong> {intent.reasoning}
261
+ </span>
262
+ <span className="intent-chip">
263
+ <strong>Category:</strong> {intent.category}
264
+ </span>
265
+ {intent.location && (
266
+ <span className="intent-chip">
267
+ <strong>Location:</strong> {intent.location}
268
+ </span>
269
+ )}
270
+ {intent.budget?.max && (
271
+ <span className="intent-chip">
272
+ <strong>Budget:</strong> up to {intent.budget.currency}{intent.budget.max}
273
+ </span>
274
+ )}
275
+ {intent.occasion && (
276
+ <span className="intent-chip">
277
+ <strong>Occasion:</strong> {intent.occasion}
278
+ </span>
279
+ )}
280
+ </div>
281
+ </section>
282
+ )}
283
+
284
+ {loading && (
285
+ <section className="home-results">
286
+ <div className="home-results-header">
287
+ <h3>{loadingMsg}</h3>
288
+ </div>
289
+ <div className="home-results-grid">
290
+ {[1, 2, 3, 4].map(i => <SkeletonCard key={i} />)}
291
+ </div>
292
+ </section>
293
+ )}
294
+
295
+ {!loading && hasSearched && results.length > 0 && (
296
+ <section className="home-results-layout">
297
+ <FilterPanel
298
+ filters={filters}
299
+ onChange={setFilters}
300
+ dynamicFilters={dynamicFilters}
301
+ resultCount={filteredResults.length}
302
+ />
303
+ <section className="home-results">
304
+ <div className="home-results-header">
305
+ <h3>Results</h3>
306
+ {meta && (
307
+ <span className="home-results-meta">
308
+ {filteredResults.length} of {meta.total} · {meta.source}
309
+ </span>
310
+ )}
311
+ </div>
312
+ <div className="home-results-grid">
313
+ {filteredResults.map((r, i) => (
314
+ <ResultCard
315
+ key={i}
316
+ result={{ ...r, cached: meta?.cached }}
317
+ index={i}
318
+ onClick={() => setSelectedResult(r)}
319
+ />
320
+ ))}
321
+ </div>
322
+ {filteredResults.length === 0 && (
323
+ <div className="home-empty">
324
+ <p>No results match your filters. Try adjusting them.</p>
325
+ </div>
326
+ )}
327
+ </section>
328
+ </section>
329
+ )}
330
+
331
+ {hasSearched && !loading && results.length === 0 && !error && (
332
+ <div className="home-empty fade-in-up">
333
+ <p>No results found. Try a different query or location.</p>
334
+ </div>
335
+ )}
336
+
337
+ {selectedResult && (
338
+ <ResultModal result={selectedResult} onClose={() => setSelectedResult(null)} />
339
+ )}
340
+ </main>
341
+ );
342
+ }
client/src/app/terms/Terms.css ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .terms-container {
2
+ max-width: 800px;
3
+ margin: 40px auto;
4
+ padding: 40px;
5
+ background: var(--bg-card);
6
+ border: 1px solid var(--border-subtle);
7
+ border-radius: var(--radius-lg);
8
+ box-shadow: var(--shadow-lg);
9
+ }
10
+
11
+ .terms-header {
12
+ margin-bottom: 40px;
13
+ text-align: center;
14
+ }
15
+
16
+ .terms-header h1 {
17
+ font-size: 2.5rem;
18
+ color: var(--text-primary);
19
+ margin-bottom: 15px;
20
+ }
21
+
22
+ .terms-last-updated {
23
+ font-size: 0.9rem;
24
+ color: var(--text-muted);
25
+ }
26
+
27
+ .terms-section {
28
+ margin-bottom: 30px;
29
+ }
30
+
31
+ .terms-section h2 {
32
+ font-size: 1.5rem;
33
+ color: var(--accent-primary);
34
+ margin-bottom: 15px;
35
+ border-bottom: 1px solid var(--border-subtle);
36
+ padding-bottom: 10px;
37
+ }
38
+
39
+ .terms-section p {
40
+ font-size: 1rem;
41
+ line-height: 1.8;
42
+ color: var(--text-secondary);
43
+ margin-bottom: 15px;
44
+ }
45
+
46
+ .terms-section ul {
47
+ list-style: none;
48
+ padding-left: 0;
49
+ }
50
+
51
+ .terms-section li {
52
+ position: relative;
53
+ padding-left: 25px;
54
+ margin-bottom: 12px;
55
+ color: var(--text-secondary);
56
+ }
57
+
58
+ .terms-section li::before {
59
+ content: "→";
60
+ position: absolute;
61
+ left: 0;
62
+ color: var(--accent-tertiary);
63
+ }
64
+
65
+ .terms-footer {
66
+ margin-top: 50px;
67
+ padding-top: 30px;
68
+ border-top: 1px solid var(--border-subtle);
69
+ text-align: center;
70
+ color: var(--text-muted);
71
+ }
client/src/app/terms/page.jsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import './Terms.css';
4
+ import Link from 'next/link';
5
+
6
+ export default function Terms() {
7
+ return (
8
+ <main className="terms-container fade-in-up">
9
+ <header className="terms-header">
10
+ <h1>Terms of Use</h1>
11
+ <p className="terms-last-updated">Last Updated: March 2026</p>
12
+ </header>
13
+
14
+ <section className="terms-section">
15
+ <h2>1. Acceptance of Terms</h2>
16
+ <p>
17
+ By accessing and using RedThread, you agree to be bound by these Terms of Use and all applicable laws and regulations. If you do not agree with any of these terms, you are prohibited from using or accessing this site.
18
+ </p>
19
+ </section>
20
+
21
+ <section className="terms-section">
22
+ <h2>2. Ethical AI Use</h2>
23
+ <p>
24
+ RedThread is designed to be a helpful assistant for discovering location-based information. Users are strictly prohibited from using the platform for:
25
+ </p>
26
+ <ul>
27
+ <li>Generating or promoting harmful, illegal, or adult content.</li>
28
+ <li>Stalking, harassment, or any form of malicious monitoring.</li>
29
+ <li>Automated bulk scraping beyond normal individual research use.</li>
30
+ <li>Attempting to bypass safety filters or platform restrictions.</li>
31
+ </ul>
32
+ </section>
33
+
34
+ <section className="terms-section">
35
+ <h2>3. Data & Privacy</h2>
36
+ <p>
37
+ We value your privacy. RedThread processes your queries to provide relevant recommendations. While we strive for accuracy, AI-generated content may occasionally contain errors. Always verify critical information directly with the service provider.
38
+ </p>
39
+ </section>
40
+
41
+ <section className="terms-section">
42
+ <h2>4. Intellectual Property</h2>
43
+ <p>
44
+ The technology, design, and "RedThread" brand are the intellectual property of its creators. Users are granted a limited license for personal, non-commercial use of the search results.
45
+ </p>
46
+ </section>
47
+
48
+ <section className="terms-section">
49
+ <h2>5. Disclaimer</h2>
50
+ <p>
51
+ The materials on RedThread are provided on an 'as is' basis. RedThread makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, or fitness for a particular purpose.
52
+ </p>
53
+ </section>
54
+
55
+ <footer className="terms-footer">
56
+ <Link href="/" className="accent-text">Return to Search</Link>
57
+ </footer>
58
+ </main>
59
+ );
60
+ }
client/src/assets/react.svg ADDED
client/src/components/ClarificationPrompt.css ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .clarification-prompt {
2
+ background: var(--bg-secondary);
3
+ border: 1px solid var(--border-subtle);
4
+ border-radius: var(--radius-lg);
5
+ padding: 1.5rem;
6
+ max-width: 720px;
7
+ margin: 1rem auto;
8
+ box-shadow: var(--shadow-md);
9
+ position: relative;
10
+ z-index: 15;
11
+ }
12
+
13
+ .clarification-header {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 0.5rem;
17
+ margin-bottom: 1rem;
18
+ }
19
+
20
+ .clarification-icon {
21
+ font-size: 1.25rem;
22
+ }
23
+
24
+ .clarification-title {
25
+ font-weight: 600;
26
+ color: var(--primary-color);
27
+ font-size: 0.95rem;
28
+ text-transform: uppercase;
29
+ letter-spacing: 0.05em;
30
+ flex: 1;
31
+ }
32
+
33
+ .clarification-close {
34
+ background: none;
35
+ border: none;
36
+ color: var(--text-muted);
37
+ font-size: 1.5rem;
38
+ cursor: pointer;
39
+ line-height: 1;
40
+ padding: 0;
41
+ transition: color var(--transition-fast);
42
+ }
43
+
44
+ .clarification-close:hover {
45
+ color: #ff7675;
46
+ }
47
+
48
+ .clarification-question {
49
+ font-size: 1.05rem;
50
+ color: var(--text-primary);
51
+ line-height: 1.5;
52
+ margin-bottom: 1.25rem;
53
+ padding-left: 0.5rem;
54
+ border-left: 3px solid rgba(238, 105, 131, 0.4);
55
+ }
56
+
57
+ .clarification-form {
58
+ display: flex;
59
+ gap: 0.75rem;
60
+ }
61
+
62
+ .clarification-input {
63
+ flex: 1;
64
+ padding: 0.75rem 1rem;
65
+ background: var(--bg-input);
66
+ border: 1px solid var(--border-subtle);
67
+ border-radius: var(--radius-md);
68
+ color: var(--text-primary);
69
+ font-size: 0.95rem;
70
+ transition: all var(--transition-normal);
71
+ }
72
+
73
+ .clarification-input:focus {
74
+ border-color: var(--border-focus);
75
+ box-shadow: 0 0 0 3px rgba(238, 105, 131, 0.15);
76
+ outline: none;
77
+ }
78
+
79
+ .clarification-btn {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 0.5rem;
83
+ padding: 0.75rem 1.25rem;
84
+ background: rgba(238, 105, 131, 0.15);
85
+ color: var(--primary-color);
86
+ font-weight: 600;
87
+ border: 1px solid rgba(238, 105, 131, 0.3);
88
+ border-radius: var(--radius-md);
89
+ cursor: pointer;
90
+ transition: all var(--transition-fast);
91
+ }
92
+
93
+ .clarification-btn:hover:not(:disabled) {
94
+ background: rgba(238, 105, 131, 0.25);
95
+ text-shadow: 0 0 8px rgba(238, 105, 131, 0.4);
96
+ }
97
+
98
+ .clarification-btn:disabled {
99
+ opacity: 0.5;
100
+ cursor: not-allowed;
101
+ }
102
+
103
+ @media (max-width: 600px) {
104
+ .clarification-form {
105
+ flex-direction: column;
106
+ }
107
+
108
+ .clarification-btn {
109
+ justify-content: center;
110
+ }
111
+ }
client/src/components/ClarificationPrompt.jsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import './ClarificationPrompt.css';
3
+
4
+ export default function ClarificationPrompt({ question, onSubmit, onCancel }) {
5
+ const [answer, setAnswer] = useState('');
6
+ const inputRef = useRef(null);
7
+
8
+ useEffect(() => {
9
+ if (inputRef.current) {
10
+ inputRef.current.focus();
11
+ }
12
+ }, [question]);
13
+
14
+ const handleSubmit = (e) => {
15
+ e.preventDefault();
16
+ if (answer.trim()) {
17
+ onSubmit(answer.trim());
18
+ }
19
+ };
20
+
21
+ return (
22
+ <div className="clarification-prompt fade-in-up">
23
+ <div className="clarification-header">
24
+ <span className="clarification-icon">🤖</span>
25
+ <span className="clarification-title">Clarification Needed</span>
26
+ <button type="button" className="clarification-close" onClick={onCancel} title="Cancel search">
27
+ &times;
28
+ </button>
29
+ </div>
30
+
31
+ <p className="clarification-question">{question}</p>
32
+
33
+ <form className="clarification-form" onSubmit={handleSubmit}>
34
+ <input
35
+ ref={inputRef}
36
+ type="text"
37
+ className="clarification-input"
38
+ placeholder="Type your answer..."
39
+ value={answer}
40
+ onChange={(e) => setAnswer(e.target.value)}
41
+ />
42
+ <button type="submit" className="clarification-btn" disabled={!answer.trim()}>
43
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
44
+ <line x1="22" y1="2" x2="11" y2="13" />
45
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
46
+ </svg>
47
+ Send
48
+ </button>
49
+ </form>
50
+ </div>
51
+ );
52
+ }
client/src/components/ErrorBoundary.css ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .error-boundary-container {
2
+ min-height: 100vh;
3
+ display: flex;
4
+ align-items: center;
5
+ justify-content: center;
6
+ padding: 2rem;
7
+ background: var(--bg-primary);
8
+ text-align: center;
9
+ }
10
+
11
+ .error-boundary-content {
12
+ max-width: 480px;
13
+ background: var(--bg-card);
14
+ padding: 3rem 2rem;
15
+ border-radius: var(--radius-lg);
16
+ border: 1px solid var(--border-subtle);
17
+ box-shadow: var(--shadow-xl);
18
+ display: flex;
19
+ flex-direction: column;
20
+ align-items: center;
21
+ gap: 1.5rem;
22
+ }
23
+
24
+ .error-boundary-icon {
25
+ color: #ff7675;
26
+ filter: drop-shadow(0 0 12px rgba(255, 118, 117, 0.2));
27
+ }
28
+
29
+ .error-boundary-content h2 {
30
+ font-size: 1.5rem;
31
+ color: var(--text-primary);
32
+ margin-bottom: 0.5rem;
33
+ }
34
+
35
+ .error-boundary-content p {
36
+ color: var(--text-secondary);
37
+ font-size: 0.95rem;
38
+ line-height: 1.5;
39
+ }
40
+
41
+ .error-boundary-btn {
42
+ margin-top: 1rem;
43
+ padding: 0.75rem 2rem;
44
+ background: var(--bg-secondary);
45
+ color: var(--text-primary);
46
+ border: 1px solid var(--border-subtle);
47
+ border-radius: var(--radius-md);
48
+ font-weight: 600;
49
+ cursor: pointer;
50
+ transition: all var(--transition-normal);
51
+ }
52
+
53
+ .error-boundary-btn:hover {
54
+ background: var(--bg-input);
55
+ border-color: var(--border-hover);
56
+ transform: translateY(-2px);
57
+ }
58
+
59
+ .error-boundary-details {
60
+ margin-top: 1.5rem;
61
+ text-align: left;
62
+ background: rgba(0, 0, 0, 0.2);
63
+ padding: 1rem;
64
+ border-radius: var(--radius-sm);
65
+ font-size: 0.75rem;
66
+ color: var(--text-muted);
67
+ width: 100%;
68
+ overflow-x: auto;
69
+ border: 1px solid rgba(255, 118, 117, 0.1);
70
+ }
client/src/components/ErrorBoundary.jsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import './ErrorBoundary.css';
3
+
4
+ class ErrorBoundary extends React.Component {
5
+ constructor(props) {
6
+ super(props);
7
+ this.state = { hasError: false, error: null };
8
+ }
9
+
10
+ static getDerivedStateFromError(error) {
11
+ return { hasError: true, error };
12
+ }
13
+
14
+ componentDidCatch(error, errorInfo) {
15
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
16
+ }
17
+
18
+ render() {
19
+ if (this.state.hasError) {
20
+ return (
21
+ <div className="error-boundary-container">
22
+ <div className="error-boundary-content fade-in-up">
23
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="error-boundary-icon">
24
+ <polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
25
+ <line x1="12" y1="8" x2="12" y2="12" />
26
+ <line x1="12" y1="16" x2="12.01" y2="16" />
27
+ </svg>
28
+ <h2>Something went wrong.</h2>
29
+ <p>We've encountered an unexpected error. Our team has been notified.</p>
30
+ <button
31
+ className="error-boundary-btn"
32
+ onClick={() => window.location.reload()}
33
+ >
34
+ Reload Page
35
+ </button>
36
+ {process.env.NODE_ENV === 'development' && (
37
+ <pre className="error-boundary-details">{this.state.error?.toString()}</pre>
38
+ )}
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return this.props.children;
45
+ }
46
+ }
47
+
48
+ export default ErrorBoundary;
client/src/components/FilterPanel.css ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .filter-panel {
2
+ background: var(--bg-card);
3
+ border: 1px solid var(--border-subtle);
4
+ border-radius: var(--radius-lg);
5
+ overflow: hidden;
6
+ margin-bottom: 1.5rem;
7
+ }
8
+
9
+ .filter-toggle {
10
+ width: 100%;
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 0.5rem;
14
+ padding: 0.75rem 1rem;
15
+ background: none;
16
+ color: var(--text-primary);
17
+ font-size: 0.85rem;
18
+ font-weight: 600;
19
+ text-align: left;
20
+ transition: background var(--transition-fast);
21
+ }
22
+
23
+ .filter-toggle:hover {
24
+ background: rgba(255, 255, 255, 0.02);
25
+ }
26
+
27
+ .filter-active-dot {
28
+ width: 6px;
29
+ height: 6px;
30
+ border-radius: 50%;
31
+ background: var(--accent-primary);
32
+ box-shadow: 0 0 6px var(--accent-primary);
33
+ }
34
+
35
+ .filter-chevron {
36
+ margin-left: auto;
37
+ transition: transform var(--transition-fast);
38
+ }
39
+
40
+ .filter-chevron.open {
41
+ transform: rotate(180deg);
42
+ }
43
+
44
+ .filter-body {
45
+ padding: 0 1rem 1rem;
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 1rem;
49
+ animation: fadeInUp 200ms ease;
50
+ }
51
+
52
+ .filter-group {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.4rem;
56
+ }
57
+
58
+ .home-headline {
59
+ font-size: 2.5rem;
60
+ font-weight: 800;
61
+ line-height: 1.15;
62
+ margin-bottom: 1.5rem;
63
+ color: var(--text-primary);
64
+ text-shadow: 4px 4px 0px rgba(0, 0, 0, 0.3);
65
+ }
66
+
67
+ .filter-label {
68
+ font-size: 0.68rem;
69
+ font-weight: 600;
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.08em;
72
+ color: var(--text-muted);
73
+ }
74
+
75
+ .filter-chips {
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ gap: 0.35rem;
79
+ }
80
+
81
+ .filter-chip {
82
+ padding: 0.3rem 0.65rem;
83
+ border-radius: 20px;
84
+ font-size: 0.72rem;
85
+ font-weight: 500;
86
+ background: rgba(255, 255, 255, 0.04);
87
+ border: 1px solid var(--border-subtle);
88
+ color: var(--text-secondary);
89
+ transition: all var(--transition-fast);
90
+ }
91
+
92
+ .filter-chip:hover {
93
+ background: rgba(238, 105, 131, 0.08);
94
+ border-color: rgba(238, 105, 131, 0.2);
95
+ }
96
+
97
+ .filter-chip.active {
98
+ background: rgba(238, 105, 131, 0.2);
99
+ border-color: var(--accent-primary);
100
+ color: var(--accent-secondary);
101
+ }
102
+
103
+ .filter-budget-row {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.75rem;
107
+ }
108
+
109
+ .filter-slider {
110
+ flex: 1;
111
+ -webkit-appearance: none;
112
+ appearance: none;
113
+ height: 4px;
114
+ border-radius: 2px;
115
+ background: var(--border-subtle);
116
+ outline: none;
117
+ }
118
+
119
+ .filter-slider::-webkit-slider-thumb {
120
+ -webkit-appearance: none;
121
+ appearance: none;
122
+ width: 16px;
123
+ height: 16px;
124
+ border-radius: 50%;
125
+ background: var(--accent-primary);
126
+ cursor: pointer;
127
+ box-shadow: 0 0 6px rgba(238, 105, 131, 0.4);
128
+ }
129
+
130
+ .filter-budget-val {
131
+ font-size: 0.8rem;
132
+ font-weight: 600;
133
+ color: var(--accent-secondary);
134
+ min-width: 50px;
135
+ text-align: right;
136
+ }
137
+
138
+ .filter-select {
139
+ padding: 0.4rem 0.6rem;
140
+ background: var(--bg-input);
141
+ border: 1px solid var(--border-subtle);
142
+ border-radius: var(--radius-sm);
143
+ color: var(--text-primary);
144
+ font-size: 0.8rem;
145
+ cursor: pointer;
146
+ }
147
+
148
+ .filter-select:focus {
149
+ border-color: var(--border-focus);
150
+ }
151
+
152
+ .filter-select option {
153
+ background: #850E35;
154
+ color: var(--text-primary);
155
+ }
156
+
157
+ .filter-footer {
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: space-between;
161
+ padding-top: 0.5rem;
162
+ border-top: 1px solid var(--border-subtle);
163
+ }
164
+
165
+ .filter-count {
166
+ font-size: 0.72rem;
167
+ color: var(--text-muted);
168
+ }
169
+
170
+ .filter-reset {
171
+ font-size: 0.72rem;
172
+ font-weight: 500;
173
+ background: none;
174
+ color: var(--accent-secondary);
175
+ padding: 0.25rem 0.5rem;
176
+ border-radius: var(--radius-sm);
177
+ transition: background var(--transition-fast);
178
+ }
179
+
180
+ .filter-reset:hover {
181
+ background: rgba(238, 105, 131, 0.1);
182
+ }
client/src/components/FilterPanel.jsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import './FilterPanel.css';
3
+
4
+ export default function FilterPanel({ filters, onChange, dynamicFilters = [], resultCount }) {
5
+ const [expanded, setExpanded] = useState(true);
6
+
7
+ if (!dynamicFilters || dynamicFilters.length === 0) return null;
8
+
9
+ function updateFilter(key, value) {
10
+ onChange({ ...filters, [key]: value });
11
+ }
12
+
13
+ function toggleSelectVal(id, option) {
14
+ const current = filters[id] || [];
15
+ const next = current.includes(option)
16
+ ? current.filter(f => f !== option)
17
+ : [...current, option];
18
+ updateFilter(id, next);
19
+ }
20
+
21
+ function resetFilters() {
22
+ onChange({});
23
+ }
24
+
25
+ const hasActiveFilters = Object.values(filters).some(val =>
26
+ (Array.isArray(val) && val.length > 0) ||
27
+ (typeof val === 'string' && val !== '' && val !== 'relevance')
28
+ );
29
+
30
+ return (
31
+ <aside className={`filter-panel ${expanded ? 'expanded' : 'collapsed'}`}>
32
+ <button className="filter-toggle" onClick={() => setExpanded(!expanded)}>
33
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
34
+ <line x1="4" y1="6" x2="20" y2="6" /><line x1="4" y1="12" x2="14" y2="12" /><line x1="4" y1="18" x2="8" y2="18" />
35
+ </svg>
36
+ AI Filters
37
+ {hasActiveFilters && <span className="filter-active-dot"></span>}
38
+ <svg className={`filter-chevron ${expanded ? 'open' : ''}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
39
+ <polyline points="6 9 12 15 18 9" />
40
+ </svg>
41
+ </button>
42
+
43
+ {expanded && (
44
+ <div className="filter-body">
45
+ {dynamicFilters.map((schema, index) => {
46
+ if (schema.type === 'range') {
47
+ return (
48
+ <div key={index} className="filter-group">
49
+ <label className="filter-label">{schema.label}</label>
50
+ <div className="filter-budget-row">
51
+ <input
52
+ type="range"
53
+ min={schema.min || 0}
54
+ max={schema.max || 5000}
55
+ step={schema.step || 100}
56
+ value={filters[schema.id] || 0}
57
+ onChange={e => updateFilter(schema.id, e.target.value === '0' ? '' : e.target.value)}
58
+ className="filter-slider"
59
+ />
60
+ <span className="filter-budget-val">
61
+ {filters[schema.id] ? `₹${filters[schema.id]}` : 'Any'}
62
+ </span>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ if (schema.type === 'select') {
69
+ const activeSet = filters[schema.id] || [];
70
+ return (
71
+ <div key={index} className="filter-group">
72
+ <label className="filter-label">{schema.label}</label>
73
+ <div className="filter-chips">
74
+ {(schema.options || []).map(opt => (
75
+ <button
76
+ key={opt}
77
+ className={`filter-chip ${activeSet.includes(opt) ? 'active' : ''}`}
78
+ onClick={() => toggleSelectVal(schema.id, opt)}
79
+ >
80
+ {opt}
81
+ </button>
82
+ ))}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ if (schema.type === 'sort') {
89
+ return (
90
+ <div key={index} className="filter-group">
91
+ <label className="filter-label">{schema.label}</label>
92
+ <select
93
+ className="filter-select"
94
+ value={filters[schema.id] || 'relevance'}
95
+ onChange={e => updateFilter(schema.id, e.target.value)}
96
+ >
97
+ {(schema.options || []).map(opt => (
98
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
99
+ ))}
100
+ </select>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ return null;
106
+ })}
107
+
108
+ <div className="filter-footer">
109
+ {resultCount !== undefined && (
110
+ <span className="filter-count">{resultCount} result{resultCount !== 1 ? 's' : ''}</span>
111
+ )}
112
+ {hasActiveFilters && (
113
+ <button className="filter-reset" onClick={resetFilters}>Reset Custom AI Filters</button>
114
+ )}
115
+ </div>
116
+ </div>
117
+ )}
118
+ </aside>
119
+ );
120
+ }
client/src/components/Header.css ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .header {
2
+ position: sticky;
3
+ top: 0;
4
+ z-index: 100;
5
+ background: rgba(133, 14, 53, 0.85);
6
+ backdrop-filter: blur(16px);
7
+ border-bottom: 1px solid var(--border-subtle);
8
+ }
9
+
10
+ .header-inner {
11
+ max-width: 1200px;
12
+ margin: 0 auto;
13
+ padding: 0.75rem 1.5rem;
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ }
18
+
19
+ .header-brand {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 0.625rem;
23
+ text-decoration: none;
24
+ }
25
+
26
+ .header-logo {
27
+ display: flex;
28
+ align-items: center;
29
+ position: relative;
30
+ width: 32px;
31
+ height: 32px;
32
+ }
33
+
34
+ .thread-logo {
35
+ width: 100%;
36
+ height: 100%;
37
+ }
38
+
39
+ .thread-path-outer {
40
+ stroke: var(--accent-primary);
41
+ stroke-width: 1.5;
42
+ stroke-linecap: round;
43
+ stroke-dasharray: 80;
44
+ stroke-dashoffset: 80;
45
+ animation: thread-unfold 4s ease-in-out infinite alternate;
46
+ }
47
+
48
+ .thread-core {
49
+ fill: var(--text-primary);
50
+ }
51
+
52
+ .thread-line {
53
+ stroke: var(--accent-secondary);
54
+ stroke-width: 1;
55
+ stroke-dasharray: 28;
56
+ stroke-dashoffset: 28;
57
+ animation: thread-line-flow 4s linear infinite;
58
+ opacity: 0.6;
59
+ }
60
+
61
+ @keyframes thread-unfold {
62
+ 0% {
63
+ stroke-dashoffset: 80;
64
+ opacity: 0.3;
65
+ }
66
+
67
+ 50% {
68
+ stroke-dashoffset: 0;
69
+ opacity: 1;
70
+ }
71
+
72
+ 100% {
73
+ stroke-dashoffset: -80;
74
+ opacity: 0.3;
75
+ }
76
+ }
77
+
78
+ @keyframes core-pulse {
79
+ 0% {
80
+ transform: scale(0.8);
81
+ opacity: 0.6;
82
+ }
83
+
84
+ 100% {
85
+ transform: scale(1.2);
86
+ opacity: 1;
87
+ }
88
+ }
89
+
90
+ @keyframes thread-line-flow {
91
+ 0% {
92
+ stroke-dashoffset: 28;
93
+ }
94
+
95
+ 100% {
96
+ stroke-dashoffset: -28;
97
+ }
98
+ }
99
+
100
+ .header-title {
101
+ font-size: 1.25rem;
102
+ font-weight: 700;
103
+ background: var(--accent-gradient);
104
+ -webkit-background-clip: text;
105
+ -webkit-text-fill-color: transparent;
106
+ background-clip: text;
107
+ letter-spacing: -0.02em;
108
+ }
109
+
110
+ .header-badge {
111
+ font-size: 0.6rem;
112
+ font-weight: 700;
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.1em;
115
+ padding: 0.125rem 0.4rem;
116
+ border-radius: 4px;
117
+ background: var(--accent-gradient);
118
+ color: white;
119
+ }
120
+
121
+ .header-nav {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 0.25rem;
125
+ }
126
+
127
+ .header-link {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 0.35rem;
131
+ padding: 0.4rem 0.75rem;
132
+ border-radius: var(--radius-sm);
133
+ font-size: 0.8rem;
134
+ font-weight: 500;
135
+ color: var(--text-muted);
136
+ text-decoration: none;
137
+ transition: all var(--transition-fast);
138
+ }
139
+
140
+ .header-link:hover {
141
+ color: var(--text-secondary);
142
+ background: rgba(255, 255, 255, 0.04);
143
+ }
144
+
145
+ .header-link.active {
146
+ color: var(--accent-secondary);
147
+ background: rgba(238, 105, 131, 0.1);
148
+ }
149
+
150
+ .header-status {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 0.4rem;
154
+ font-size: 0.7rem;
155
+ color: var(--text-muted);
156
+ margin-left: 0.75rem;
157
+ padding-left: 0.75rem;
158
+ border-left: 1px solid var(--border-subtle);
159
+ }
160
+
161
+ .status-dot {
162
+ width: 6px;
163
+ height: 6px;
164
+ border-radius: 50%;
165
+ background: var(--success);
166
+ box-shadow: 0 0 8px var(--success);
167
+ animation: pulse-glow-green 2s infinite;
168
+ }
169
+
170
+ @keyframes pulse-glow-green {
171
+
172
+ 0%,
173
+ 100% {
174
+ box-shadow: 0 0 4px var(--success);
175
+ }
176
+
177
+ 50% {
178
+ box-shadow: 0 0 12px var(--success);
179
+ }
180
+ }
181
+
182
+ @media (max-width: 600px) {
183
+ .header-nav {
184
+ gap: 0;
185
+ }
186
+
187
+ .header-link span,
188
+ .header-link svg+* {
189
+ display: none;
190
+ }
191
+
192
+ .header-link {
193
+ padding: 0.4rem;
194
+ }
195
+
196
+ .header-status {
197
+ display: none;
198
+ }
199
+ }
client/src/components/Header.jsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { usePathname } from 'next/navigation';
5
+ import './Header.css';
6
+
7
+ export default function Header() {
8
+ const pathname = usePathname();
9
+
10
+ const isActive = (path) => pathname === path;
11
+
12
+ return (
13
+ <header className="header">
14
+ <div className="header-inner">
15
+ <Link href="/" className="header-brand">
16
+ <div className="header-logo">
17
+ <div className="header-logo">
18
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none" className="thread-logo">
19
+ <path d="M4 16C4 16 8 8 16 8C24 8 28 16 28 16C28 16 24 24 16 24C8 24 4 16 4 16Z" className="thread-path-outer" />
20
+ <circle cx="16" cy="16" r="3" className="thread-core" />
21
+ <path d="M2 16H30" className="thread-line" />
22
+ </svg>
23
+ </div>
24
+ </div>
25
+ <h1 className="header-title">RedThread</h1>
26
+ <span className="header-badge">AI</span>
27
+ </Link>
28
+
29
+ <nav className="header-nav">
30
+ <Link href="/" className={`header-link ${isActive('/') ? 'active' : ''}`}>
31
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
32
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
33
+ </svg>
34
+ Search
35
+ </Link>
36
+ <Link href="/history" className={`header-link ${isActive('/history') ? 'active' : ''}`}>
37
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
38
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
39
+ </svg>
40
+ History
41
+ </Link>
42
+ <Link href="/about" className={`header-link ${isActive('/about') ? 'active' : ''}`}>
43
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
44
+ <circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
45
+ </svg>
46
+ About
47
+ </Link>
48
+ <span className="header-status">
49
+ <span className="status-dot"></span>
50
+ Online
51
+ </span>
52
+ </nav>
53
+ </div>
54
+ </header>
55
+ );
56
+ }
client/src/components/Providers.jsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { ToastProvider } from '../contexts/ToastContext';
4
+
5
+ export function Providers({ children }) {
6
+ return (
7
+ <ToastProvider>
8
+ {children}
9
+ </ToastProvider>
10
+ );
11
+ }
client/src/components/ResultCard.css ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .result-card {
2
+ background: var(--bg-card);
3
+ border: 1px solid var(--border-subtle);
4
+ border-radius: var(--radius-md);
5
+ padding: 1rem;
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: 0.75rem;
9
+ cursor: pointer;
10
+ transition: all var(--transition-normal);
11
+ position: relative;
12
+ overflow: hidden;
13
+ }
14
+
15
+ .result-card::before {
16
+ content: '';
17
+ position: absolute;
18
+ inset: 0;
19
+ background: var(--accent-gradient);
20
+ opacity: 0;
21
+ transition: opacity var(--transition-normal);
22
+ z-index: 0;
23
+ }
24
+
25
+ .result-card:hover {
26
+ border-color: var(--border-hover);
27
+ box-shadow: var(--shadow-md);
28
+ }
29
+
30
+ .result-card:hover::before {
31
+ opacity: 0.03;
32
+ }
33
+
34
+ .result-card>* {
35
+ position: relative;
36
+ z-index: 1;
37
+ }
38
+
39
+ .result-card-header {
40
+ display: flex;
41
+ justify-content: space-between;
42
+ align-items: flex-start;
43
+ gap: 1rem;
44
+ }
45
+
46
+ .result-card-title-row {
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: 0.25rem;
50
+ }
51
+
52
+ .result-card-name {
53
+ font-size: 1.15rem;
54
+ font-weight: 700;
55
+ color: var(--text-primary);
56
+ line-height: 1.2;
57
+ }
58
+
59
+ .result-card-category {
60
+ font-size: 0.7rem;
61
+ font-weight: 600;
62
+ text-transform: uppercase;
63
+ letter-spacing: 0.05em;
64
+ color: var(--accent-primary);
65
+ background: rgba(238, 105, 131, 0.1);
66
+ padding: 0.2rem 0.5rem;
67
+ border-radius: 4px;
68
+ width: fit-content;
69
+ }
70
+
71
+ .result-card-rating {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 0.25rem;
75
+ font-weight: 700;
76
+ color: var(--text-primary);
77
+ background: var(--bg-input);
78
+ padding: 0.25rem 0.5rem;
79
+ border-radius: var(--radius-sm);
80
+ font-size: 0.85rem;
81
+ }
82
+
83
+ .result-card-address {
84
+ font-size: 0.85rem;
85
+ color: var(--text-secondary);
86
+ }
87
+
88
+ .result-card-price {
89
+ font-size: 0.9rem;
90
+ font-weight: 600;
91
+ color: var(--success);
92
+ }
93
+
94
+ .result-card-features {
95
+ display: flex;
96
+ flex-wrap: wrap;
97
+ gap: 0.4rem;
98
+ margin-top: 0.25rem;
99
+ }
100
+
101
+ .result-card-tag {
102
+ font-size: 0.75rem;
103
+ padding: 0.2rem 0.5rem;
104
+ background: var(--bg-secondary);
105
+ border: 1px solid var(--border-subtle);
106
+ border-radius: var(--radius-sm);
107
+ color: var(--text-secondary);
108
+ }
109
+
110
+ .result-card-review {
111
+ font-style: italic;
112
+ font-size: 0.85rem;
113
+ color: var(--text-muted);
114
+ margin-top: 0.5rem;
115
+ padding-left: 0.75rem;
116
+ border-left: 2px solid var(--border-hover);
117
+ }
118
+
119
+ .result-card-footer {
120
+ margin-top: auto;
121
+ padding-top: 1rem;
122
+ display: flex;
123
+ justify-content: space-between;
124
+ align-items: center;
125
+ font-size: 0.75rem;
126
+ border-top: 1px solid var(--border-subtle);
127
+ }
128
+
129
+ .result-card-source {
130
+ color: var(--text-muted);
131
+ text-transform: uppercase;
132
+ letter-spacing: 0.05em;
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 0.5rem;
136
+ }
137
+
138
+ .cache-badge {
139
+ background: rgba(46, 204, 113, 0.15);
140
+ color: var(--success);
141
+ padding: 0.1rem 0.4rem;
142
+ border-radius: 4px;
143
+ font-weight: 700;
144
+ font-size: 0.65rem;
145
+ }
146
+
147
+ .result-card-expand {
148
+ color: var(--accent-secondary);
149
+ font-weight: 600;
150
+ opacity: 0;
151
+ transform: translateX(-10px);
152
+ transition: all var(--transition-normal);
153
+ }
154
+
155
+ .result-card:hover .result-card-expand {
156
+ opacity: 1;
157
+ transform: translateX(0);
158
+ }
client/src/components/ResultCard.jsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './ResultCard.css';
2
+
3
+ export default function ResultCard({ result, index, onClick }) {
4
+ return (
5
+ <article
6
+ className={`result-card fade-in-up stagger-${(index % 4) + 1}`}
7
+ onClick={onClick}
8
+ role="button"
9
+ tabIndex={0}
10
+ onKeyDown={e => { if (e.key === 'Enter') onClick?.(); }}
11
+ >
12
+ <div className="result-card-header">
13
+ <div className="result-card-title-row">
14
+ <h3 className="result-card-name">{result.name}</h3>
15
+ <span className="result-card-category">{result.category}</span>
16
+ </div>
17
+ <div className="result-card-rating">
18
+ {result.rating !== 'N/A' && (
19
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="#fdcb6e" stroke="none">
20
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
21
+ </svg>
22
+ )}
23
+ <span>{result.rating}</span>
24
+ </div>
25
+ </div>
26
+
27
+ <p className="result-card-address">{result.address}</p>
28
+ {result.priceRange && result.priceRange !== 'N/A' && (
29
+ <p className="result-card-price">{result.priceRange}</p>
30
+ )}
31
+
32
+ {result.features && result.features.length > 0 && (
33
+ <div className="result-card-features">
34
+ {result.features.map((f, i) => (
35
+ <span key={i} className="result-card-tag">{f}</span>
36
+ ))}
37
+ </div>
38
+ )}
39
+
40
+ {result.reviewSummary && (
41
+ <p className="result-card-review">"{result.reviewSummary}"</p>
42
+ )}
43
+
44
+ <div className="result-card-footer">
45
+ <span className="result-card-source">
46
+ Source: {result.source}
47
+ {result.cached && <span className="cache-badge">⚡ Cached</span>}
48
+ </span>
49
+ <span className="result-card-expand">View Details →</span>
50
+ </div>
51
+ </article>
52
+ );
53
+ }
client/src/components/ResultModal.css ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .modal-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 200;
5
+ background: rgba(0, 0, 0, 0.65);
6
+ backdrop-filter: blur(4px);
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ padding: 1.5rem;
11
+ animation: fadeInUp 200ms ease;
12
+ }
13
+
14
+ .modal-content {
15
+ position: relative;
16
+ width: 100%;
17
+ max-width: 560px;
18
+ max-height: 85vh;
19
+ overflow-y: auto;
20
+ background: var(--bg-secondary);
21
+ border: 1px solid var(--border-subtle);
22
+ border-radius: var(--radius-xl);
23
+ padding: 2rem;
24
+ box-shadow: var(--shadow-lg);
25
+ }
26
+
27
+ .modal-close {
28
+ position: absolute;
29
+ top: 1rem;
30
+ right: 1rem;
31
+ width: 32px;
32
+ height: 32px;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ border-radius: 50%;
37
+ background: rgba(255, 255, 255, 0.05);
38
+ color: var(--text-muted);
39
+ transition: all var(--transition-fast);
40
+ }
41
+
42
+ .modal-close:hover {
43
+ background: rgba(255, 255, 255, 0.1);
44
+ color: var(--text-primary);
45
+ }
46
+
47
+ .modal-header {
48
+ display: flex;
49
+ justify-content: space-between;
50
+ align-items: flex-start;
51
+ margin-bottom: 1.25rem;
52
+ padding-right: 2.5rem;
53
+ }
54
+
55
+ .modal-name {
56
+ font-size: 1.3rem;
57
+ font-weight: 700;
58
+ margin-bottom: 0.3rem;
59
+ }
60
+
61
+ .modal-category {
62
+ font-size: 0.65rem;
63
+ font-weight: 600;
64
+ text-transform: uppercase;
65
+ letter-spacing: 0.08em;
66
+ padding: 0.15rem 0.5rem;
67
+ border-radius: 4px;
68
+ background: rgba(238, 105, 131, 0.15);
69
+ color: var(--accent-secondary);
70
+ }
71
+
72
+ .modal-rating {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 0.35rem;
76
+ font-size: 1.1rem;
77
+ font-weight: 700;
78
+ color: var(--warning);
79
+ }
80
+
81
+ .modal-detail-grid {
82
+ display: grid;
83
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
84
+ gap: 0.75rem;
85
+ margin-bottom: 1.25rem;
86
+ }
87
+
88
+ .modal-detail {
89
+ padding: 0.6rem 0.75rem;
90
+ background: var(--bg-card);
91
+ border-radius: var(--radius-sm);
92
+ }
93
+
94
+ .modal-detail-label {
95
+ display: block;
96
+ font-size: 0.62rem;
97
+ font-weight: 600;
98
+ text-transform: uppercase;
99
+ letter-spacing: 0.08em;
100
+ color: var(--text-muted);
101
+ margin-bottom: 0.2rem;
102
+ }
103
+
104
+ .modal-detail span:last-child {
105
+ font-size: 0.85rem;
106
+ color: var(--text-primary);
107
+ }
108
+
109
+ .modal-price {
110
+ color: var(--success) !important;
111
+ font-weight: 600;
112
+ }
113
+
114
+ .modal-section {
115
+ margin-bottom: 1.25rem;
116
+ }
117
+
118
+ .modal-section-title {
119
+ font-size: 0.7rem;
120
+ font-weight: 600;
121
+ text-transform: uppercase;
122
+ letter-spacing: 0.08em;
123
+ color: var(--text-muted);
124
+ margin-bottom: 0.5rem;
125
+ }
126
+
127
+ .modal-features {
128
+ display: flex;
129
+ flex-wrap: wrap;
130
+ gap: 0.4rem;
131
+ }
132
+
133
+ .modal-feature-tag {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 0.3rem;
137
+ font-size: 0.78rem;
138
+ font-weight: 500;
139
+ padding: 0.25rem 0.6rem;
140
+ border-radius: 20px;
141
+ background: rgba(0, 184, 148, 0.1);
142
+ border: 1px solid rgba(0, 184, 148, 0.2);
143
+ color: var(--success);
144
+ }
145
+
146
+ .modal-review {
147
+ font-size: 0.88rem;
148
+ color: var(--text-secondary);
149
+ font-style: italic;
150
+ line-height: 1.6;
151
+ padding: 0.75rem 1rem;
152
+ border-left: 3px solid var(--accent-primary);
153
+ background: rgba(238, 105, 131, 0.05);
154
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
155
+ margin: 0;
156
+ }
157
+
158
+ .modal-map-button {
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ gap: 0.5rem;
163
+ padding: 0.8rem 1.5rem;
164
+ background: var(--accent-primary);
165
+ color: white;
166
+ font-size: 0.9rem;
167
+ font-weight: 600;
168
+ text-decoration: none;
169
+ border-radius: var(--radius-md);
170
+ transition: all var(--transition-fast);
171
+ box-shadow: 0 4px 15px rgba(238, 105, 131, 0.2);
172
+ margin-top: 1rem;
173
+ }
174
+
175
+ .modal-map-button:hover {
176
+ background: var(--accent-secondary);
177
+ transform: translateY(-2px);
178
+ box-shadow: 0 6px 20px rgba(238, 105, 131, 0.3);
179
+ }
client/src/components/ResultModal.jsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from 'react';
2
+ import './ResultModal.css';
3
+
4
+ export default function ResultModal({ result, onClose }) {
5
+ useEffect(() => {
6
+ function handleKey(e) {
7
+ if (e.key === 'Escape') onClose();
8
+ }
9
+ document.addEventListener('keydown', handleKey);
10
+ document.body.style.overflow = 'hidden';
11
+ return () => {
12
+ document.removeEventListener('keydown', handleKey);
13
+ document.body.style.overflow = '';
14
+ };
15
+ }, [onClose]);
16
+
17
+ if (!result) return null;
18
+
19
+ return (
20
+ <div className="modal-overlay" onClick={onClose}>
21
+ <div className="modal-content" onClick={e => e.stopPropagation()}>
22
+ <button className="modal-close" onClick={onClose}>
23
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
24
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
25
+ </svg>
26
+ </button>
27
+
28
+ <div className="modal-header">
29
+ <div>
30
+ <h2 className="modal-name">{result.name}</h2>
31
+ <span className="modal-category">{result.category}</span>
32
+ </div>
33
+ <div className="modal-rating">
34
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="#fdcb6e" stroke="none">
35
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
36
+ </svg>
37
+ <span>{result.rating}</span>
38
+ </div>
39
+ </div>
40
+
41
+ <div className="modal-detail-grid">
42
+ <div className="modal-detail">
43
+ <span className="modal-detail-label">Address</span>
44
+ <span>{result.address}</span>
45
+ </div>
46
+ {result.priceRange && result.priceRange !== 'N/A' && (
47
+ <div className="modal-detail">
48
+ <span className="modal-detail-label">Price Range</span>
49
+ <span className="modal-price">{result.priceRange}</span>
50
+ </div>
51
+ )}
52
+ <div className="modal-detail">
53
+ <span className="modal-detail-label">Data Source</span>
54
+ <span>{result.source}</span>
55
+ </div>
56
+ </div>
57
+
58
+ {result.features && result.features.length > 0 && (
59
+ <div className="modal-section">
60
+ <h4 className="modal-section-title">Features</h4>
61
+ <div className="modal-features">
62
+ {result.features.map((f, i) => (
63
+ <span key={i} className="modal-feature-tag">
64
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
65
+ <polyline points="20 6 9 17 4 12" />
66
+ </svg>
67
+ {f}
68
+ </span>
69
+ ))}
70
+ </div>
71
+ </div>
72
+ )}
73
+
74
+ {result.reviewSummary && (
75
+ <div className="modal-section">
76
+ <h4 className="modal-section-title">AI Review Summary</h4>
77
+ <blockquote className="modal-review">
78
+ {result.reviewSummary}
79
+ </blockquote>
80
+ </div>
81
+ )}
82
+
83
+ <a
84
+ href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${result.name} ${result.address}`)}`}
85
+ target="_blank"
86
+ rel="noopener noreferrer"
87
+ className="modal-map-button"
88
+ >
89
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
90
+ <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
91
+ <circle cx="12" cy="10" r="3" />
92
+ </svg>
93
+ View on Google Maps
94
+ </a>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
client/src/components/SafetyBanner.css ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .safety-banner {
2
+ display: flex;
3
+ align-items: flex-start;
4
+ gap: 0.75rem;
5
+ max-width: 720px;
6
+ margin: 0 auto;
7
+ padding: 0.75rem 1rem;
8
+ background: rgba(238, 105, 131, 0.06);
9
+ border: 1px solid rgba(238, 105, 131, 0.12);
10
+ border-radius: var(--radius-md);
11
+ }
12
+
13
+ .safety-banner-icon {
14
+ flex-shrink: 0;
15
+ color: var(--accent-secondary);
16
+ margin-top: 0.1rem;
17
+ }
18
+
19
+ .safety-banner-text {
20
+ font-size: 0.75rem;
21
+ color: var(--text-secondary);
22
+ line-height: 1.5;
23
+ }
24
+
25
+ .safety-banner-text a {
26
+ color: var(--accent-secondary);
27
+ text-decoration: underline;
28
+ text-underline-offset: 2px;
29
+ }
client/src/components/SafetyBanner.jsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from 'next/link';
2
+ import './SafetyBanner.css';
3
+
4
+ export default function SafetyBanner() {
5
+ return (
6
+ <aside className="safety-banner">
7
+ <div className="safety-banner-icon">
8
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
9
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
10
+ </svg>
11
+ </div>
12
+ <div className="safety-banner-content">
13
+ <p className="safety-banner-text">
14
+ RedThread uses AI-powered safety validation. Harmful, illegal, or adult content queries are automatically blocked.
15
+ By searching, you agree to our <Link href="/terms">Terms of Use</Link> and accept responsibility for ethical use.
16
+ </p>
17
+ </div>
18
+ </aside>
19
+ );
20
+ }
client/src/components/SearchBar.css ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .searchbar-container {
2
+ max-width: 720px;
3
+ margin: 0 auto;
4
+ position: relative;
5
+ z-index: 100;
6
+ }
7
+
8
+ .searchbar {
9
+ display: flex;
10
+ gap: 0.75rem;
11
+ position: relative;
12
+ z-index: 10;
13
+ /* Ensure searchbar is above siblings like location-active-plate */
14
+ }
15
+
16
+ .location-hint {
17
+ margin-top: 0.6rem;
18
+ margin-left: 0.15rem;
19
+ font-size: 0.8rem;
20
+ color: var(--text-secondary);
21
+ max-width: 720px;
22
+ }
23
+
24
+ .location-hint .mono {
25
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
26
+ font-size: 0.78rem;
27
+ }
28
+
29
+ .searchbar-input-wrap {
30
+ flex: 1;
31
+ position: relative;
32
+ display: flex;
33
+ align-items: center;
34
+ }
35
+
36
+ .location-active-plate {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 0.5rem;
40
+ align-self: flex-start;
41
+ margin-top: 0.75rem;
42
+ margin-left: 1rem;
43
+ padding: 0.4rem 0.8rem;
44
+ background: rgba(238, 105, 131, 0.1);
45
+ border: 1px solid rgba(238, 105, 131, 0.2);
46
+ border-radius: 20px;
47
+ font-size: 0.8rem;
48
+ color: var(--text-secondary);
49
+ animation: fadeIn 0.2s ease-out;
50
+ }
51
+
52
+ .active-dot {
53
+ width: 6px;
54
+ height: 6px;
55
+ background-color: var(--primary-color);
56
+ border-radius: 50%;
57
+ box-shadow: 0 0 8px var(--primary-color);
58
+ }
59
+
60
+ .clear-location-text {
61
+ background: none;
62
+ border: none;
63
+ color: var(--text-muted);
64
+ font-size: 0.75rem;
65
+ margin-left: 0.25rem;
66
+ cursor: pointer;
67
+ text-decoration: underline;
68
+ text-decoration-color: transparent;
69
+ transition: all 0.2s ease;
70
+ padding: 0;
71
+ }
72
+
73
+ .clear-location-text:hover {
74
+ color: #ff7675;
75
+ text-decoration-color: #ff7675;
76
+ }
77
+
78
+
79
+
80
+ .location-btn {
81
+ position: absolute;
82
+ left: 10px;
83
+ background: transparent;
84
+ border: none;
85
+ color: var(--text-muted);
86
+ cursor: pointer;
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ padding: 8px;
91
+ border-radius: 50%;
92
+ transition: all 0.2s ease;
93
+ z-index: 2;
94
+ }
95
+
96
+ .location-btn:hover:not(:disabled) {
97
+ background: rgba(255, 255, 255, 0.1);
98
+ color: var(--text-main);
99
+ }
100
+
101
+ .location-btn.active {
102
+ color: var(--primary-color);
103
+ }
104
+
105
+ .location-btn:not(.active):not(.error):not(:disabled) {
106
+ animation: pulse-hint 3s infinite;
107
+ }
108
+
109
+ @keyframes pulse-hint {
110
+ 0% {
111
+ transform: scale(1);
112
+ filter: brightness(1);
113
+ }
114
+
115
+ 50% {
116
+ transform: scale(1.15);
117
+ filter: brightness(1.3);
118
+ color: var(--accent-primary);
119
+ }
120
+
121
+ 100% {
122
+ transform: scale(1);
123
+ filter: brightness(1);
124
+ }
125
+ }
126
+
127
+ .location-btn.error {
128
+ color: #ff7675;
129
+ }
130
+
131
+ .location-spinner {
132
+ width: 20px;
133
+ height: 20px;
134
+ border: 2px solid rgba(255, 255, 255, 0.2);
135
+ border-top-color: var(--primary-color);
136
+ border-radius: 50%;
137
+ animation: spin 1s linear infinite;
138
+ }
139
+
140
+ /* Consolidated search input styling */
141
+ .searchbar-input {
142
+ width: 100%;
143
+ padding: 0.875rem 1rem 0.875rem 3rem;
144
+ background: var(--bg-input);
145
+ border: 1.5px solid var(--border-subtle);
146
+ border-radius: var(--radius-md);
147
+ color: var(--text-primary);
148
+ font-size: 0.95rem;
149
+ transition: all var(--transition-normal);
150
+ }
151
+
152
+ .searchbar-input::placeholder {
153
+ color: var(--text-muted);
154
+ font-size: 0.85rem;
155
+ }
156
+
157
+ .searchbar-input:focus {
158
+ border-color: var(--border-focus);
159
+ box-shadow: 0 0 0 3px rgba(238, 105, 131, 0.15);
160
+ }
161
+
162
+ .searchbar-btn {
163
+ display: flex;
164
+ align-items: center;
165
+ gap: 0.5rem;
166
+ padding: 0.875rem 1.5rem;
167
+ background: var(--accent-gradient);
168
+ color: white;
169
+ font-weight: 600;
170
+ font-size: 0.9rem;
171
+ border-radius: var(--radius-md);
172
+ transition: all var(--transition-normal);
173
+ white-space: nowrap;
174
+ }
175
+
176
+ .searchbar-btn:hover:not(:disabled) {
177
+ transform: translateY(-1px);
178
+ box-shadow: var(--accent-glow);
179
+ }
180
+
181
+ .searchbar-btn:disabled {
182
+ opacity: 0.5;
183
+ cursor: not-allowed;
184
+ }
185
+
186
+ .searchbar-spinner {
187
+ width: 18px;
188
+ height: 18px;
189
+ border: 2.5px solid rgba(255, 255, 255, 0.3);
190
+ border-top-color: white;
191
+ border-radius: 50%;
192
+ animation: spin 0.6s linear infinite;
193
+ }
194
+
195
+ .searchbar-suggestions {
196
+ position: absolute;
197
+ top: calc(100% + 8px);
198
+ left: 0;
199
+ right: 0;
200
+ background: #850E35;
201
+ /* Opaque background to prevent overlap bleed-through */
202
+ border: 1px solid var(--border-subtle);
203
+ border-radius: var(--radius-md);
204
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
205
+ list-style: none;
206
+ z-index: 9999;
207
+ overflow: hidden;
208
+ animation: fadeInUp 150ms ease;
209
+ backdrop-filter: blur(10px);
210
+ }
211
+
212
+ .searchbar-suggestion {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 0.5rem;
216
+ padding: 0.6rem 0.75rem;
217
+ cursor: pointer;
218
+ transition: background var(--transition-fast);
219
+ font-size: 0.85rem;
220
+ }
221
+
222
+ .searchbar-suggestion:hover,
223
+ .searchbar-suggestion.active {
224
+ background: rgba(238, 105, 131, 0.1);
225
+ }
226
+
227
+ .suggestion-type-icon {
228
+ font-size: 0.8rem;
229
+ flex-shrink: 0;
230
+ }
231
+
232
+ .suggestion-text {
233
+ flex: 1;
234
+ color: var(--text-primary);
235
+ }
236
+
237
+ .suggestion-cat {
238
+ font-size: 0.65rem;
239
+ font-weight: 600;
240
+ text-transform: uppercase;
241
+ letter-spacing: 0.05em;
242
+ padding: 0.1rem 0.35rem;
243
+ border-radius: 3px;
244
+ background: rgba(238, 105, 131, 0.12);
245
+ color: var(--accent-secondary);
246
+ }
247
+
248
+ .searchbar-suggestion.is-history {
249
+ color: var(--text-secondary);
250
+ }
251
+
252
+ .searchbar-suggestion.is-history svg {
253
+ color: var(--text-muted);
254
+ }
255
+
256
+
257
+ .suggestion-tag {
258
+ font-size: 0.6rem;
259
+ font-weight: 500;
260
+ text-transform: uppercase;
261
+ letter-spacing: 0.05em;
262
+ padding: 0.1rem 0.3rem;
263
+ border-radius: 3px;
264
+ background: rgba(255, 255, 255, 0.05);
265
+ color: var(--text-muted);
266
+ margin-left: 0.5rem;
267
+ }
268
+
269
+ @media (max-width: 600px) {
270
+ .searchbar {
271
+ flex-direction: column;
272
+ }
273
+
274
+ .searchbar-btn {
275
+ justify-content: center;
276
+ }
277
+ }
client/src/components/SearchBar.jsx ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useMemo } from 'react';
2
+ import useSearchHistory from '../hooks/useSearchHistory';
3
+ import './SearchBar.css';
4
+
5
+ export default function SearchBar({
6
+ onSearch,
7
+ loading,
8
+ userLocation,
9
+ locationLoading,
10
+ locationError,
11
+ requestLocation,
12
+ clearLocation
13
+ }) {
14
+ const [query, setQuery] = useState('');
15
+ const [showSuggestions, setShowSuggestions] = useState(false);
16
+ const [activeIdx, setActiveIdx] = useState(-1);
17
+ const { history } = useSearchHistory();
18
+ const debounceRef = useRef(null);
19
+ const wrapperRef = useRef(null);
20
+
21
+ // Filter local history based on query
22
+ const historySuggestions = useMemo(() => {
23
+ if (!query.trim()) {
24
+ return history.slice(0, 5).map(h => ({ ...h, type: 'history', text: h.query }));
25
+ }
26
+ const lowerQuery = query.toLowerCase();
27
+ return history
28
+ .filter(h => h.query.toLowerCase().includes(lowerQuery))
29
+ .slice(0, 5)
30
+ .map(h => ({ ...h, type: 'history', text: h.query }));
31
+ }, [history, query]);
32
+
33
+ // Only show private history suggestions
34
+ const allSuggestions = useMemo(() => {
35
+ return historySuggestions;
36
+ }, [historySuggestions]);
37
+
38
+ useEffect(() => {
39
+ function handleClickOutside(e) {
40
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
41
+ setShowSuggestions(false);
42
+ }
43
+ }
44
+ document.addEventListener('mousedown', handleClickOutside);
45
+ return () => document.removeEventListener('mousedown', handleClickOutside);
46
+ }, []);
47
+
48
+ function handleInputChange(value) {
49
+ setQuery(value);
50
+ setActiveIdx(-1);
51
+
52
+ if (debounceRef.current) clearTimeout(debounceRef.current);
53
+
54
+ setShowSuggestions(true);
55
+
56
+ // We stop fetching global suggestions to respect "no global searches"
57
+ // and only rely on the filtered local historySuggestions.
58
+ }
59
+
60
+
61
+ function handleSelect(text) {
62
+ if (debounceRef.current) clearTimeout(debounceRef.current);
63
+ setQuery(text);
64
+ setShowSuggestions(false);
65
+ onSearch(text);
66
+ }
67
+
68
+
69
+ function handleSubmit(e) {
70
+ e.preventDefault();
71
+ if (query.trim().length < 3) return;
72
+ if (debounceRef.current) clearTimeout(debounceRef.current);
73
+ setShowSuggestions(false);
74
+ onSearch(query.trim());
75
+ }
76
+
77
+ function handleKeyDown(e) {
78
+ if (!showSuggestions || allSuggestions.length === 0) return;
79
+
80
+ if (e.key === 'ArrowDown') {
81
+ e.preventDefault();
82
+ setActiveIdx(prev => (prev < allSuggestions.length - 1 ? prev + 1 : 0));
83
+ } else if (e.key === 'ArrowUp') {
84
+ e.preventDefault();
85
+ setActiveIdx(prev => (prev > 0 ? prev - 1 : allSuggestions.length - 1));
86
+ } else if (e.key === 'Enter' && activeIdx >= 0) {
87
+ e.preventDefault();
88
+ handleSelect(allSuggestions[activeIdx].text);
89
+ } else if (e.key === 'Escape') {
90
+ setShowSuggestions(false);
91
+ } else if (e.key === 'Tab') {
92
+ setShowSuggestions(false);
93
+ }
94
+ }
95
+
96
+ return (
97
+ <div className="searchbar-container" ref={wrapperRef}>
98
+ <form className="searchbar" onSubmit={handleSubmit}>
99
+ <div className={`searchbar-input-wrap ${userLocation ? 'has-location' : ''}`}>
100
+ <button
101
+ type="button"
102
+ className={`location-btn ${userLocation ? 'active' : ''} ${locationError ? 'error' : ''}`}
103
+ onClick={userLocation ? clearLocation : requestLocation}
104
+ disabled={locationLoading || loading}
105
+ title={userLocation ? "Clear Location" : locationError ? locationError : "Use My Location"}
106
+ >
107
+ {locationLoading ? (
108
+ <span className="location-spinner"></span>
109
+ ) : (
110
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
111
+ <polygon points="3 11 22 2 13 21 11 13 3 11" />
112
+ </svg>
113
+ )}
114
+ </button>
115
+ <input
116
+ id="search-input"
117
+ type="text"
118
+ className="searchbar-input"
119
+ placeholder='Search anything — add your city or use your location'
120
+ value={query}
121
+ onChange={e => handleInputChange(e.target.value)}
122
+ onFocus={() => { setShowSuggestions(true); }}
123
+ onKeyDown={handleKeyDown}
124
+ disabled={loading}
125
+ autoComplete="off"
126
+ />
127
+
128
+ {showSuggestions && allSuggestions.length > 0 && !loading && (
129
+ <ul className="searchbar-suggestions" role="listbox">
130
+ {allSuggestions.map((s, i) => (
131
+ <li
132
+ key={`${s.type}-${i}`}
133
+ className={`searchbar-suggestion ${i === activeIdx ? 'active' : ''} ${s.type === 'history' ? 'is-history' : ''}`}
134
+ onClick={() => handleSelect(s.text)}
135
+ role="option"
136
+ aria-selected={i === activeIdx}
137
+ >
138
+ <span className="suggestion-type-icon">
139
+ {s.type === 'history' ? (
140
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
141
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
142
+ </svg>
143
+ ) : s.type === 'place' ? '📍' : s.type === 'category' ? '📂' : '💡'}
144
+ </span>
145
+ <span className="suggestion-text">{s.text}</span>
146
+ {s.type === 'history' && (
147
+ <span className="suggestion-tag">Recent</span>
148
+ )}
149
+ {s.category && (
150
+ <span className="suggestion-cat">{s.category}</span>
151
+ )}
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
156
+ </div>
157
+ <button
158
+ id="search-button"
159
+ type="submit"
160
+ className="searchbar-btn"
161
+ disabled={loading || query.trim().length < 3}
162
+ >
163
+ {loading ? (
164
+ <span className="searchbar-spinner"></span>
165
+ ) : (
166
+ <>
167
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
168
+ <path d="M22 2L11 13" /><path d="M22 2L15 22L11 13L2 9L22 2Z" />
169
+ </svg>
170
+ Search
171
+ </>
172
+ )}
173
+ </button>
174
+ </form>
175
+
176
+ {userLocation ? (
177
+ <div className="location-active-plate">
178
+ <span className="active-dot"></span>
179
+ Using precise GPS location
180
+ <button type="button" className="clear-location-text" onClick={clearLocation}>
181
+ Clear
182
+ </button>
183
+ </div>
184
+ ) : (
185
+ <div className="location-hint">
186
+ Tip: include your city in the search or use your location
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }
192
+
client/src/components/SkeletonCard.css ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .skeleton-card {
2
+ background: var(--bg-card);
3
+ border: 1px solid var(--border-subtle);
4
+ border-radius: var(--radius-lg);
5
+ padding: 1.25rem 1.5rem;
6
+ }
7
+
8
+ .skeleton-line {
9
+ border-radius: 4px;
10
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.04) 75%);
11
+ background-size: 400% 100%;
12
+ animation: shimmer 1.5s infinite;
13
+ }
14
+
15
+ .skeleton-header {
16
+ display: flex;
17
+ justify-content: space-between;
18
+ margin-bottom: 0.6rem;
19
+ }
20
+
21
+ .skeleton-title {
22
+ width: 55%;
23
+ height: 18px;
24
+ }
25
+
26
+ .skeleton-badge {
27
+ width: 40px;
28
+ height: 18px;
29
+ }
30
+
31
+ .skeleton-address {
32
+ width: 70%;
33
+ height: 12px;
34
+ margin-bottom: 0.35rem;
35
+ }
36
+
37
+ .skeleton-price {
38
+ width: 30%;
39
+ height: 14px;
40
+ margin-bottom: 0.75rem;
41
+ }
42
+
43
+ .skeleton-tags {
44
+ display: flex;
45
+ gap: 0.375rem;
46
+ margin-bottom: 0.75rem;
47
+ }
48
+
49
+ .skeleton-tag {
50
+ width: 60px;
51
+ height: 22px;
52
+ border-radius: 20px;
53
+ }
54
+
55
+ .skeleton-review {
56
+ width: 100%;
57
+ height: 12px;
58
+ margin-bottom: 0.35rem;
59
+ }
60
+
61
+ .skeleton-review-short {
62
+ width: 65%;
63
+ height: 12px;
64
+ }
client/src/components/SkeletonCard.jsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './SkeletonCard.css';
2
+
3
+ export default function SkeletonCard() {
4
+ return (
5
+ <div className="skeleton-card">
6
+ <div className="skeleton-header">
7
+ <div className="skeleton-line skeleton-title"></div>
8
+ <div className="skeleton-line skeleton-badge"></div>
9
+ </div>
10
+ <div className="skeleton-line skeleton-address"></div>
11
+ <div className="skeleton-line skeleton-price"></div>
12
+ <div className="skeleton-tags">
13
+ <div className="skeleton-line skeleton-tag"></div>
14
+ <div className="skeleton-line skeleton-tag"></div>
15
+ <div className="skeleton-line skeleton-tag"></div>
16
+ </div>
17
+ <div className="skeleton-line skeleton-review"></div>
18
+ <div className="skeleton-line skeleton-review-short"></div>
19
+ </div>
20
+ );
21
+ }
client/src/contexts/ToastContext.css ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .toast-container {
2
+ position: fixed;
3
+ bottom: 2rem;
4
+ right: 2rem;
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 0.75rem;
8
+ z-index: 9999;
9
+ pointer-events: none;
10
+ }
11
+
12
+ .toast {
13
+ pointer-events: auto;
14
+ background: var(--bg-card);
15
+ color: var(--text-primary);
16
+ padding: 1rem 1.25rem;
17
+ border-radius: var(--radius-md);
18
+ border: 1px solid var(--border-subtle);
19
+ box-shadow: var(--shadow-lg);
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 0.75rem;
23
+ font-size: 0.9rem;
24
+ font-weight: 500;
25
+ min-width: 250px;
26
+ max-width: 400px;
27
+ animation-duration: 0.3s;
28
+ }
29
+
30
+ .toast-success {
31
+ border-left: 4px solid var(--success);
32
+ }
33
+
34
+ .toast-error {
35
+ border-left: 4px solid #ff7675;
36
+ }
37
+
38
+ .toast-info {
39
+ border-left: 4px solid var(--accent-primary);
40
+ }
41
+
42
+ .toast-close {
43
+ margin-left: auto;
44
+ background: none;
45
+ border: none;
46
+ color: var(--text-muted);
47
+ font-size: 1.25rem;
48
+ line-height: 1;
49
+ cursor: pointer;
50
+ padding: 0 0.25rem;
51
+ transition: color var(--transition-fast);
52
+ }
53
+
54
+ .toast-close:hover {
55
+ color: var(--text-primary);
56
+ }
57
+
58
+ @media (max-width: 600px) {
59
+ .toast-container {
60
+ bottom: 1rem;
61
+ left: 1rem;
62
+ right: 1rem;
63
+ align-items: center;
64
+ }
65
+
66
+ .toast {
67
+ width: 100%;
68
+ max-width: 100%;
69
+ }
70
+ }
client/src/contexts/ToastContext.jsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useCallback } from 'react';
4
+ import './ToastContext.css';
5
+
6
+ const ToastContext = createContext(null);
7
+
8
+ export function ToastProvider({ children }) {
9
+ const [toasts, setToasts] = useState([]);
10
+
11
+ const addToast = useCallback((message, type = 'info', duration = 3000) => {
12
+ const id = Date.now();
13
+ setToasts(prev => [...prev, { id, message, type }]);
14
+
15
+ setTimeout(() => {
16
+ setToasts(prev => prev.filter(t => t.id !== id));
17
+ }, duration);
18
+ }, []);
19
+
20
+ const removeToast = useCallback(id => {
21
+ setToasts(prev => prev.filter(t => t.id !== id));
22
+ }, []);
23
+
24
+ return (
25
+ <ToastContext.Provider value={{ addToast }}>
26
+ {children}
27
+ <div className="toast-container" aria-live="polite">
28
+ {toasts.map(toast => (
29
+ <div key={toast.id} className={`toast toast-${toast.type} fade-in-up`}>
30
+ {toast.type === 'success' && '✅ '}
31
+ {toast.type === 'error' && '❌ '}
32
+ {toast.type === 'info' && '💡 '}
33
+ {toast.message}
34
+ <button
35
+ className="toast-close"
36
+ onClick={() => removeToast(toast.id)}
37
+ aria-label="Close"
38
+ >
39
+ ×
40
+ </button>
41
+ </div>
42
+ ))}
43
+ </div>
44
+ </ToastContext.Provider>
45
+ );
46
+ }
47
+
48
+ export function useToast() {
49
+ const context = useContext(ToastContext);
50
+ if (!context) {
51
+ throw new Error('useToast must be used within a ToastProvider');
52
+ }
53
+ return context;
54
+ }
client/src/hooks/useGeolocation.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+
3
+ export function useGeolocation() {
4
+ const [location, setLocation] = useState(null);
5
+ const [error, setError] = useState(null);
6
+ const [isLoading, setIsLoading] = useState(false);
7
+
8
+ const requestLocation = () => {
9
+ setIsLoading(true);
10
+ setError(null);
11
+
12
+ if (!navigator.geolocation) {
13
+ setError('Geolocation is not supported by your browser');
14
+ setIsLoading(false);
15
+ return;
16
+ }
17
+
18
+ navigator.geolocation.getCurrentPosition(
19
+ (position) => {
20
+ setLocation({
21
+ lat: position.coords.latitude,
22
+ lng: position.coords.longitude
23
+ });
24
+ setIsLoading(false);
25
+ },
26
+ (err) => {
27
+ setError(err.message || 'Failed to retrieve location');
28
+ setIsLoading(false);
29
+ },
30
+ {
31
+ enableHighAccuracy: true,
32
+ timeout: 10000,
33
+ maximumAge: 0
34
+ }
35
+ );
36
+ };
37
+
38
+ const clearLocation = () => {
39
+ setLocation(null);
40
+ setError(null);
41
+ };
42
+
43
+ return { location, error, isLoading, requestLocation, clearLocation };
44
+ }