diff --git a/.env.example b/.env.example index 4dfd12b..bd11f10 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,16 @@ NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co" NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key" SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" -# Cohere (Vision) API Key +# Anthropic (Vision) API +ANTHROPIC_API_KEY="" +# Optional: override default model used by /api/vision-detect +# Defaults to 'claude-sonnet-4-20250514' if unset +ANTHROPIC_MODEL="" + +# Cohere (fallback) API Key COHERE_API_KEY="" +# Optional: override Cohere model used as fallback (defaults to 'command-r') +COHERE_MODEL="" NEXT_PUBLIC_MERCHANT_ID_DEFAULT="a1b2c3d4-e5f6-7890-abcd-ef1234567890" # MINLP Service Configuration diff --git a/MERGE_PLAN.txt b/MERGE_PLAN.txt new file mode 100644 index 0000000..6158634 --- /dev/null +++ b/MERGE_PLAN.txt @@ -0,0 +1,142 @@ +# MERGE_PLAN.txt +# Current branch kept: frontend +# Other branch to replace from: backend +# Action meanings: +# KEEP -> keep file contents from frontend +# REPLACE -> replace file with version from backend +# +# Review and edit as needed, then apply with: +# bash scripts/apply-merge-plan.sh frontend backend +# +KEEP .env.example +KEEP .gitignore +KEEP .vscode/launch.json +KEEP .vscode/settings.json +KEEP README.md +KEEP README_DEMO.md +KEEP app/[page]/layout.tsx +KEEP app/[page]/opengraph-image.tsx +KEEP app/[page]/page.tsx +REPLACE app/api/minlp/explain/route.ts +REPLACE app/api/minlp/solve/route.ts +REPLACE app/api/products/[handle]/route.ts +REPLACE app/api/revalidate/route.ts +REPLACE app/api/sync-products/route.ts +KEEP app/api/vision-detect/route.ts +KEEP app/cart/page.tsx +KEEP app/checkout/page.tsx +KEEP app/checkout/success/page.tsx +REPLACE app/dashboard/page.tsx +KEEP app/error.tsx +KEEP app/favicon.ico +KEEP app/globals.css +KEEP app/layout.tsx +KEEP app/merchant/[merchantId]/page.tsx +KEEP app/opengraph-image.tsx +KEEP app/page.tsx +KEEP app/product/[handle]/page.tsx +KEEP app/robots.ts +KEEP app/search/[collection]/opengraph-image.tsx +KEEP app/search/[collection]/page.tsx +KEEP app/search/children-wrapper.tsx +KEEP app/search/layout.tsx +KEEP app/search/loading.tsx +KEEP app/search/page.tsx +KEEP app/sitemap.ts +KEEP components/camera-scanner.tsx +KEEP components/carousel.tsx +KEEP components/cart/actions.ts +KEEP components/cart/add-to-cart.tsx +KEEP components/cart/cart-context.tsx +KEEP components/cart/delete-item-button.tsx +KEEP components/cart/edit-item-quantity-button.tsx +KEEP components/cart/modal.tsx +KEEP components/cart/open-cart.tsx +KEEP components/cart/supabase-cart-context.tsx +REPLACE components/dashboard/DashboardClient.tsx +KEEP components/grid/index.tsx +KEEP components/grid/three-items.tsx +KEEP components/grid/tile.tsx +KEEP components/icons/logo.tsx +KEEP components/label.tsx +KEEP components/layout/footer-menu.tsx +KEEP components/layout/footer.tsx +KEEP components/layout/navbar/cart-button.tsx +KEEP components/layout/navbar/index.tsx +KEEP components/layout/navbar/mobile-menu.tsx +KEEP components/layout/navbar/scanner-button.tsx +KEEP components/layout/navbar/search.tsx +KEEP components/layout/navbar/tarazoo-navbar.tsx +KEEP components/layout/product-grid-items.tsx +KEEP components/layout/search/collections.tsx +KEEP components/layout/search/filter/dropdown.tsx +KEEP components/layout/search/filter/index.tsx +KEEP components/layout/search/filter/item.tsx +KEEP components/loading-dots.tsx +KEEP components/logo-square.tsx +KEEP components/opengraph-image.tsx +KEEP components/price.tsx +KEEP components/product/gallery.tsx +KEEP components/product/product-context.tsx +KEEP components/product/product-description.tsx +KEEP components/product/variant-selector.tsx +KEEP components/prose.tsx +KEEP components/providers.tsx +KEEP components/setup/shopify-setup.tsx +KEEP components/welcome-toast.tsx +REPLACE docs/shopify-setup.md +KEEP fonts/Inter-Bold.ttf +KEEP frontend/.next/cache/webpack/client-development-fallback/0.pack.gz +KEEP frontend/.next/cache/webpack/client-development-fallback/index.pack.gz +KEEP frontend/.next/cache/webpack/client-development/0.pack.gz +KEEP frontend/.next/cache/webpack/client-development/index.pack.gz +KEEP frontend/.next/cache/webpack/client-development/index.pack.gz.old +KEEP frontend/.next/server/app-paths-manifest.json +KEEP frontend/.next/server/pages-manifest.json +KEEP frontend/.next/server/server-reference-manifest.js +KEEP frontend/.next/server/server-reference-manifest.json +KEEP frontend/.next/types/cache-life.d.ts +KEEP frontend/.next/types/package.json +KEEP frontend/.next/types/routes.d.ts +KEEP frontend/.next/types/validator.ts +KEEP lib/constants.ts +KEEP lib/shopify-supabase-sync.ts +KEEP lib/shopify/config.ts +KEEP lib/shopify/fragments/cart.ts +KEEP lib/shopify/fragments/image.ts +KEEP lib/shopify/fragments/product.ts +KEEP lib/shopify/fragments/seo.ts +KEEP lib/shopify/index.ts +KEEP lib/shopify/mock-data.ts +KEEP lib/shopify/mutations/cart.ts +KEEP lib/shopify/queries/cart.ts +KEEP lib/shopify/queries/collection.ts +KEEP lib/shopify/queries/menu.ts +KEEP lib/shopify/queries/page.ts +KEEP lib/shopify/queries/product.ts +KEEP lib/shopify/types.ts +REPLACE lib/supabase.ts +KEEP lib/type-guards.ts +KEEP lib/utils.ts +KEEP license.md +KEEP middleware.ts +KEEP next.config.mjs +KEEP package.json +REPLACE packages/shared/types.ts +KEEP pnpm-lock.yaml +KEEP postcss.config.mjs +KEEP public/manifest.json +REPLACE scripts/add-google-bottle.js +REPLACE scripts/setup-supabase.js +REPLACE scripts/test-checkout.js +REPLACE scripts/test-shopify-connection.js +REPLACE services/minlp/main.py +REPLACE services/minlp/requirements.txt +REPLACE supabase/fix-rls.sql +REPLACE supabase/migrations/001_initial_schema.sql +REPLACE supabase/migrations/002_seed_data.sql +REPLACE supabase/migrations/003_shopify_integration.sql +REPLACE supabase/new-update.sql +REPLACE supabase/setup-all.sql +REPLACE supabase/shopify-setup.sql +KEEP tsconfig.json diff --git a/app/api/sync-products/route.ts b/app/api/sync-products/route.ts index eb78c16..14cb39f 100644 --- a/app/api/sync-products/route.ts +++ b/app/api/sync-products/route.ts @@ -1,32 +1,26 @@ +'use server'; + import { NextRequest, NextResponse } from 'next/server'; -import { syncShopifyToSupabase } from 'lib/shopify-supabase-sync'; +import { syncShopifyToSupabase, syncShopifyProductByHandle } from 'lib/shopify-supabase-sync'; -export async function POST(request: NextRequest) { +export async function POST(req: NextRequest) { try { + const { searchParams } = new URL(req.url); + const handle = searchParams.get('handle'); + if (handle) { + const result = await syncShopifyProductByHandle(handle); + if (!result.success) { + return NextResponse.json({ error: 'Sync failed', details: result }, { status: 500 }); + } + return NextResponse.json({ success: true, count: result.count || 0 }); + } const result = await syncShopifyToSupabase(); - - if (result.success) { - return NextResponse.json({ - success: true, - message: `Synced ${result.count} products from Shopify`, - products: result.products - }); - } else { - return NextResponse.json( - { success: false, error: result.error }, - { status: 500 } - ); + if (!result.success) { + return NextResponse.json({ error: 'Sync failed', details: result }, { status: 500 }); } - } catch (error) { - console.error('Sync API error:', error); - return NextResponse.json( - { success: false, error: 'Sync failed' }, - { status: 500 } - ); + return NextResponse.json({ success: true, count: result.count || 0 }); + } catch (e: any) { + return NextResponse.json({ error: 'Unexpected error', details: e?.message || String(e) }, { status: 500 }); } } -export async function GET(request: NextRequest) { - // Trigger sync on GET for easy testing - return POST(request); -} diff --git a/app/api/vision-detect/route.ts b/app/api/vision-detect/route.ts index c2a58c8..960a791 100644 --- a/app/api/vision-detect/route.ts +++ b/app/api/vision-detect/route.ts @@ -1,23 +1,90 @@ 'use server'; import { NextRequest, NextResponse } from 'next/server'; +import { createHash } from 'crypto'; // Allowed catalog labels. Must match exactly for downstream cart lookup. +// Put 'none' first to counter list-order bias in uncertain cases. const ALLOWED_ITEMS = [ + 'none', 'pen', 'head cap', 'redbull', 'google cloud water bottle', - 'chips' + 'socks' ]; -export async function POST(req: NextRequest) { +async function tryCohere(params: { b64: string; mime: string; systemPrompt: string }) { + const { b64, mime, systemPrompt } = params; + const cohereKey = process.env.COHERE_API_KEY; + if (!cohereKey) { + console.warn('[Vision Detect] Cohere API key not configured; skipping fallback'); + return null; + } try { - const apiKey = process.env.COHERE_API_KEY; - if (!apiKey) { - return NextResponse.json({ error: 'Cohere API key not configured' }, { status: 500 }); + const model = process.env.COHERE_MODEL || 'command-r'; + const res = await fetch('https://api.cohere.com/v2/chat', { + method: 'POST', + headers: { + Authorization: `Bearer ${cohereKey}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: mime, data: b64 } }, + { type: 'text', text: 'Pick one label from the allowed set and output ONLY that label.' } + ] + } + ], + max_tokens: 64 + }) + }); + if (!res.ok) { + const t = await res.text(); + console.error('[Vision Detect] Cohere API error:', t); + return null; + } + const data = await res.json(); + // Attempt to extract text from Cohere response across possible shapes + let raw = ''; + if (typeof (data as any)?.text === 'string') { + raw = (data as any).text; + } else if (typeof (data as any)?.message?.content?.map === 'function') { + raw = (data as any).message.content + .map((b: any) => (typeof b?.text === 'string' ? b.text : '')) + .join(' '); + } else if (Array.isArray((data as any)?.content)) { + const textBlock = (data as any).content.find((b: any) => b?.type === 'text' || b?.type === 'output_text'); + raw = (textBlock?.text || '').toString(); + } + raw = (raw || '').trim().toLowerCase(); + console.log('[Vision Detect] Cohere parsed label:', raw); + const normalized = raw + .replace(/\s+/g, ' ') + .replace(/red bull/g, 'redbull') + .replace(/google cloud bottle/g, 'google cloud water bottle') + .trim(); + const matchLabel = ALLOWED_ITEMS.find((x) => x === normalized); + if (!matchLabel) { + return null; + } + if (matchLabel === 'none') { + return { item: null, model: `cohere:${model}`, systemPrompt }; } + return { item: matchLabel, model: `cohere:${model}`, systemPrompt }; + } catch (err) { + console.error('[Vision Detect] Cohere fallback unexpected error:', err); + return null; + } +} +export async function POST(req: NextRequest) { + try { const { image } = await req.json(); if (!image || typeof image !== 'string') { return NextResponse.json({ error: 'Missing image' }, { status: 400 }); @@ -25,47 +92,85 @@ export async function POST(req: NextRequest) { // Accept either a data URL (data:image/jpeg;base64,...) or raw base64 const match = image.match(/^data:(.*?);base64,(.*)$/); - const mime = match ? match[1] : 'image/jpeg'; - const b64 = match ? match[2] : image; + const mime = match && match[1] ? match[1] : 'image/jpeg'; + const b64 = match && match[2] ? match[2] : image; + const dataUrl = match ? image : `data:${mime};base64,${b64}`; + + // System prompt: default to 'none' unless clearly visible and centered. + const systemPrompt = `You are a retail vision assistant. Given an image, output EXACTLY ONE label from this allowed set and nothing else (lowercase, no punctuation):\n\n- none\n- pen\n- head cap\n- redbull\n- google cloud water bottle\n- socks\n\nRules:\n- Your main goal is to identify if one of the items in the list is present in the image.\n- If you are uncertain, it's better to say 'none'.\n- The item might not be perfectly centered or might be slightly blurry. Use your best judgment.\n- The label 'pen' refers only to a writing instrument.`; + + // Model we are using (configurable via env). Default to the model in docs snippet. + const model = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514'; - // System prompt ensures exact label selection - const systemPrompt = `You are a retail vision assistant. Given an image of a product, choose exactly one label from this list and reply ONLY with that label in lowercase with no punctuation or extra words:\n\n- pen\n- head cap\n- redbull\n- google cloud water bottle\n- chips\n\nIf uncertain, pick the closest match.`; + // Read Anthropic key (may be undefined; we handle fallback below) + const apiKey = process.env.ANTHROPIC_API_KEY; - // Call Cohere Chat API with vision model - const cohereRes = await fetch('https://api.cohere.ai/v1/chat', { + // Log what we are sending (without exposing the whole image) + const b64Hash = createHash('sha256').update(b64).digest('hex').slice(0, 16); + const preview = b64.slice(0, 32); + console.log('[Vision Detect] Request:', { + model, + mime, + base64_len: b64.length, + base64_preview: `${preview}...`, + base64_sha256_16: b64Hash, + systemPrompt_len: systemPrompt.length, + allowedItems: ALLOWED_ITEMS + }); + + // If no Anthropic key, try Cohere fallback immediately + if (!apiKey) { + const cohereResult = await tryCohere({ b64, mime, systemPrompt }); + if (cohereResult) return NextResponse.json(cohereResult); + return NextResponse.json({ error: 'Anthropic API key not configured and Cohere fallback not available' }, { status: 500 }); + } + + // Call Anthropic Messages API with image (base64). We avoid the Files API to keep dependencies minimal. + const anthropicRes = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json' }, body: JSON.stringify({ - model: 'c4ai-aya-vision-32b', - message: 'Identify the product from the image and output only the exact label.', - preamble: systemPrompt, - temperature: 0, - // Attach the image as base64. Different versions of Cohere accept either - // `attachments` or `images`; we include attachments which is widely supported. - attachments: [ + model, + max_tokens: 64, + system: systemPrompt, + messages: [ { - type: 'image', - data: b64, - mime_type: mime + role: 'user', + content: [ + { + type: 'image', + source: { type: 'base64', media_type: mime, data: b64 } + }, + { + type: 'text', + text: 'Pick one label from the allowed set and output ONLY that label.' + } + ] } ] }) }); - if (!cohereRes.ok) { - const errText = await cohereRes.text(); - return NextResponse.json({ error: 'Cohere API error', details: errText }, { status: 502 }); + if (!anthropicRes.ok) { + const errText = await anthropicRes.text(); + console.error('[Vision Detect] Anthropic API error:', errText); + // Fallback to Cohere if available + const cohereResult = await tryCohere({ b64, mime, systemPrompt }); + if (cohereResult) return NextResponse.json(cohereResult); + return NextResponse.json({ error: 'Anthropic API error', details: errText }, { status: 502 }); } - const data = await cohereRes.json(); - // Cohere chat responses typically include a `text` field or `message`/`content`. - let raw = (data.text || data.reply || data.response || '').toString().trim().toLowerCase(); - if (!raw && data?.message?.content) { - raw = String(data.message.content).trim().toLowerCase(); - } + const data = await anthropicRes.json(); + // Anthropic returns content array with text blocks; take the first text + const textBlock = Array.isArray(data?.content) + ? data.content.find((b: any) => b?.type === 'text') + : null; + let raw = (textBlock?.text || '').toString().trim().toLowerCase(); + console.log('[Vision Detect] Parsed label:', raw); // Normalize to allowed set // Also allow minor variations (e.g., 'red bull' => 'redbull') @@ -77,10 +182,18 @@ export async function POST(req: NextRequest) { const matchLabel = ALLOWED_ITEMS.find((x) => x === normalized); if (!matchLabel) { - return NextResponse.json({ error: 'Unrecognized label', raw }, { status: 422 }); + console.warn('[Vision Detect] Unrecognized label from Anthropic:', { raw, normalized }); + const cohereResult = await tryCohere({ b64, mime, systemPrompt }); + if (cohereResult) return NextResponse.json(cohereResult); + return NextResponse.json({ error: 'Unrecognized label', raw, model, systemPrompt }, { status: 422 }); + } + + // If the model says 'none', indicate no detection so the client keeps scanning + if (matchLabel === 'none') { + return NextResponse.json({ item: null, model, systemPrompt }); } - return NextResponse.json({ item: matchLabel }); + return NextResponse.json({ item: matchLabel, model, systemPrompt }); } catch (err: any) { return NextResponse.json({ error: 'Unexpected error', details: err?.message || String(err) }, { status: 500 }); } diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx index 57a10e7..88e38d2 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useSupabaseCart } from 'components/cart/supabase-cart-context'; import { createOrder } from 'lib/supabase'; @@ -11,16 +11,25 @@ const MERCHANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; // Demo merchant export default function CheckoutPage() { const router = useRouter(); const { items, subtotal, tax, total, clearCart } = useSupabaseCart(); - const DISCOUNT_LABEL = 'Hack The North Developer Discount'; + const DISCOUNT_LABEL = 'Hack The North Discount'; + const [discountCode, setDiscountCode] = useState(''); + const [discountApplied, setDiscountApplied] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); + const normalizedCode = discountCode.trim().toUpperCase(); + const discountCents = discountApplied && normalizedCode === 'HTNDEV' ? total : 0; + const finalTotal = Math.max(0, total - discountCents); + const handleCheckout = async (e: React.FormEvent) => { e.preventDefault(); setIsProcessing(true); setError(null); try { + // Show a brief loading screen to simulate processing + await new Promise((resolve) => setTimeout(resolve, 1000)); + // Prepare order items const orderItems = items.map(item => ({ sku: item.product.sku, @@ -28,15 +37,15 @@ export default function CheckoutPage() { price_cents: item.product.price_cents })); - // Create order in Supabase with full discount to zero out invoice + // Create order in Supabase with optional discount const order = await createOrder( MERCHANT_ID, orderItems, subtotal, tax, - 0, // total is 0 after full discount - DISCOUNT_LABEL, - total // discount equals the cart total (subtotal + tax) + finalTotal, + discountCents > 0 ? DISCOUNT_LABEL : undefined, + discountCents > 0 ? discountCents : undefined ); if (order) { @@ -56,13 +65,24 @@ export default function CheckoutPage() { } }; - if (items.length === 0) { - router.push('/cart'); - return null; - } + useEffect(() => { + if (items.length === 0) { + router.push('/cart'); + } + }, [items.length, router]); + + if (items.length === 0) return null; return (
+ {isProcessing && ( +
+
+
+

Processing payment...

+
+
+ )}

Checkout

@@ -177,14 +197,7 @@ export default function CheckoutPage() {
-
-

Payment (Demo Mode)

-
-

- This is a demo checkout. No payment will be processed. -

-
-
+ {/* Payment section removed */} {error && (
@@ -224,16 +237,52 @@ export default function CheckoutPage() {
{formatPrice(tax / 100)}
-
-
{DISCOUNT_LABEL}
-
- {formatPrice(total / 100)}
-
+ {discountCents > 0 && ( +
+
{DISCOUNT_LABEL}
+
- {formatPrice(discountCents / 100)}
+
+ )}
Order total
-
{formatPrice(0)}
+
{formatPrice(finalTotal / 100)}
+
+ +
+ setDiscountCode(e.target.value)} + placeholder="Enter code (e.g., HTNDEV)" + className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2" + /> + +
+ {discountApplied && normalizedCode !== 'HTNDEV' && ( +

Invalid code

+ )} + {discountCents > 0 && ( +

Code applied: 100% off

+ )} +
+ + {/* Extra clarity: show original total and savings */} +
+

Original total: {formatPrice(total / 100)}

+ {discountCents > 0 && ( +

You save: {formatPrice(discountCents / 100)}

+ )} +
+

Items

diff --git a/app/checkout/success/page.tsx b/app/checkout/success/page.tsx index 649ff26..b2d8298 100644 --- a/app/checkout/success/page.tsx +++ b/app/checkout/success/page.tsx @@ -1,12 +1,33 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; +import { supabase } from 'lib/supabase'; +import { formatPrice } from 'lib/utils'; -export default function CheckoutSuccessPage() { +function SuccessContent() { const searchParams = useSearchParams(); const orderId = searchParams?.get('orderId'); + const [amountProcessed, setAmountProcessed] = useState(null); + const [discountInfo, setDiscountInfo] = useState<{ label?: string | null; cents?: number } | null>(null); + + useEffect(() => { + const load = async () => { + if (!orderId) return; + const { data } = await supabase + .from('orders') + .select('subtotal_cents, tax_cents, discount_cents, discount_label, total_cents') + .eq('order_id', orderId) + .maybeSingle(); + if (data) { + const original = (data.subtotal_cents || 0) + (data.tax_cents || 0); + setAmountProcessed(original); + setDiscountInfo({ label: data.discount_label, cents: data.discount_cents }); + } + }; + load(); + }, [orderId]); return (
@@ -16,15 +37,27 @@ export default function CheckoutSuccessPage() {
- -

- Order Confirmed! -

- + +

Order Confirmed!

+

Thank you for your order. Your order has been successfully placed and will be processed shortly.

+ {amountProcessed !== null && ( +
+

+ Processed amount (pre-discount): {formatPrice(amountProcessed / 100)} + {discountInfo?.cents ? ( + <> + {' '} · Discount: -{formatPrice((discountInfo.cents || 0) / 100)} + {discountInfo?.label ? ` (${discountInfo.label})` : ''} + + ) : null} +

+
+ )} + {orderId && (

Order ID:

@@ -50,3 +83,11 @@ export default function CheckoutSuccessPage() {
); } + +export default function CheckoutSuccessPage() { + return ( + Loading...
}> + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 55d73af..f2a3d89 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,9 @@ import { Carousel } from 'components/carousel'; import { ThreeItemGrid } from 'components/grid/three-items'; import Footer from 'components/layout/footer'; import ShopifySetup from 'components/setup/shopify-setup'; +import Grid from 'components/grid'; +import ProductGridItems from 'components/layout/product-grid-items'; +import { getProducts } from 'lib/shopify'; import { isShopifyConfigured } from 'lib/shopify/config'; import { syncShopifyToSupabase } from 'lib/shopify-supabase-sync'; @@ -24,11 +27,20 @@ export default async function HomePage() { // Ensure Supabase has the latest Shopify data on each homepage load await syncShopifyToSupabase(); + const products = await getProducts({ fresh: true }); return ( <> + {products?.length ? ( +
+

Products

+ + + +
+ ) : null}
); diff --git a/components/camera-scanner.tsx b/components/camera-scanner.tsx index 9c0da3e..a5d3eb0 100644 --- a/components/camera-scanner.tsx +++ b/components/camera-scanner.tsx @@ -1,8 +1,8 @@ 'use client'; +import { getProductByName } from 'lib/supabase'; import { useEffect, useRef, useState } from 'react'; import type { Product } from '../packages/shared/types'; -import { getProductByName } from 'lib/supabase'; interface CameraScannerProps { onDetected: (product: Product) => void; @@ -18,7 +18,7 @@ export default function CameraScanner({ onDetected, onClose, autoCloseOnScan = t const [torchEnabled, setTorchEnabled] = useState(false); const streamRef = useRef(null); const timerRef = useRef(null); - const [autoMode, setAutoMode] = useState(false); + const scannedRef = useRef(false); useEffect(() => { startScanning(); @@ -59,8 +59,8 @@ export default function CameraScanner({ onDetected, onClose, autoCloseOnScan = t } } - // Start periodic vision detection only if auto mode is enabled - if (autoMode) startPeriodicDetection(); + // Start periodic vision detection + startPeriodicDetection(); } } catch (err) { console.error('Camera error:', err); @@ -76,6 +76,7 @@ export default function CameraScanner({ onDetected, onClose, autoCloseOnScan = t const stopScanning = () => { if (timerRef.current) { + // Works for both setInterval and setTimeout clearInterval(timerRef.current as any); timerRef.current = null; } @@ -102,54 +103,39 @@ export default function CameraScanner({ onDetected, onClose, autoCloseOnScan = t } }; - const startPeriodicDetection = () => { + const startPeriodicDetection = () => { if (timerRef.current) return; + scannedRef.current = false; + timerRef.current = setInterval(async () => { + // If a scan has already succeeded, do nothing further. + if (scannedRef.current) return; + try { const label = await detectCurrentFrame(); - if (!label) return; + if (!label) return; // Nothing detected, continue scanning. + + // A label was found, so we try to find the product. const product = await getProductByName(label); if (product) { - if ('vibrate' in navigator) { - navigator.vibrate(200); - } + // Check the flag again to handle race conditions. + if (scannedRef.current) return; + scannedRef.current = true; // Set flag to true + + // Stop all scanning activity. stopScanning(); + + // Vibrate and call the callback. + if ('vibrate' in navigator) navigator.vibrate(200); onDetected(product); if (autoCloseOnScan) onClose(); - } else { - setError(`Detected "${label}" but could not find a matching product.`); - setTimeout(() => setError(null), 1500); } } catch (e) { - // Swallow intermittent errors from detection + // Ignore errors and allow the loop to continue. } }, 1500); }; - const captureAndDetect = async () => { - try { - const label = await detectCurrentFrame(); - if (!label) { - setError('Could not identify item. Try again.'); - setTimeout(() => setError(null), 1500); - return; - } - const product = await getProductByName(label); - if (product) { - if ('vibrate' in navigator) navigator.vibrate(150); - stopScanning(); - onDetected(product); - if (autoCloseOnScan) onClose(); - } else { - setError(`Detected "${label}" but no matching product found.`); - setTimeout(() => setError(null), 1500); - } - } catch (e) { - setError('Capture failed. Please try again.'); - setTimeout(() => setError(null), 1500); - } - }; - const detectCurrentFrame = async (): Promise => { if (!videoRef.current) return null; const video = videoRef.current; @@ -236,7 +222,7 @@ export default function CameraScanner({ onDetected, onClose, autoCloseOnScan = t
- {isScanning && autoMode && ( + {isScanning && (
@@ -244,35 +230,6 @@ export default function CameraScanner({ onDetected, onClose, autoCloseOnScan = t - {/* Controls: torch (left), auto toggle (next), shutter (bottom center in manual mode) */} - - - {!autoMode && ( -
-
- )} - {error && (
{error} diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index 25cb1b8..66f1b3b 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -2,11 +2,10 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; -import { addItem } from 'components/cart/actions'; import { useProduct } from 'components/product/product-context'; -import { Product, ProductVariant } from 'lib/shopify/types'; -import { useFormState } from 'react-dom'; -import { useCart } from './cart-context'; +import type { Product as ShopifyProduct, ProductVariant } from 'lib/shopify/types'; +import { getProductByName } from 'lib/supabase'; +import { useSupabaseCart } from './supabase-cart-context'; function SubmitButton({ availableForSale, @@ -57,11 +56,10 @@ function SubmitButton({ ); } -export function AddToCart({ product }: { product: Product }) { +export function AddToCart({ product }: { product: ShopifyProduct }) { const { variants, availableForSale } = product; - const { addCartItem } = useCart(); + const { addItem } = useSupabaseCart(); const { state } = useProduct(); - const [message, formAction] = useFormState(addItem, null); const variant = variants.find((variant: ProductVariant) => variant.selectedOptions.every( @@ -78,20 +76,45 @@ export function AddToCart({ product }: { product: Product }) { const selectedVariant = variants.find((v) => v.id === selectedVariantId); const isAvailableForSale = selectedVariant?.availableForSale ?? availableForSale; + const handleAdd = async () => { + if (!selectedVariantId) return; + const name = finalVariant.title === 'Default Title' ? product.title : `${product.title} - ${finalVariant.title}`; + let supaProduct = await getProductByName(name.toLowerCase()); + if (!supaProduct) { + // Attempt to sync this product from Shopify into Supabase, then retry + try { + await fetch(`/api/sync-products?handle=${encodeURIComponent(product.handle)}`, { method: 'POST' }); + supaProduct = await getProductByName(name.toLowerCase()); + } catch {} + } + if (supaProduct) { + addItem(supaProduct); + // Optional toast + const msg = document.createElement('div'); + msg.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; + msg.textContent = `Added ${supaProduct.name} to cart`; + document.body.appendChild(msg); + setTimeout(() => msg.remove(), 2000); + } else { + const err = document.createElement('div'); + err.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; + err.textContent = 'Product not available in Supabase catalog.'; + document.body.appendChild(err); + setTimeout(() => err.remove(), 2500); + } + }; + return ( -
{ - await formAction(selectedVariantId); - addCartItem(finalVariant, product); - }} +
); } diff --git a/components/cart/supabase-cart-context.tsx b/components/cart/supabase-cart-context.tsx index 52c5d1b..52d2ae0 100644 --- a/components/cart/supabase-cart-context.tsx +++ b/components/cart/supabase-cart-context.tsx @@ -39,7 +39,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('tarazoo_cart', JSON.stringify(items)); }, [items]); - const addItem = (product: Product) => { + const addItem = (product: Product) => { setItems(prevItems => { const existingItem = prevItems.find(item => item.product.product_id === product.product_id); @@ -55,12 +55,12 @@ export function CartProvider({ children }: { children: React.ReactNode }) { }); }; - const removeItem = (productId: string) => { + const removeItem = (productId: string) => { setItems(prevItems => prevItems.filter(item => item.product.product_id !== productId)); }; - const updateQuantity = (productId: string, quantity: number) => { - if (quantity <= 0) { + const updateQuantity = (productId: string, quantity: number) => { + if (quantity <= 0) { removeItem(productId); return; } @@ -87,7 +87,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) { return ( 2023 ? `-${currentYear}` : ''); const skeleton = 'w-full h-6 animate-pulse rounded-sm bg-neutral-200 dark:bg-neutral-700'; const menu = await getMenu('next-js-frontend-footer-menu'); const copyrightName = COMPANY_NAME || SITE_NAME || ''; @@ -37,32 +36,15 @@ export default async function Footer() { > - + {/* Removed deploy button and placeholder actions */}
-

- © {copyrightDate} {copyrightName} - {copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved. -

+

{currentYear}


-

- View the source -

-

- - Created by ▲ Vercel - + {/* Removed placeholder source link */} +

+ Created by Mehar and Karan

diff --git a/components/layout/navbar/scanner-button.tsx b/components/layout/navbar/scanner-button.tsx index 0f8a39d..f94319a 100644 --- a/components/layout/navbar/scanner-button.tsx +++ b/components/layout/navbar/scanner-button.tsx @@ -15,7 +15,7 @@ export default function ScannerButton() {