From 9dedad5e9ad0196ecd677958df8a07eaad6fb554 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:04:42 +0000 Subject: [PATCH 1/2] Initial plan From 1d8163f81016d0197c91c649c952fc9eb4c66e49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:15:52 +0000 Subject: [PATCH 2/2] Add analytics route, web/main.js API module, and project README Co-authored-by: Vishnumgit <261896239+Vishnumgit@users.noreply.github.com> --- README.md | 83 +++++++++++++ backend/src/routes/analytics.js | 69 +++++++++++ backend/src/server.js | 6 +- database/001_init_schema.sql | 1 + docs/API.md | 42 +++++++ web/index.html | 165 +------------------------- web/main.js | 204 ++++++++++++++++++++++++++++++++ 7 files changed, 404 insertions(+), 166 deletions(-) create mode 100644 backend/src/routes/analytics.js create mode 100644 web/main.js diff --git a/README.md b/README.md index 8b13789..6c79a71 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ +# QR to 3D AR Hybrid Visualization System +Scan a QR code → view the linked product as an interactive 3D model in your browser (WebAR). + +## Architecture + +``` +Browser (WebAR) → Express API (Render) → PostgreSQL (Supabase) + ↑ + QR code scan +``` + +## Quick Start (Docker) + +```bash +git clone https://github.com/Vishnumgit/analysis-feedback-repo.git +cd analysis-feedback-repo +cp backend/.env.example backend/.env # fill in values +docker compose up --build +``` + +| Service | URL | +|----------|-----------------------------------| +| API | http://localhost:3000/api/health | +| Frontend | http://localhost:5173 | +| Database | localhost:5432 | + +## Deployment + +See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for step-by-step instructions to deploy on: + +- **Database** – Supabase (free tier) +- **Backend API** – Render (free tier) +- **Frontend** – Vercel (free tier) + +## API Reference + +See [docs/API.md](docs/API.md) for all endpoints. + +Key endpoints: + +| Method | Path | Description | +|--------|--------------------------------|-------------------------| +| GET | /api/health | Server health check | +| GET | /api/products | List products | +| GET | /api/products/:id | Get product by ID | +| GET | /api/qr/:qrCode | Look up QR code | +| POST | /api/qr/:qrCode/scan | Record a scan event | +| POST | /api/analytics/session | Log an AR session | + +## Project Structure + +``` +├── backend/ Express.js API +│ ├── src/ +│ │ ├── server.js Entry point +│ │ ├── config/database.js +│ │ └── routes/ +│ │ ├── qr.js +│ │ ├── products.js +│ │ └── analytics.js +│ ├── Dockerfile +│ └── package.json +├── web/ WebAR frontend +│ ├── index.html +│ ├── main.js Three.js viewer + API helpers +│ └── package.json +├── database/ +│ ├── 001_init_schema.sql Table definitions +│ └── seed_data.sql Sample products & QR codes +├── docs/ +│ ├── API.md +│ ├── DEPLOYMENT.md +│ └── SETUP.md +└── docker-compose.yml +``` + +## Local Development + +See [docs/SETUP.md](docs/SETUP.md) for detailed instructions. + +## License + +MIT diff --git a/backend/src/routes/analytics.js b/backend/src/routes/analytics.js new file mode 100644 index 0000000..f3e4070 --- /dev/null +++ b/backend/src/routes/analytics.js @@ -0,0 +1,69 @@ +'use strict'; + +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const pool = require('../config/database'); + +const router = express.Router(); + +const validate = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + next(); +}; + +/** + * POST /api/analytics/session + * Log an AR session event. + * + * Body: + * product_id {number} required + * platform {string} optional e.g. "web" | "mobile" + * duration {number} optional seconds spent in AR + * user_agent {string} optional + * latitude {number} optional + * longitude {number} optional + */ +router.post( + '/session', + [ + body('product_id').isInt({ min: 1 }).toInt(), + body('platform').optional().isString().trim().isLength({ max: 50 }), + body('duration').optional().isFloat({ min: 0 }).toFloat(), + body('user_agent').optional().isString().trim().isLength({ max: 500 }), + body('latitude').optional().isFloat({ min: -90, max: 90 }).toFloat(), + body('longitude').optional().isFloat({ min: -180, max: 180 }).toFloat(), + ], + validate, + async (req, res) => { + try { + const { product_id, platform, duration, user_agent, latitude, longitude } = req.body; + + const result = await pool.query( + `INSERT INTO ar_sessions (product_id, user_agent, latitude, longitude, duration_sec, platform) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING session_id, product_id, platform, created_at`, + [ + product_id, + user_agent || null, + latitude || null, + longitude || null, + duration != null ? Math.round(duration) : null, + platform || null, + ] + ); + + res.status(201).json({ + success: true, + data: result.rows[0], + }); + } catch (err) { + console.error('POST /api/analytics/session error:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index ce4ddd6..1ef82a5 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -8,8 +8,9 @@ const helmet = require('helmet'); const morgan = require('morgan'); const rateLimit = require('express-rate-limit'); -const qrRoutes = require('./routes/qr'); -const productRoutes = require('./routes/products'); +const qrRoutes = require('./routes/qr'); +const productRoutes = require('./routes/products'); +const analyticsRoutes = require('./routes/analytics'); const app = express(); @@ -71,6 +72,7 @@ app.get('/api/health', (_req, res) => { app.use('/api/qr', qrRoutes); app.use('/api/products', productRoutes); +app.use('/api/analytics', analyticsRoutes); // ── 404 handler ─────────────────────────────────────────────── app.use((_req, res) => { diff --git a/database/001_init_schema.sql b/database/001_init_schema.sql index 8b11941..baa00d3 100644 --- a/database/001_init_schema.sql +++ b/database/001_init_schema.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS ar_sessions ( latitude FLOAT, longitude FLOAT, duration_sec INT, -- seconds spent in AR + platform VARCHAR(50), -- e.g. 'web' | 'mobile' created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/docs/API.md b/docs/API.md index b747131..4e8a7fc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -170,6 +170,48 @@ Create a new product entry (admin use). --- +## Analytics + +### `POST /api/analytics/session` + +Log an AR session event (called automatically by the WebAR viewer). + +**Request body** +```json +{ + "product_id": 1, + "platform": "web", + "duration": 45, + "user_agent": "Mozilla/5.0…", + "latitude": 37.7749, + "longitude": -122.4194 +} +``` + +| Field | Type | Required | Description | +|--------------|---------|----------|------------------------------------| +| `product_id` | integer | ✅ | ID of the viewed product | +| `platform` | string | – | `web` or `mobile` | +| `duration` | number | – | Seconds spent in AR | +| `user_agent` | string | – | Browser user-agent string | +| `latitude` | number | – | GPS latitude (–90 to 90) | +| `longitude` | number | – | GPS longitude (–180 to 180) | + +**Response 201** +```json +{ + "success": true, + "data": { + "session_id": 1, + "product_id": 1, + "platform": "web", + "created_at": "2026-03-17T12:00:00.000Z" + } +} +``` + +--- + ## Error Codes | Code | Meaning | diff --git a/web/index.html b/web/index.html index 4f091b0..17c96f8 100644 --- a/web/index.html +++ b/web/index.html @@ -128,169 +128,6 @@

Product Name

- + diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..e371bf6 --- /dev/null +++ b/web/main.js @@ -0,0 +1,204 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { ARButton } from 'three/addons/webxr/ARButton.js'; + +// ── API configuration ────────────────────────────────────────── +// Vite replaces import.meta.env.VITE_API_BASE at build time. +// Falls back to localhost for plain-browser / Docker dev usage. +export const API_BASE = + (import.meta.env?.VITE_API_BASE) || + 'http://localhost:3000'; + +// ── API helpers ─────────────────────────────────────────────── + +/** + * Fetch all products (with optional pagination / category filter). + * @returns {Promise<{data: object[], pagination: object}>} + */ +export async function loadProducts({ page = 1, limit = 20, category } = {}) { + const params = new URLSearchParams({ page, limit }); + if (category) params.set('category', category); + const response = await fetch(`${API_BASE}/api/products?${params}`); + if (!response.ok) throw new Error(`loadProducts: API returned ${response.status}`); + const products = await response.json(); + console.log('Products:', products); + return products; +} + +/** + * Fetch a single product by QR code. + * @param {string} qrCode + * @returns {Promise<{data: object}>} + */ +export async function loadProductByQR(qrCode) { + const response = await fetch(`${API_BASE}/api/qr/${encodeURIComponent(qrCode)}`); + if (!response.ok) throw new Error(`loadProductByQR: API returned ${response.status}`); + const product = await response.json(); + console.log('Product:', product); + return product; +} + +/** + * Record an AR session for analytics. + * @param {{product_id: number, platform?: string, duration?: number}} payload + */ +export async function logSession(payload) { + await fetch(`${API_BASE}/api/analytics/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch((err) => console.warn('logSession failed:', err.message)); +} + +// ── DOM helpers ─────────────────────────────────────────────── +const $ = (id) => document.getElementById(id); + +const showError = (msg) => { + const el = $('error-msg'); + el.textContent = msg; + el.style.display = 'block'; + setTimeout(() => { el.style.display = 'none'; }, 5000); +}; + +const setLoading = (visible, text = 'Loading…') => { + $('loading').classList.toggle('hidden', !visible); + $('loading-text').textContent = text; +}; + +// ── Three.js setup ──────────────────────────────────────────── +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x111111); + +const camera = new THREE.PerspectiveCamera( + 60, window.innerWidth / window.innerHeight, 0.01, 100 +); +camera.position.set(0, 1.2, 2.5); + +const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1; +renderer.xr.enabled = true; +$('canvas-container').appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.target.set(0, 0.5, 0); + +scene.add(new THREE.AmbientLight(0xffffff, 0.5)); +const dirLight = new THREE.DirectionalLight(0xffffff, 1.5); +dirLight.position.set(3, 5, 3); +scene.add(dirLight); + +// ── Model loading ───────────────────────────────────────────── +const gltfLoader = new GLTFLoader(); +let currentModel = null; + +function loadGLTF(url) { + return new Promise((resolve, reject) => { + if (currentModel) { scene.remove(currentModel); currentModel = null; } + gltfLoader.load(url, (gltf) => { + currentModel = gltf.scene; + const box = new THREE.Box3().setFromObject(currentModel); + const centre = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + const s = 1 / maxDim; + currentModel.position.set(-centre.x * s, -centre.y * s, -centre.z * s); + currentModel.scale.setScalar(s); + scene.add(currentModel); + resolve(currentModel); + }, undefined, reject); + }); +} + +function loadDemoModel() { + if (currentModel) scene.remove(currentModel); + const geo = new THREE.BoxGeometry(0.5, 0.5, 0.5); + const mat = new THREE.MeshStandardMaterial({ + color: 0x3b82f6, roughness: 0.4, metalness: 0.3, + }); + currentModel = new THREE.Mesh(geo, mat); + currentModel.position.set(0, 0.25, 0); + scene.add(currentModel); +} + +// ── Product card ────────────────────────────────────────────── +function showProductCard(p) { + $('product-name').textContent = p.product_name; + $('product-desc').textContent = p.description || ''; + $('product-price').textContent = p.price ? `$${parseFloat(p.price).toFixed(2)}` : ''; + $('product-card').style.display = 'block'; +} + +// ── AR Button ───────────────────────────────────────────────── +async function setupARButton() { + if (!('xr' in navigator)) return; + const supported = await navigator.xr.isSessionSupported('immersive-ar').catch(() => false); + if (!supported) return; + const arBtn = ARButton.createButton(renderer, { + requiredFeatures: ['hit-test'], + optionalFeatures: ['dom-overlay'], + domOverlay: { root: $('ui') }, + }); + arBtn.className = 'btn btn-ar'; + $('ui').appendChild(arBtn); +} + +// ── Animation loop ──────────────────────────────────────────── +renderer.setAnimationLoop(() => { + controls.update(); + if (currentModel && !renderer.xr.isPresenting) { + currentModel.rotation.y += 0.005; + } + renderer.render(scene, camera); +}); + +// ── Resize ──────────────────────────────────────────────────── +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +// ── Button ──────────────────────────────────────────────────── +$('btn-load').addEventListener('click', () => { + loadDemoModel(); + $('btn-load').textContent = 'Reload Demo'; +}); + +// ── Entry point ─────────────────────────────────────────────── +const qrCode = new URLSearchParams(location.search).get('qr'); + +(async () => { + try { + if (qrCode) { + setLoading(true, 'Fetching product…'); + const { data } = await loadProductByQR(qrCode); + showProductCard(data); + + setLoading(true, 'Loading 3D model…'); + await loadGLTF(data.model_url); + + // Fire-and-forget: record scan + session + fetch(`${API_BASE}/api/qr/${encodeURIComponent(qrCode)}/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_agent: navigator.userAgent }), + }).catch(() => {}); + + logSession({ product_id: data.product_id, platform: 'web' }); + } else { + loadDemoModel(); + } + await setupARButton(); + } catch (err) { + showError('Failed to load model: ' + err.message); + loadDemoModel(); + } finally { + setLoading(false); + } +})();