Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Stage 1: Install production dependencies only
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force

# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Copy built application from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000

# listen on all interfaces
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000

# Stage 4: Start the application
CMD ["node", "server.js"]
5 changes: 3 additions & 2 deletions src/app/[locale]/add-new/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function NewToothpasteForm() {
name: "",
ingredients: "",
});
const BASE_URL = "http://server:3001/api";

const handleImageUpload = (e) => {
const file = e.target.files?.[0];
Expand Down Expand Up @@ -63,10 +64,10 @@ export default function NewToothpasteForm() {
formDataToSend.append("brand", formData.brand);
formDataToSend.append("name", formData.name);
formDataToSend.append("ingredients", JSON.stringify(ingredientsArray));
formDataToSend.append("image", imageFile);
formDataToSend.append("file", imageFile);

try {
const res = await fetch("http://localhost:3000/api/v1/toothpastes/new", {
const res = await fetch(`${BASE_URL}/v1/toothpastes/new`, {
method: "POST",
body: formDataToSend,
});
Expand Down
5 changes: 2 additions & 3 deletions src/app/[locale]/products/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,12 @@ export default function ProductDetail() {
const [product, setProduct] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const BASE_URL = "http://server:3001/api";

useEffect(() => {
async function fetchProduct() {
try {
const res = await fetch(
`http://localhost:3000/api/v1/toothpastes/${id}`,
);
const res = await fetch(`${BASE_URL}/v1/toothpastes/${id}`);
if (!res.ok) {
if (res.status === 404) {
setError("Product not found");
Expand Down
2 changes: 1 addition & 1 deletion src/app/[locale]/products/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function SearchResults() {
const [showFilters, setShowFilters] = useState(false);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const BASE_URL = "http://localhost:3000/api";
const BASE_URL = "http://server:3001/api";

useEffect(() => {
async function fetchProducts() {
Expand Down
169 changes: 169 additions & 0 deletions src/app/[locale]/scan/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"use client";
import { useState, useRef } from "react";
import { Camera, Upload, X, ChevronLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { useRouter } from "next/navigation";

export default function ScanProductPage() {
const router = useRouter();
const [imagePreview, setImagePreview] = useState(null);
const [imageFile, setImageFile] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef(null);
const cameraInputRef = useRef(null);

const handleFileSelect = (e) => {
const file = e.target.files?.[0];
if (file) {
processImage(file);
}
};

const processImage = (file) => {
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
};

const removeImage = () => {
setImagePreview(null);
setImageFile(null);
};

const handleSubmit = async () => {
if (!imageFile) {
alert("Please select or capture an image");
return;
}

setIsSubmitting(true);

const formDataToSend = new FormData();
formDataToSend.append("file", imageFile);

try {
const res = await fetch(
"http://server-fastapi:8000/api/search-by-image",
{
method: "POST",
body: formDataToSend,
},
);

if (res.ok) {
const data = await res.json();
if (data.results.length > 0) {
router.push(`/products/${data.results[0].id}`);
} else {
router.push("/products");
}
} else {
alert("Product not found");
}
} catch (err) {
console.error(err);
alert("Network error — please try again later.");
} finally {
setIsSubmitting(false);
}
};

return (
<div className="min-h-screen px-4 py-16">
<div className="max-w-3xl mx-auto w-full flex flex-col gap-8">
<div>
<Button
variant="ghost"
size="sm"
className="mb-6 -ml-2"
onClick={() => router.back()}
>
<ChevronLeft size={16} className="mr-1" />
Back
</Button>

<p className="text-4xl font-light text-center tracking-tight mb-2">
Scan Product
</p>
<p className="text-sm text-muted-foreground text-center">
Upload or capture an image to identify the product
</p>
</div>

<Card className="bg-background">
<CardContent className="p-6">
<FieldLabel className="mb-3 block">Product Image</FieldLabel>

{!imagePreview
? <div className="space-y-3">
<label className="border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
<Camera size={32} className="text-muted-foreground mb-3" />
<p className="text-sm font-medium mb-1">Take Photo</p>
<p className="text-xs text-muted-foreground">
Use your camera to capture
</p>
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleFileSelect}
className="hidden"
/>
</label>

<label className="border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
<Upload size={32} className="text-muted-foreground mb-3" />
<p className="text-sm font-medium mb-1">
Upload from Device
</p>
<p className="text-xs text-muted-foreground">
PNG, JPG up to 10MB
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
</label>
</div>
: <div className="relative">
<div className="aspect-square overflow-hidden rounded-lg bg-muted">
<img
src={imagePreview}
alt="Product preview"
className="w-full h-full object-cover"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2"
onClick={removeImage}
>
<X size={16} />
</Button>
</div>}
</CardContent>
</Card>

<div className="flex flex-col gap-2">
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Scanning..." : "Scan Product"}
</Button>
<Button variant="outline" onClick={() => router.back()}>
Cancel
</Button>
</div>
</div>
</div>
);
}
Loading