Compare commits
5 Commits
feature/do
...
670b2f9200
| Author | SHA1 | Date | |
|---|---|---|---|
| 670b2f9200 | |||
|
|
521e3f552f | ||
| 627e970986 | |||
|
|
50a0bd9417 | ||
| 097f0f9cee |
@@ -2,25 +2,64 @@
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-6">Scan Item</h1>
|
||||
|
||||
<UCard>
|
||||
<div class="text-center py-12">
|
||||
<UIcon
|
||||
name="i-heroicons-qr-code"
|
||||
class="w-16 h-16 text-gray-400 mx-auto mb-4"
|
||||
<UCard v-if="!scannedBarcode" class="mb-6">
|
||||
<ScanBarcodeScanner
|
||||
@barcode-detected="handleBarcodeDetected"
|
||||
@manual-entry="showManualEntry = true"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Barcode Scanner
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
This feature will be implemented in Week 3.
|
||||
</p>
|
||||
</UCard>
|
||||
|
||||
<!-- Product Lookup Result -->
|
||||
<UCard v-if="productData" class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
v-if="productData.image_url"
|
||||
:src="productData.image_url"
|
||||
:alt="productData.name"
|
||||
class="w-24 h-24 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold mb-1">{{ productData.name }}</h3>
|
||||
<p v-if="productData.brand" class="text-gray-600">{{ productData.brand }}</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Barcode: {{ scannedBarcode }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="lookupError"
|
||||
color="yellow"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Product not found"
|
||||
:description="lookupError"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
to="/"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="lg"
|
||||
icon="i-heroicons-plus"
|
||||
class="flex-1"
|
||||
@click="addToInventory"
|
||||
>
|
||||
Back to Inventory
|
||||
Add to Inventory
|
||||
</UButton>
|
||||
<UButton
|
||||
color="gray"
|
||||
size="lg"
|
||||
@click="resetScanner"
|
||||
>
|
||||
Scan Again
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Loading State -->
|
||||
<UCard v-if="isLookingUp">
|
||||
<div class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mb-4"></div>
|
||||
<p class="text-gray-600">Looking up product...</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
@@ -30,4 +69,60 @@
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
const scannedBarcode = ref<string | null>(null)
|
||||
const productData = ref<any>(null)
|
||||
const isLookingUp = ref(false)
|
||||
const lookupError = ref<string | null>(null)
|
||||
const showManualEntry = ref(false)
|
||||
|
||||
const handleBarcodeDetected = async (barcode: string) => {
|
||||
scannedBarcode.value = barcode
|
||||
lookupError.value = null
|
||||
isLookingUp.value = true
|
||||
|
||||
try {
|
||||
// TODO: Implement product lookup via Edge Function (Issue #24)
|
||||
// For now, create a basic product object
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)) // Simulate API call
|
||||
|
||||
productData.value = {
|
||||
name: `Product ${barcode}`,
|
||||
brand: 'Unknown Brand',
|
||||
barcode: barcode,
|
||||
image_url: null
|
||||
}
|
||||
|
||||
lookupError.value = 'Product lookup not yet implemented. Using default data.'
|
||||
} catch (error) {
|
||||
console.error('Product lookup error:', error)
|
||||
lookupError.value = 'Failed to look up product. You can still add it manually.'
|
||||
productData.value = {
|
||||
name: `Product ${barcode}`,
|
||||
barcode: barcode
|
||||
}
|
||||
} finally {
|
||||
isLookingUp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addToInventory = () => {
|
||||
// TODO: Implement scan-to-add flow (Issue #25)
|
||||
// Navigate to add form with pre-filled data
|
||||
navigateTo({
|
||||
path: '/',
|
||||
query: {
|
||||
barcode: scannedBarcode.value,
|
||||
name: productData.value?.name,
|
||||
brand: productData.value?.brand
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetScanner = () => {
|
||||
scannedBarcode.value = null
|
||||
productData.value = null
|
||||
lookupError.value = null
|
||||
isLookingUp.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
83
supabase/functions/product-lookup/README.md
Normal file
83
supabase/functions/product-lookup/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Product Lookup Edge Function
|
||||
|
||||
Fetches product data from Open Food Facts API by barcode and caches results in the database.
|
||||
|
||||
## Endpoint
|
||||
|
||||
`POST /functions/v1/product-lookup`
|
||||
|
||||
## Request
|
||||
|
||||
```json
|
||||
{
|
||||
"barcode": "8000500310427"
|
||||
}
|
||||
```
|
||||
|
||||
## Response
|
||||
|
||||
### Success (200)
|
||||
|
||||
```json
|
||||
{
|
||||
"barcode": "8000500310427",
|
||||
"name": "Nutella",
|
||||
"brand": "Ferrero",
|
||||
"quantity": "750g",
|
||||
"image_url": "https://...",
|
||||
"category": "spreads",
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found (404)
|
||||
|
||||
```json
|
||||
{
|
||||
"barcode": "1234567890123",
|
||||
"name": "Unknown Product (1234567890123)",
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
### Error (500)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error message",
|
||||
"barcode": null,
|
||||
"name": null
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Queries Open Food Facts API
|
||||
- ✅ Caches results in `products` table
|
||||
- ✅ Returns cached data for subsequent requests
|
||||
- ✅ Handles product not found gracefully
|
||||
- ✅ CORS enabled for frontend access
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `SUPABASE_URL`: Auto-injected by Supabase
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`: Auto-injected by Supabase
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Local (with Supabase CLI)
|
||||
supabase functions serve product-lookup
|
||||
|
||||
# Test request
|
||||
curl -X POST http://localhost:54321/functions/v1/product-lookup \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_ANON_KEY" \
|
||||
-d '{"barcode":"8000500310427"}'
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
supabase functions deploy product-lookup
|
||||
```
|
||||
140
supabase/functions/product-lookup/index.ts
Normal file
140
supabase/functions/product-lookup/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Product Lookup Edge Function
|
||||
// Fetches product data from Open Food Facts API by barcode
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
interface ProductData {
|
||||
barcode: string
|
||||
name: string
|
||||
brand?: string
|
||||
quantity?: string
|
||||
image_url?: string
|
||||
category?: string
|
||||
cached?: boolean
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
try {
|
||||
const { barcode } = await req.json()
|
||||
|
||||
if (!barcode) {
|
||||
throw new Error('Barcode is required')
|
||||
}
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
|
||||
// Check cache first (products table can store known products)
|
||||
const { data: cachedProduct } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('barcode', barcode)
|
||||
.single()
|
||||
|
||||
if (cachedProduct) {
|
||||
console.log(`Cache HIT for barcode: ${barcode}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
barcode: cachedProduct.barcode,
|
||||
name: cachedProduct.name,
|
||||
brand: cachedProduct.brand,
|
||||
quantity: cachedProduct.quantity,
|
||||
image_url: cachedProduct.image_url,
|
||||
category: cachedProduct.category,
|
||||
cached: true,
|
||||
} as ProductData),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Cache MISS for barcode: ${barcode}, fetching from Open Food Facts...`)
|
||||
|
||||
// Fetch from Open Food Facts
|
||||
const offResponse = await fetch(
|
||||
`https://world.openfoodfacts.org/api/v2/product/${barcode}.json`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Pantry/1.0 (https://github.com/pantry-app/pantry)',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!offResponse.ok) {
|
||||
throw new Error(`Open Food Facts API error: ${offResponse.status}`)
|
||||
}
|
||||
|
||||
const offData = await offResponse.json()
|
||||
|
||||
if (offData.status !== 1 || !offData.product) {
|
||||
// Product not found in Open Food Facts
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
barcode,
|
||||
name: `Unknown Product (${barcode})`,
|
||||
cached: false,
|
||||
} as ProductData),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 404
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const product = offData.product
|
||||
|
||||
// Extract relevant data
|
||||
const productData: ProductData = {
|
||||
barcode,
|
||||
name: product.product_name || product.generic_name || `Product ${barcode}`,
|
||||
brand: product.brands || undefined,
|
||||
quantity: product.quantity || undefined,
|
||||
image_url: product.image_url || product.image_front_url || undefined,
|
||||
category: product.categories || undefined,
|
||||
cached: false,
|
||||
}
|
||||
|
||||
// Cache the product in our database (upsert)
|
||||
await supabase.from('products').upsert({
|
||||
barcode: productData.barcode,
|
||||
name: productData.name,
|
||||
brand: productData.brand,
|
||||
quantity: productData.quantity,
|
||||
image_url: productData.image_url,
|
||||
category: productData.category,
|
||||
}, { onConflict: 'barcode' })
|
||||
|
||||
console.log(`Successfully fetched and cached product: ${productData.name}`)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(productData),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in product-lookup:', error)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
barcode: null,
|
||||
name: null,
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user