commit 9e9287e66c9659458435e9ecb5565f97f2acced6 Author: m3mo Date: Mon Apr 21 02:10:04 2025 +0200 Initial commit with postal delivery application and address validation service diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8c59a0 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Next Auth +# Generate a secret with: openssl rand -base64 32 +NEXTAUTH_SECRET=your_nextauth_secret_here +NEXTAUTH_URL=http://localhost:3000 + +# Database +DATABASE_URL="postgresql://postgres:password@localhost:5432/postaci" + +# API Services +API_URL="http://127.0.0.1:8000/api" + +# Google Maps (if used) +# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..6b10a5b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e088e36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# dependencies +/node_modules +/.pnp +.pnp.js +/validation-service/node_modules + +# testing +/coverage + +# next.js +/.next/ +/out/ +/build + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local +.env +.env.development +.env.production + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# logs +*.log +validation-service/*.log + +# editor directories and files +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d39ae0 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Postaci - Swiss Postal Delivery Application + +Postaci is a web application for managing postal deliveries in Switzerland, featuring OCR address scanning, address validation, and route optimization. + +## Features + +- OCR-based address scanning using Tesseract.js +- Swiss address validation with canton detection +- Route optimization for multiple delivery addresses +- Interactive map interface using Leaflet +- User authentication with NextAuth.js +- Prisma ORM for database operations + +## Technology Stack + +- **Frontend**: React, Next.js 15, TailwindCSS +- **Backend**: Next.js API routes, Express.js (validation service) +- **Database**: PostgreSQL with Prisma ORM +- **Authentication**: NextAuth.js +- **Maps**: Leaflet, React-Leaflet +- **OCR**: Tesseract.js + +## Project Structure + +- `app/` - Next.js application routes and components +- `components/` - Reusable React components +- `lib/` - Utility functions and API helpers +- `prisma/` - Database schema and migrations +- `providers/` - React context providers +- `public/` - Static assets +- `validation-service/` - Address validation microservice + +## Setup & Development + +### Prerequisites + +- Node.js 18+ and npm +- PostgreSQL (for production) + +### Installation + +1. Clone the repository: + ```bash + git clone https://gitea.oezdag.io/m3mo/POSTERAPP_V1.git + cd POSTERAPP_V1 + ``` + +2. Install dependencies: + ```bash + npm install + cd validation-service && npm install + ``` + +3. Configure environment variables: + ```bash + cp .env.example .env.local + ``` + +4. Start the development environment: + ```bash + ./start-dev.sh + ``` + +This will start both the Next.js application and the address validation service. + +## Database Setup + +1. Initialize Prisma: + ```bash + npx prisma generate + npx prisma db push + ``` + +## Testing + +The address validation service can be tested independently: + +```bash +cd validation-service +npm run dev +``` + +Then use curl to test the endpoints: + +```bash +# Validate an address +curl -X POST http://localhost:8000/api/validate-address \ + -H "Content-Type: application/json" \ + -d '{"address": "Luzernstrasse 27, 4552 Derendingen"}' +``` + +## License + +This project is proprietary and confidential. \ No newline at end of file diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..82862d6 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import Link from 'next/link' +import { cookies } from 'next/headers' +import LanguageSelector from '@/components/LanguageSelector' + +async function getMessages() { + const cookieStore = await cookies() + const lang = cookieStore.get('NEXT_LOCALE')?.value || 'de' + const messages = await import(`@/messages/${lang}.json`) + return messages.default +} + +export default async function AboutPage() { + const messages = await getMessages() + + return ( +
+
+
+ +
+ +
+

+ {messages.about.title} +

+

+ {messages.about.description} +

+
+ +
+
+

+ {messages.about.features.title} +

+
    +
  • + + + + {messages.about.features.ocr} +
  • +
  • + + + + {messages.about.features.optimization} +
  • +
  • + + + + {messages.about.features.tracking} +
  • +
  • + + + + {messages.about.features.history} +
  • +
+
+ +
+

+ {messages.about.contact.title} +

+
+

+ + + + {messages.about.contact.email} +

+

+ + + + {messages.about.contact.phone} +

+
+
+
+ +
+ + + + + {messages.common.back} + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/api/address-suggestions/route.ts b/app/api/address-suggestions/route.ts new file mode 100644 index 0000000..0c4bf40 --- /dev/null +++ b/app/api/address-suggestions/route.ts @@ -0,0 +1,314 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { Address } from '@prisma/client' + +// İsviçre posta kodları ve şehirleri (örnek veri) +const SWISS_CITIES = [ + { city: 'Zürich', postcode: '8000' }, + { city: 'Basel', postcode: '4000' }, + { city: 'Bern', postcode: '3000' }, + { city: 'Genf', postcode: '1200' }, + { city: 'Lausanne', postcode: '1000' }, + { city: 'Winterthur', postcode: '8400' }, + { city: 'St. Gallen', postcode: '9000' }, + { city: 'Lugano', postcode: '6900' }, +] + +// Sokak isimleri için örnek son ekler +const STREET_SUFFIXES = [ + 'strasse', + 'weg', + 'platz', + 'gasse', + 'allee', + 'boulevard', + 'rue', + 'via', +] + +interface NominatimResult { + place_id: number + licence: string + osm_type: string + osm_id: number + boundingbox: string[] + lat: string + lon: string + display_name: string + class: string + type: string + importance: number + address?: { + house_number?: string + road?: string + suburb?: string + city?: string + town?: string + village?: string + municipality?: string + state?: string + postcode?: string + country?: string + street?: string + } +} + +interface SearchParams { + q: string + type: 'street' | 'number' | 'city' + street?: string +} + +async function searchStreets(query: string): Promise { + try { + const params = new URLSearchParams({ + q: `${query} ${query.toLowerCase().endsWith('strasse') ? '' : 'strasse'} switzerland`, + format: 'json', + addressdetails: '1', + countrycodes: 'ch', + limit: '5', + 'accept-language': 'de', + featuretype: 'street' + }) + + const response = await fetch( + `https://nominatim.openstreetmap.org/search?${params}`, + { + headers: { + 'User-Agent': 'PostaciApp/1.0' + } + } + ) + + if (!response.ok) { + throw new Error('Adres arama servisi şu anda kullanılamıyor') + } + + const results: NominatimResult[] = await response.json() + return results + .filter(result => result.address?.road) + .map(result => result.address?.road || '') + .filter((value, index, self) => self.indexOf(value) === index) // Tekrar edenleri kaldır + } catch (error) { + console.error('OpenStreetMap API hatası:', error) + throw error + } +} + +async function searchHouseNumbers(street: string): Promise { + try { + const params = new URLSearchParams({ + q: `${street} switzerland`, + format: 'json', + addressdetails: '1', + countrycodes: 'ch', + limit: '15', // Daha fazla sonuç al + 'accept-language': 'de' + }) + + const response = await fetch( + `https://nominatim.openstreetmap.org/search?${params}`, + { + headers: { + 'User-Agent': 'PostaciApp/1.0' + } + } + ) + + if (!response.ok) { + throw new Error('Adres arama servisi şu anda kullanılamıyor') + } + + const results: NominatimResult[] = await response.json() + return results + .filter(result => result.address?.house_number && result.address?.road === street) + .map(result => result.address?.house_number || '') + .filter((value, index, self) => self.indexOf(value) === index) // Tekrar edenleri kaldır + .sort((a, b) => parseInt(a) - parseInt(b)) // Numaraları sırala + } catch (error) { + console.error('OpenStreetMap API hatası:', error) + throw error + } +} + +async function searchCities(query: string): Promise { + try { + const params = new URLSearchParams({ + q: `${query} switzerland`, + format: 'json', + addressdetails: '1', + countrycodes: 'ch', + limit: '5', + 'accept-language': 'de', + featuretype: 'city' + }) + + const response = await fetch( + `https://nominatim.openstreetmap.org/search?${params}`, + { + headers: { + 'User-Agent': 'PostaciApp/1.0' + } + } + ) + + if (!response.ok) { + throw new Error('Adres arama servisi şu anda kullanılamıyor') + } + + const results: NominatimResult[] = await response.json() + return results + .filter(result => result.address?.city || result.address?.postcode) + .map(result => { + const city = result.address?.city || '' + const postcode = result.address?.postcode || '' + return `${postcode} ${city}`.trim() + }) + .filter((value, index, self) => self.indexOf(value) === index) // Tekrar edenleri kaldır + } catch (error) { + console.error('OpenStreetMap API hatası:', error) + throw error + } +} + +// Levenshtein mesafesi hesaplama +function levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length + if (b.length === 0) return a.length + + const matrix = [] + + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i] + } + + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j + } + + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1] + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ) + } + } + } + + return matrix[b.length][a.length] +} + +// Benzerlik oranı hesaplama +function calculateSimilarity(input: string, candidate: string): number { + const distance = levenshteinDistance(input.toLowerCase(), candidate.toLowerCase()) + const maxLength = Math.max(input.length, candidate.length) + return 1 - distance / maxLength +} + +// Yedek adres verileri +const BACKUP_ADDRESSES = [ + 'Luzernstrasse 27, 4552 Derendingen', + 'Luzernstrasse 15, 4552 Derendingen', + 'Luzernstrasse 29, 4552 Derendingen', + 'Luzernstrasse 25, 4552 Derendingen', + 'Luzernstrasse 31, 4552 Derendingen', + 'Luzernstrasse 12, 6003 Luzern', + 'Luzernstrasse 8, 6003 Luzern', + 'Luzernstrasse 16, 6003 Luzern', + 'Bahnhofstrasse 10, 3011 Bern', + 'Hauptstrasse 1, 8001 Zürich', + 'Kirchstrasse 7, 2502 Biel', + 'Marktgasse 8, 4051 Basel', + 'Oberer Graben 12, 9000 St. Gallen', + 'Pilatusstrasse 20, 6003 Luzern', + 'Rue de Lausanne 25, 1800 Vevey', + 'Rue du Mont-Blanc 15, 1201 Genève', + 'Via Nassa 5, 6900 Lugano' +] + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const query = searchParams.get('q') + + if (!query || query.length < 2) { + return NextResponse.json({ error: 'Arama terimi çok kısa' }, { status: 400 }) + } + + // Önce Nominatim API'den ara + try { + const params = new URLSearchParams({ + q: `${query} switzerland`, + format: 'json', + addressdetails: '1', + countrycodes: 'ch', + limit: '5', + 'accept-language': 'de' + }) + + const response = await fetch( + `https://nominatim.openstreetmap.org/search?${params}`, + { + headers: { + 'User-Agent': 'PostaciApp/1.0' + } + } + ) + + if (!response.ok) { + throw new Error('Nominatim API yanıt vermedi') + } + + const results: NominatimResult[] = await response.json() + const addresses = results + .filter(result => result.address?.road || result.display_name) + .map(result => { + const street = result.address?.road || '' + const houseNumber = result.address?.house_number || '' + const postcode = result.address?.postcode || '' + const city = result.address?.city || result.address?.town || result.address?.village || '' + return `${street} ${houseNumber}, ${postcode} ${city}`.trim().replace(/\s+/g, ' ') + }) + .filter(address => address.length > 0) + + // Eğer Nominatim'den sonuç geldiyse, döndür + if (addresses.length > 0) { + return NextResponse.json(addresses) + } + } catch (error) { + console.warn('Nominatim API hatası:', error) + // Nominatim hatası durumunda yedek adreslere geç + } + + // Yedek adreslerden benzerlik araması yap + const sortedAddresses = BACKUP_ADDRESSES + .map(address => ({ + address, + similarity: calculateSimilarity(query, address) + })) + .sort((a, b) => b.similarity - a.similarity) + .filter(item => item.similarity > 0.3) + .map(item => item.address) + .slice(0, 5) + + if (sortedAddresses.length === 0) { + return NextResponse.json( + { error: 'Sonuç bulunamadı' }, + { status: 200 } // 404 yerine 200 dönüyoruz + ) + } + + return NextResponse.json(sortedAddresses) + } catch (error) { + console.error('Adres arama hatası:', error) + return NextResponse.json( + { error: 'Adres arama sırasında bir hata oluştu' }, + { status: 200 } // 404 yerine 200 dönüyoruz + ) + } +} \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..d99b380 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,87 @@ +import NextAuth, { AuthOptions } from 'next-auth' +import CredentialsProvider from 'next-auth/providers/credentials' +import { compare } from 'bcryptjs' +import { prisma } from '../../../../lib/prisma' +import { UserRole } from '@prisma/client' + +export const authOptions: AuthOptions = { + providers: [ + CredentialsProvider({ + id: 'credentials', + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Şifre', type: 'password' } + }, + async authorize(credentials) { + try { + if (!credentials?.email || !credentials?.password) { + return null + } + + const user = await prisma.user.findUnique({ + where: { + email: credentials.email + } + }) + + if (!user || !user.password) { + console.error('Kullanıcı bulunamadı:', credentials.email) + return null + } + + const isValid = await compare(credentials.password, user.password) + + if (!isValid) { + console.error('Geçersiz şifre:', credentials.email) + return null + } + + console.log('Başarılı giriş:', user.email) + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role + } + } catch (error) { + console.error('Giriş hatası:', error) + return null + } + } + }) + ], + secret: process.env.NEXTAUTH_SECRET, + session: { + strategy: 'jwt', + maxAge: 30 * 24 * 60 * 60, // 30 gün + }, + pages: { + signIn: '/auth/login', + signOut: '/auth/logout', + error: '/auth/error' + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.role = user.role + token.id = user.id + } + return token + }, + async session({ session, token }) { + if (session?.user) { + session.user.role = token.role as UserRole + session.user.id = token.id as string + } + return session + } + }, + debug: process.env.NODE_ENV === 'development', + trustHost: true +} + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } \ No newline at end of file diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..7e97842 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server' +import { hash } from 'bcryptjs' +import { prisma } from '@/lib/prisma' + +export async function POST(req: Request) { + try { + const { name, email, password } = await req.json() + + // Gerekli alanların kontrolü + if (!name || !email || !password) { + return NextResponse.json( + { message: 'Tüm alanların doldurulması zorunludur' }, + { status: 400 } + ) + } + + // Email formatı kontrolü + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email)) { + return NextResponse.json( + { message: 'Geçerli bir email adresi giriniz' }, + { status: 400 } + ) + } + + // Email kullanımda mı kontrolü + const existingUser = await prisma.user.findUnique({ + where: { email }, + }) + + if (existingUser) { + return NextResponse.json( + { message: 'Bu email adresi zaten kullanımda' }, + { status: 400 } + ) + } + + // Şifre hash'leme + const hashedPassword = await hash(password, 12) + + // Kullanıcı oluşturma + const user = await prisma.user.create({ + data: { + name, + email, + password: hashedPassword, + role: 'POSTACI', // Varsayılan rol + }, + }) + + return NextResponse.json( + { + message: 'Kullanıcı başarıyla oluşturuldu', + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + }, + { status: 201 } + ) + } catch (error) { + console.error('Kayıt hatası:', error) + return NextResponse.json( + { message: 'Kayıt işlemi sırasında bir hata oluştu' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/history/route.ts b/app/api/history/route.ts new file mode 100644 index 0000000..16e2e7c --- /dev/null +++ b/app/api/history/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '../auth/[...nextauth]/route' +import { prisma } from '@/lib/prisma' + +// Geçmiş kayıtlarını getir +export async function GET() { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.email) { + return NextResponse.json( + { error: 'Oturum bulunamadı' }, + { status: 401 } + ) + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }) + + if (!user) { + return NextResponse.json( + { error: 'Kullanıcı bulunamadı' }, + { status: 401 } + ) + } + + const history = await prisma.history.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + include: { + route: true, + address: true + } + }) + + return NextResponse.json(history) + } catch (error) { + console.error('Geçmiş kayıtları alınırken hata oluştu:', error) + return NextResponse.json( + { error: 'Geçmiş kayıtları alınırken bir hata oluştu' }, + { status: 500 } + ) + } +} + +// Yeni geçmiş kaydı oluştur +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.email) { + return NextResponse.json( + { error: 'Oturum bulunamadı' }, + { status: 401 } + ) + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }) + + if (!user) { + return NextResponse.json( + { error: 'Kullanıcı bulunamadı' }, + { status: 401 } + ) + } + + const { routeId, addressId, action } = await req.json() + + const history = await prisma.history.create({ + data: { + userId: user.id, + routeId, + addressId, + action, + createdAt: new Date() + }, + include: { + route: true, + address: true + } + }) + + return NextResponse.json(history, { status: 201 }) + } catch (error) { + console.error('Geçmiş kaydı oluşturulurken hata oluştu:', error) + return NextResponse.json( + { error: 'Geçmiş kaydı oluşturulurken bir hata oluştu' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/routes/[id]/route.ts b/app/api/routes/[id]/route.ts new file mode 100644 index 0000000..3cbb311 --- /dev/null +++ b/app/api/routes/[id]/route.ts @@ -0,0 +1,268 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../../auth/[...nextauth]/route"; +import { prisma } from "@/lib/prisma"; + +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const { params } = context; + const { id } = await params; + + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json( + { error: "Oturum bulunamadı" }, + { status: 401 } + ); + } + + const user = await prisma.user.findFirst({ + where: { email: session.user.email }, + }); + + if (!user) { + return NextResponse.json( + { error: "Kullanıcı bulunamadı" }, + { status: 401 } + ); + } + + const route = await prisma.route.findFirst({ + where: { + id: id, + userId: user.id + }, + include: { + addresses: { + orderBy: { + order: 'asc' + } + } + } + }); + + if (!route) { + return NextResponse.json( + { error: "Rota bulunamadı" }, + { status: 404 } + ); + } + + return NextResponse.json(route); + } catch (error) { + console.error("Rota detayları alınırken hata oluştu:", error); + return NextResponse.json( + { error: "Rota detayları alınırken bir hata oluştu" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const { params } = context; + const { id } = await params; + + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json( + { error: "Oturum bulunamadı" }, + { status: 401 } + ); + } + + const user = await prisma.user.findFirst({ + where: { email: session.user.email }, + }); + + if (!user) { + return NextResponse.json( + { error: "Kullanıcı bulunamadı" }, + { status: 401 } + ); + } + + const existingRoute = await prisma.route.findFirst({ + where: { + id: id, + userId: user.id + }, + include: { + addresses: true + } + }); + + if (!existingRoute) { + return NextResponse.json( + { error: "Rota bulunamadı veya düzenleme yetkiniz yok" }, + { status: 404 } + ); + } + + const { name, status, addresses } = await request.json(); + + // Create a map of existing addresses by street for quick lookup + const existingAddressMap = new Map(); + existingRoute.addresses.forEach(address => { + existingAddressMap.set(address.street, address); + }); + + // Use a transaction to ensure atomicity + const updatedRoute = await prisma.$transaction(async (tx) => { + // Update route properties if provided + const routeUpdate = { + ...(name !== undefined && { name }), + ...(status !== undefined && { status }), + }; + + // Update the route if there are properties to update + if (Object.keys(routeUpdate).length > 0) { + await tx.route.update({ + where: { id }, + data: routeUpdate + }); + } + + // If addresses are provided, update them + if (addresses && addresses.length > 0) { + // Delete addresses that are no longer present + const newStreetAddresses = addresses.map((a: { street: string }) => a.street); + await tx.address.deleteMany({ + where: { + routeId: id, + street: { notIn: newStreetAddresses } + } + }); + + // Process each address + const addressPromises = addresses.map(async (address: { + street: string; + city?: string; + postcode?: string; + latitude?: number; + longitude?: number; + }, index: number) => { + const existingAddress = existingAddressMap.get(address.street); + + if (existingAddress) { + // Update existing address + return tx.address.update({ + where: { id: existingAddress.id }, + data: { + street: address.street, + city: address.city || '', + postcode: address.postcode || '', + country: 'Switzerland', + order: index, + // Preserve latitude/longitude if not provided + ...(address.latitude !== undefined ? { latitude: address.latitude } : {}), + ...(address.longitude !== undefined ? { longitude: address.longitude } : {}) + } + }); + } else { + // Create new address + return tx.address.create({ + data: { + street: address.street, + city: address.city || '', + postcode: address.postcode || '', + country: 'Switzerland', + order: index, + latitude: address.latitude, + longitude: address.longitude, + routeId: id + } + }); + } + }); + + await Promise.all(addressPromises); + } + + // Return the updated route with sorted addresses + return tx.route.findUnique({ + where: { id }, + include: { + addresses: { + orderBy: { + order: 'asc' + } + } + } + }); + }); + + return NextResponse.json(updatedRoute); + } catch (error) { + console.error("Rota güncellenirken hata oluştu:", error); + return NextResponse.json( + { error: "Rota güncellenirken bir hata oluştu" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const { params } = context; + const { id } = await params; + + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json( + { error: "Oturum bulunamadı" }, + { status: 401 } + ); + } + + const user = await prisma.user.findFirst({ + where: { email: session.user.email }, + }); + + if (!user) { + return NextResponse.json( + { error: "Kullanıcı bulunamadı" }, + { status: 401 } + ); + } + + const route = await prisma.route.findFirst({ + where: { + id: id, + userId: user.id + } + }); + + if (!route) { + return NextResponse.json( + { error: "Rota bulunamadı veya silme yetkiniz yok" }, + { status: 404 } + ); + } + + await prisma.route.delete({ + where: { + id: id + } + }); + + return NextResponse.json({ message: "Rota başarıyla silindi" }); + } catch (error) { + console.error("Rota silinirken hata oluştu:", error); + return NextResponse.json( + { error: "Rota silinirken bir hata oluştu" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/routes/optimize/route.ts b/app/api/routes/optimize/route.ts new file mode 100644 index 0000000..ec8f28b --- /dev/null +++ b/app/api/routes/optimize/route.ts @@ -0,0 +1,275 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { prisma } from '@/lib/prisma'; + +interface Coordinate { + lat: number; + lon: number; +} + +interface AddressPoint { + coordinate: Coordinate; + city: string; + street: string; + originalIndex: number; +} + +// İki nokta arası mesafeyi hesapla (Haversine formülü) +function calculateDistance(coord1: Coordinate, coord2: Coordinate): number { + const R = 6371; // Dünya'nın yarıçapı (km) + const dLat = toRad(coord2.lat - coord1.lat); + const dLon = toRad(coord2.lon - coord1.lon); + const lat1 = toRad(coord1.lat); + const lat2 = toRad(coord2.lat); + + const a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.sin(dLon/2) * Math.sin(dLon/2) * Math.cos(lat1) * Math.cos(lat2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; +} + +function toRad(value: number): number { + return value * Math.PI / 180; +} + +async function geocodeAddress(address: string): Promise { + try { + const query = encodeURIComponent(address + ", Switzerland"); + const response = await fetch( + `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1`, + { + headers: { + 'User-Agent': 'PostaciApp/1.0' + } + } + ); + + if (!response.ok) { + throw new Error('Geocoding request failed'); + } + + const data = await response.json(); + if (data && data.length > 0) { + return { + lat: parseFloat(data[0].lat), + lon: parseFloat(data[0].lon) + }; + } + return null; + } catch (error) { + console.error('Geocoding error:', error); + return null; + } +} + +function optimizeRoute(points: AddressPoint[]): number[] { + if (points.length < 2) return points.map((_, i) => i); + + const optimizedRoute: number[] = [0]; // Başlangıç noktası + const unvisited = points.slice(1).map((_, i) => i + 1); + + while (unvisited.length > 0) { + const currentPoint = points[optimizedRoute[optimizedRoute.length - 1]]; + let nearestPoint = -1; + let nearestIndex = -1; + let minScore = Infinity; + + // Tüm ziyaret edilmemiş noktaları değerlendir + for (let i = 0; i < unvisited.length; i++) { + const pointIndex = unvisited[i]; + const candidatePoint = points[pointIndex]; + + // Mesafe ve şehir bazlı skor hesapla + const distance = calculateDistance(currentPoint.coordinate, candidatePoint.coordinate); + + // Şehir aynıysa ekstra bonus ver (daha düşük skor daha iyi) + const cityScore = currentPoint.city === candidatePoint.city ? 0 : 2; + + // Toplam skoru hesapla (mesafe + şehir skoru) + const totalScore = distance + cityScore; + + // En düşük skorlu noktayı bul + if (totalScore < minScore) { + minScore = totalScore; + nearestPoint = pointIndex; + nearestIndex = i; + } + } + + if (nearestPoint !== -1) { + optimizedRoute.push(nearestPoint); + unvisited.splice(nearestIndex, 1); + + // Debug bilgisi + console.log(`Eklenen nokta: ${points[nearestPoint].street}, ${points[nearestPoint].city}`); + console.log(`Mesafe skoru: ${minScore}`); + } + } + + return optimizedRoute; +} + +export async function POST(request: Request) { + try { + // Oturum kontrolü + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return new Response( + JSON.stringify({ + success: false, + error: 'Oturum gerekli' + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + const { addresses, routeId } = await request.json(); + console.log('Gelen adresler:', addresses); + + if (!Array.isArray(addresses) || addresses.length < 2) { + return new Response( + JSON.stringify({ + success: false, + error: 'En az 2 adres gerekli' + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Adresleri geocode et + const points: AddressPoint[] = []; + for (let i = 0; i < addresses.length; i++) { + const address = addresses[i]; + const parts = address.split(','); + const cityMatch = parts[1]?.match(/\d{4}\s+([^\d]+)/); + const city = cityMatch ? cityMatch[1].trim() : ''; + + const coordinate = await geocodeAddress(address); + console.log(`Adres ${i + 1} koordinatları:`, { address, city, coordinate }); + + if (coordinate) { + points.push({ + coordinate, + city, + street: parts[0].trim(), + originalIndex: i + }); + } + + // Rate limiting + if (i < addresses.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + console.log('Geocode edilmiş noktalar:', points); + + if (points.length < 2) { + return new Response( + JSON.stringify({ + success: false, + error: 'Yeterli sayıda geçerli adres bulunamadı' + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Rotayı optimize et + const optimizedIndices = optimizeRoute(points); + console.log('Optimize edilmiş sıralama:', optimizedIndices); + + // Doğrudan points dizisinden alarak optimizedRoute'u oluştur + const optimizedRoute = optimizedIndices.map(i => ({ + index: i, // originalIndex yerine doğrudan sıra numarasını kullan + lat: points[i].coordinate.lat, + lon: points[i].coordinate.lon, + address: addresses[i] // points[i].originalIndex yerine doğrudan i kullan + })); + + console.log('Optimize edilmiş rota:', optimizedRoute); + + // Toplam mesafeyi hesapla + let totalDistance = 0; + for (let i = 0; i < optimizedRoute.length - 1; i++) { + totalDistance += calculateDistance( + { lat: optimizedRoute[i].lat, lon: optimizedRoute[i].lon }, + { lat: optimizedRoute[i + 1].lat, lon: optimizedRoute[i + 1].lon } + ); + } + + // Tahmini süreyi hesapla (ortalama 30 km/saat hız varsayarak) + const estimatedDuration = (totalDistance / 30) * 60; // dakika cinsinden + + // Rotanın durumunu güncelle + if (routeId) { + // Önce tüm adresleri getir + const existingAddresses = await prisma.route.findUnique({ + where: { id: routeId }, + include: { addresses: true } + }); + + if (existingAddresses) { + // Her optimize edilmiş adres için güncelleme yap + await Promise.all(optimizedRoute.map(async (route, newOrder) => { + const addressToUpdate = existingAddresses.addresses.find( + addr => addr.street === route.address.split(',')[0].trim() + ); + + if (addressToUpdate) { + await prisma.address.update({ + where: { id: addressToUpdate.id }, + data: { + order: newOrder, + latitude: route.lat, + longitude: route.lon + } + }); + } + })); + + // Route'un durumunu güncelle + await prisma.route.update({ + where: { id: routeId }, + data: { status: 'OPTIMIZED' } + }); + } + } + + return new Response( + JSON.stringify({ + success: true, + data: { + route: optimizedRoute, + distance: Math.round(totalDistance * 10) / 10, // 1 ondalık basamak + duration: Math.round(estimatedDuration) + } + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } catch (error) { + console.error('Route optimization error:', error); + return new Response( + JSON.stringify({ + success: false, + error: 'Rota optimizasyonu sırasında bir hata oluştu' + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } +} \ No newline at end of file diff --git a/app/api/routes/route.ts b/app/api/routes/route.ts new file mode 100644 index 0000000..21e6e4b --- /dev/null +++ b/app/api/routes/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../auth/[...nextauth]/route'; +import { prisma } from '@/lib/prisma'; + +// Rotaları getir +export async function GET() { + try { + // Oturum kontrolü + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json( + { message: 'Oturum açmanız gerekiyor' }, + { status: 401 } + ); + } + + // Kullanıcıyı bul + const user = await prisma.user.findUnique({ + where: { email: session.user?.email || '' } + }); + + if (!user) { + return NextResponse.json( + { message: 'Kullanıcı bulunamadı' }, + { status: 401 } + ); + } + + // Kullanıcının rotalarını getir + const routes = await prisma.route.findMany({ + where: { userId: user.id }, + include: { + addresses: { + orderBy: { order: 'asc' } + } + }, + orderBy: { createdAt: 'desc' } + }); + + return NextResponse.json(routes); + } catch (error) { + console.error('Rotaları getirme hatası:', error); + return NextResponse.json( + { message: 'Rotalar yüklenirken bir hata oluştu' }, + { status: 500 } + ); + } +} + +// Yeni rota oluştur +export async function POST(req: Request) { + try { + // Oturum kontrolü + const session = await getServerSession(authOptions); + console.log('Session:', session); + + if (!session?.user?.email) { + return NextResponse.json( + { success: false, error: 'Oturum açmanız gerekiyor' }, + { status: 401 } + ); + } + + // Kullanıcı ID'sini veritabanından al + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }); + + if (!user) { + return NextResponse.json( + { success: false, error: 'Kullanıcı bulunamadı' }, + { status: 401 } + ); + } + + const { addresses } = await req.json(); + + // Adres kontrolü + if (!addresses || !Array.isArray(addresses) || addresses.length < 2) { + return NextResponse.json( + { success: false, error: 'En az 2 geçerli adres gerekli' }, + { status: 400 } + ); + } + + try { + // Yeni rota oluştur + const route = await prisma.route.create({ + data: { + userId: user.id, + status: 'CREATED', + addresses: { + create: addresses.map((address: string, index: number) => { + // Adresi parçalara ayır + const parts = address.split(',').map((part: string) => part.trim()); + const streetPart = parts[0] || ''; + const locationPart = parts[1] || ''; + + // Posta kodu ve şehir bilgisini ayır + const locationMatch = locationPart.match(/(\d{4})\s+([^0]+)/); + const postcode = locationMatch ? locationMatch[1] : ''; + const city = locationMatch ? locationMatch[2].replace(/\s*0+$/, '').trim() : ''; + + return { + street: streetPart, + city: city, + postcode: postcode, + country: 'Switzerland', + order: index + }; + }) + } + }, + include: { + addresses: true + } + }); + + return NextResponse.json({ + success: true, + data: route + }, { status: 201 }); + } catch (dbError) { + console.error('Veritabanı hatası:', dbError); + return NextResponse.json( + { + success: false, + error: 'Rota veritabanına kaydedilirken bir hata oluştu' + }, + { status: 500 } + ); + } + } catch (error) { + console.error('Rota oluşturma hatası:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Rota oluşturulurken bir hata oluştu' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/auth/login/LoginForm.tsx b/app/auth/login/LoginForm.tsx new file mode 100644 index 0000000..ee2f5a9 --- /dev/null +++ b/app/auth/login/LoginForm.tsx @@ -0,0 +1,142 @@ +'use client' + +import React, { useState } from 'react' +import { signIn } from 'next-auth/react' +import { useRouter } from 'next/navigation' + +interface LoginFormProps { + messages: { + common: { + email: string + password: string + } + login: { + emailPlaceholder: string + passwordPlaceholder: string + button: string + loading: string + error: string + invalidCredentials: string + } + } +} + +export default function LoginForm({ messages }: LoginFormProps) { + const router = useRouter() + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const formData = new FormData(e.currentTarget) + const email = formData.get('email') as string + const password = formData.get('password') as string + + console.log('Giriş denemesi:', email) + + const result = await signIn('credentials', { + email, + password, + redirect: false + }) + + console.log('Giriş sonucu:', result) + + if (result?.error) { + console.error('Giriş hatası:', result.error) + setError(messages.login.invalidCredentials) + return + } + + if (!result?.ok) { + console.error('Giriş başarısız:', result) + setError(messages.login.error) + return + } + + console.log('Giriş başarılı, yönlendiriliyor...') + router.push('/dashboard') + } catch (error) { + console.error('Beklenmeyen giriş hatası:', error) + setError(messages.login.error) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ + +
+
+ + +
+
+ + {error && ( +
+
+
+ + + +
+
+

{error}

+
+
+
+ )} + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..2d3c382 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { cookies } from 'next/headers' +import LoginForm from './LoginForm' +import LanguageSelector from '@/components/LanguageSelector' + +async function getMessages() { + const cookieStore = await cookies() + const lang = cookieStore.get('NEXT_LOCALE')?.value || 'de' + const messages = await import(`@/messages/${lang}.json`) + return messages.default +} + +export default async function LoginPage() { + const messages = await getMessages() + + return ( +
+
+
+ +
+ +
+

+ {messages.login.title} +

+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/app/auth/register/RegisterForm.tsx b/app/auth/register/RegisterForm.tsx new file mode 100644 index 0000000..0c40815 --- /dev/null +++ b/app/auth/register/RegisterForm.tsx @@ -0,0 +1,153 @@ +'use client' + +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' + +interface RegisterFormProps { + messages: { + common: { + name: string + email: string + password: string + confirmPassword: string + } + register: { + namePlaceholder: string + emailPlaceholder: string + passwordPlaceholder: string + confirmPasswordPlaceholder: string + button: string + loading: string + error: string + passwordMismatch: string + } + } +} + +export default function RegisterForm({ messages }: RegisterFormProps) { + const router = useRouter() + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + const formData = new FormData(e.currentTarget) + const name = formData.get('name') as string + const email = formData.get('email') as string + const password = formData.get('password') as string + const confirmPassword = formData.get('confirmPassword') as string + + if (password !== confirmPassword) { + setError(messages.register.passwordMismatch) + setLoading(false) + return + } + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + email, + password, + }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || messages.register.error) + } + + router.push('/auth/login?registered=true') + } catch (error) { + setError(error instanceof Error ? error.message : messages.register.error) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {error && ( +
+
{error}
+
+ )} + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/auth/register/page.tsx b/app/auth/register/page.tsx new file mode 100644 index 0000000..319f5ee --- /dev/null +++ b/app/auth/register/page.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { cookies } from 'next/headers' +import RegisterForm from './RegisterForm' +import LanguageSelector from '@/components/LanguageSelector' + +async function getMessages() { + const cookieStore = await cookies() + const lang = cookieStore.get('NEXT_LOCALE')?.value || 'de' + const messages = await import(`@/messages/${lang}.json`) + return messages.default +} + +export default async function RegisterPage() { + const messages = await getMessages() + + return ( +
+
+
+ +
+ +
+

+ {messages.register.title} +

+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/app/dashboard/history/page.tsx b/app/dashboard/history/page.tsx new file mode 100644 index 0000000..98a076e --- /dev/null +++ b/app/dashboard/history/page.tsx @@ -0,0 +1,161 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { useSession } from 'next-auth/react' +import Link from 'next/link' +import Cookies from 'js-cookie' + +interface Address { + id: string + street: string + city: string + postcode: string +} + +interface Route { + id: string + name: string | null + status: string +} + +interface HistoryRecord { + id: string + action: string + createdAt: string + route: Route + address: Address +} + +export default function HistoryPage() { + const { data: session, status } = useSession() + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [messages, setMessages] = useState(null) + + useEffect(() => { + const loadMessages = async () => { + const lang = Cookies.get('NEXT_LOCALE') || 'de' + const messages = await import(`@/messages/${lang}.json`) + setMessages(messages.default) + } + loadMessages() + }, []) + + useEffect(() => { + fetchHistory() + }, [session]) + + const fetchHistory = async () => { + try { + const response = await fetch('/api/history', { + credentials: 'include' + }) + + if (!response.ok) { + throw new Error(messages?.history?.error) + } + + const data = await response.json() + setHistory(data) + } catch (err) { + console.error('Geçmiş kayıtları getirme hatası:', err) + setError(err instanceof Error ? err.message : messages?.history?.error) + } finally { + setLoading(false) + } + } + + if (!messages || status === 'loading' || loading) { + return ( +
+
+
+ ) + } + + if (status === 'unauthenticated') { + return ( +
+
+

{messages.common.accessDenied}

+

{messages.common.loginRequired}

+ + {messages.common.login} + +
+
+ ) + } + + return ( +
+
+
+
+ + {messages.common.back} + +

{messages.history.title}

+
+
+ +
+ {error && ( +
+
+
+ + + +
+
+

{error}

+
+
+
+ )} + + {history.length === 0 ? ( +
+

{messages.history.noRecords}

+
+ ) : ( +
+
    + {history.map((record) => ( +
  • +
    +
    +

    + {record.address.street}, {record.address.city} {record.address.postcode} +

    +

    + {messages.history.routeStatus}: {messages.history.status[record.route.status.toLowerCase()] || record.route.status} +

    +
    +
    +

    + {new Date(record.createdAt).toLocaleString()} +

    +

    + {record.action === 'ADDRESS_VIEWED' ? messages.history.addressViewed : record.action} +

    +
    +
    +
  • + ))} +
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/app/dashboard/new-route/page.tsx b/app/dashboard/new-route/page.tsx new file mode 100644 index 0000000..ea1a4b6 --- /dev/null +++ b/app/dashboard/new-route/page.tsx @@ -0,0 +1,1425 @@ +'use client' + +import React, { useState, useRef, useCallback, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { createWorker, PSM } from 'tesseract.js' +import Link from 'next/link' +import Cookies from 'js-cookie' +import { performOCR, validateAddress, getAddressSuggestions } from '@/lib/api' + +// Schweizer Adress-Schlüsselwörter (Deutsch, Französisch und Italienisch) +const ADDRESS_KEYWORDS = [ + // Deutsch + 'strasse', 'str.', 'weg', 'platz', 'gasse', + 'hausnummer', 'nr.', 'nummer', + 'postfach', 'postleitzahl', 'plz', + 'stadt', 'kanton', + 'stockwerk', 'etage', + + // Französisch + 'rue', 'avenue', 'ave.', 'boulevard', 'blvd.', + 'place', 'chemin', 'route', + 'numéro', 'no.', + 'boîte postale', 'code postal', + 'ville', 'canton', + 'étage', + + // Italienisch + 'via', 'viale', 'corso', 'piazza', + 'numero', 'n.', + 'casella postale', 'codice postale', + 'città', 'cantone', + 'piano', + + // Generell + 'ch-', 'switzerland', 'schweiz', 'suisse', 'svizzera' +] + +// Eski tarayıcılar için tip tanımlamaları +declare global { + interface Navigator { + getUserMedia?: (constraints: MediaStreamConstraints, + successCallback: (stream: MediaStream) => void, + errorCallback: (error: Error) => void) => void; + webkitGetUserMedia?: (constraints: MediaStreamConstraints, + successCallback: (stream: MediaStream) => void, + errorCallback: (error: Error) => void) => void; + mozGetUserMedia?: (constraints: MediaStreamConstraints, + successCallback: (stream: MediaStream) => void, + errorCallback: (error: Error) => void) => void; + msGetUserMedia?: (constraints: MediaStreamConstraints, + successCallback: (stream: MediaStream) => void, + errorCallback: (error: Error) => void) => void; + } + + interface MediaDevices { + getUserMedia(constraints: MediaStreamConstraints): Promise; + } +} + +// Window-Interface erweitern +declare global { + interface Window { + handleAddressSelection?: (address: string | null) => void; + } +} + +interface AddressInput { + street: string + number: string + city: string +} + +const CACHE_DURATION = 120 * 60 * 60 * 1000; // 120 Stunden +const RATE_LIMIT = 1000; // 1 Sekunde +let lastRequestTime = 0; + +interface Coordinate { + lat: number; + lon: number; +} + +interface RoutePoint { + index: number; + lat: number; + lon: number; + address: string; +} + +interface OptimizationResult { + success: boolean; + data?: { + route: RoutePoint[]; + distance: number; + duration: number; + }; + error?: string; +} + +interface CachedAddress { + data: string[]; + timestamp: number; +} + +interface GuideRect { + x: number; + y: number; + width: number; + height: number; +} + +const addressCache = new Map(); + +function getCachedAddresses(query: string): string[] | null { + const cached = addressCache.get(query); + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.data; + } + return null; +} + +function cacheAddresses(query: string, addresses: string[]) { + addressCache.set(query, { + data: addresses, + timestamp: Date.now() + }); +} + +async function makeRequest(query: string) { + const now = Date.now(); + if (now - lastRequestTime < RATE_LIMIT) { + await new Promise(resolve => setTimeout(resolve, RATE_LIMIT)); + } + lastRequestTime = now; +} + +// Adresse von Nominatim API suchen +async function fetchFromNominatim(query: string): Promise { + try { + const response = await fetch( + `/api/address-suggestions?q=${encodeURIComponent(query)}`, + { + headers: { + 'Content-Type': 'application/json' + } + } + ); + + if (!response.ok) { + // Verwende messagesData für Fehlermeldung (wird später im Komponentenkontext übergeben oder behandelt) + throw new Error('Adresssuchdienst antwortete nicht'); // Generischer Fallback + } + + const results = await response.json(); + return results; + } catch (error) { + console.error('Fehler bei der Adresssuche:', error); + throw error; + } +} + +// Ersatzdienst mit lokalen Daten verwenden +async function searchWithFallbackService(query: string): Promise { + // Einfache lokale Suche + const localAddresses = [ + 'Bahnhofstrasse 1, 8001 Zürich', + 'Bundesplatz 3, 3003 Bern', + 'Rue du Mont-Blanc 18, 1201 Genève', + 'Via Nassa 5, 6900 Lugano' + ]; + + return localAddresses.filter(address => + address.toLowerCase().includes(query.toLowerCase()) + ); +} + +// Hauptsuchfunktion +async function searchAddress(query: string): Promise { + try { + // Zuerst Cache prüfen + const cached = getCachedAddresses(query); + if (cached) { + console.log('Aus Cache geladen:', query); + return cached; + } + + // Ratenbegrenzung anwenden + await makeRequest(query); + + // API-Anfrage stellen + const results = await fetchFromNominatim(query); + + // Ergebnisse zwischenspeichern + cacheAddresses(query, results); + console.log('Von API geladen und im Cache gespeichert:', query); + + return results; + } catch (error) { + console.error('Nominatim API Fehler:', error); + // Auf Ersatzdienst wechseln + console.log('Wechsle zu Ersatzdienst'); + return await searchWithFallbackService(query); + } +} + +// Update the validateWithAPI function to better handle OCR results +const validateWithAPI = async ( + text: string, + setAddressSuggestions: React.Dispatch>, + setShowSuggestionModal: React.Dispatch>, + setCurrentAddress: React.Dispatch> +): Promise => { + try { + console.log("[VALIDATE] Starting address validation for:", text); + + // Split text into lines and clean them + const lines = text + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + // Quick check for empty text + if (lines.length === 0) { + console.log("[VALIDATE] No lines to validate"); + return ''; + } + + console.log("[VALIDATE] Working with cleaned lines:", lines); + + // Look for patterns that suggest a Swiss address: + // 1. A line with "strasse" or similar street indicator + // 2. A line with 4-digit postal code + + let streetLine = ''; + let postalCodeLine = ''; + + for (const line of lines) { + const lowerLine = line.toLowerCase(); + + // Check for street indicators + if ( + (lowerLine.includes('strasse') || lowerLine.includes('str.') || + lowerLine.includes('weg') || lowerLine.includes('platz')) && + /\d+/.test(lowerLine) // Street lines typically include a number + ) { + streetLine = line; + } + + // Check for Swiss postal code format (4 digits followed by city name) + if (/\b\d{4}\b/.test(lowerLine)) { + postalCodeLine = line; + } + } + + // If we found both components of an address, format it + if (streetLine && postalCodeLine) { + console.log("[VALIDATE] Found address components:", { streetLine, postalCodeLine }); + + // Filter out likely person name (usually the first line) if present + const nameLine = lines[0] !== streetLine && lines[0] !== postalCodeLine ? lines[0] : ''; + + // Construct properly formatted address + let formattedAddress = ''; + + if (nameLine) { + formattedAddress = `${nameLine}\n${streetLine}\n${postalCodeLine}`; + } else { + formattedAddress = `${streetLine}\n${postalCodeLine}`; + } + + console.log("[VALIDATE] Created formatted address:", formattedAddress); + + // Try validating with API + const validationResponse = await validateAddress(formattedAddress); + + if (validationResponse.success && validationResponse.data) { + console.log("[VALIDATE] API validation successful:", validationResponse.data); + return validationResponse.data; + } + + // If API validation fails, use our formatted address anyway + return formattedAddress; + } + + // If we couldn't identify clear address components, proceed with API validation + console.log("[VALIDATE] Could not identify clear address components, calling API"); + const validationResponse = await validateAddress(text); + + if (validationResponse.success && validationResponse.data) { + console.log("[VALIDATE] API validation successful:", validationResponse.data); + return validationResponse.data; + } + + // If API validation fails, try to get suggestions + console.log("[VALIDATE] API validation failed, getting suggestions"); + const suggestionsResponse = await getAddressSuggestions(text); + + if (suggestionsResponse.success && suggestionsResponse.data && suggestionsResponse.data.length > 0) { + console.log("[VALIDATE] Found suggestions:", suggestionsResponse.data); + setAddressSuggestions(suggestionsResponse.data); + setShowSuggestionModal(true); + return ''; + } + + // If no suggestions, return the original text formatted + console.log("[VALIDATE] No suggestions found, returning original text"); + return lines.join('\n'); + } catch (error) { + console.error('[VALIDATE] Validation error:', error); + return text; + } +}; + +// Adresse aus OCR-Text extrahieren (fokussiert auf Straße, Hausnummer, PLZ und Ort) +const extractAddress = async (text: string): Promise => { + console.log('[EXTRACT] Processing OCR text:', text); + + // Zeilen aufteilen, trimmen, Leerzeilen entfernen + const lines = text + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + if (lines.length === 0) return ''; + + // Nur nach Straße und PLZ/Ort suchen (ignoriere Namen/Anreden) + let streetLine = ''; + let postalCityLine = ''; + + // Durchsuche jede Zeile nach relevanten Mustern + for (const line of lines) { + const lowerLine = line.toLowerCase(); + + // Straße mit Hausnummer erkennen + if ((/strasse|str\.|weg|platz|gasse/i.test(lowerLine)) && /\d+/.test(lowerLine)) { + streetLine = line; + console.log('[EXTRACT] Found street line:', streetLine); + continue; + } + + // PLZ (4-stellig in der Schweiz) mit Ort erkennen + if (/\b\d{4}\b/.test(lowerLine)) { + postalCityLine = line; + console.log('[EXTRACT] Found postal/city line:', postalCityLine); + continue; + } + } + + // Wenn keine strukturierte Adresse gefunden, nach Teilinformationen suchen + if (!streetLine || !postalCityLine) { + console.log('[EXTRACT] Searching for partial address information'); + + // Alle Zeilen zusammen durchsuchen + const fullText = lines.join(' ').toLowerCase(); + + // Straße suchen (falls noch nicht gefunden) + if (!streetLine) { + const streetMatch = fullText.match(/([a-zäöüß]+(?:strasse|str\.|weg|platz|gasse))\s+(\d+)/i); + if (streetMatch) { + streetLine = streetMatch[0]; + console.log('[EXTRACT] Extracted street from full text:', streetLine); + } + } + + // PLZ/Ort suchen (falls noch nicht gefunden) + if (!postalCityLine) { + const postalMatch = fullText.match(/\b(\d{4})\s+([a-zäöüß]+)\b/i); + if (postalMatch) { + postalCityLine = postalMatch[0]; + console.log('[EXTRACT] Extracted postal/city from full text:', postalCityLine); + } + } + } + + // Adresse formatieren + let formattedAddress = ''; + + if (streetLine && postalCityLine) { + // Beide Teile gefunden + formattedAddress = `${streetLine}, ${postalCityLine}`; + } else if (streetLine) { + // Nur Straße gefunden + formattedAddress = streetLine; + } else if (postalCityLine) { + // Nur PLZ/Ort gefunden + formattedAddress = postalCityLine; + } else { + // Fallback: eventuell gibt es Teile der Adresse ohne die typischen Muster + console.log('[EXTRACT] No standard address patterns found, attempting backup extraction'); + + // Nach Zahlen suchen, die wie Hausnummern oder PLZ aussehen + const numberMatches = []; + for (const line of lines) { + const numbers = line.match(/\b\d+\b/g); + if (numbers) { + for (const num of numbers) { + if (num.length === 4) { + // Wahrscheinlich eine PLZ + numberMatches.push({ type: 'postal', text: line }); + } else if (num.length <= 3) { + // Wahrscheinlich eine Hausnummer + numberMatches.push({ type: 'house', text: line }); + } + } + } + } + + // Wenn Matches gefunden, diese verwenden + if (numberMatches.length > 0) { + formattedAddress = numberMatches.map(match => match.text).join(', '); + } else { + // Letzter Ausweg: OCR-Text ohne Name verwenden + // Oft sind die ersten 1-2 Zeilen Namen, die wir ignorieren wollen + if (lines.length > 2) { + formattedAddress = lines.slice(1).join(', '); + } else { + formattedAddress = lines.join(', '); + } + } + } + + console.log('[EXTRACT] Final extracted address:', formattedAddress); + return formattedAddress; +}; + +// Schweizer Adresse formatieren (optional, wenn spezifische Formatierung benötigt wird) +const formatSwissAddress = (addressParts: string[]): string => { + // Logik zur Formatierung, z.B. Strasse, PLZ Ort + return addressParts.join(', '); +}; + +// Hilfsfunktion zum Zeichnen des Hilfsrahmens +const drawGuideLine = (canvasRef: React.RefObject): GuideRect | null => { + const canvas = canvasRef.current; + if (!canvas) { + console.log("[GUIDE] Canvas reference not available"); + return null; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + console.log("[GUIDE] Could not get canvas context"); + return null; + } + + // Nur zeichnen, wenn Canvas Dimensionen hat + if (canvas.width === 0 || canvas.height === 0) { + console.log("[GUIDE] Canvas has zero dimensions, cannot draw guide"); + return null; + } + + // Rahmenabmessungen berechnen - Adresse ist meist in einer Zeile + // Daher ist der Rahmen breiter als hoch + const frameWidth = canvas.width * 0.85; // 85% der Canvas-Breite + const frameHeight = canvas.height * 0.25; // 25% der Canvas-Höhe für eine Textzeile + const frameX = Math.floor((canvas.width - frameWidth) / 2); + const frameY = Math.floor((canvas.height - frameHeight) / 2); + + // Rahmen in kontrastierender Farbe zeichnen + ctx.strokeStyle = '#ff0000'; // Rot (gut sichtbar auf den meisten Hintergründen) + ctx.lineWidth = 4; // Dickere Linie für bessere Sichtbarkeit + ctx.setLineDash([10, 10]); // Gestrichelte Linie für bessere Sichtbarkeit + ctx.strokeRect(frameX, frameY, frameWidth, frameHeight); + + // Text-Hinweis für den Benutzer + ctx.font = 'bold 16px Arial'; + ctx.fillStyle = '#ff0000'; + ctx.textAlign = 'center'; + ctx.fillText('Adresse hier platzieren', canvas.width / 2, frameY - 10); + + // Rahmenkoordinaten zurückgeben + const guideRect = { + x: frameX, + y: frameY, + width: frameWidth, + height: frameHeight + }; + + console.log("[GUIDE] Guide rectangle created:", guideRect); + return guideRect; +}; + +// Hauptkomponente der Seite +export default function NewRoutePage() { + // Zustandsvariablen (State Hooks) + const [isManualEntry, setIsManualEntry] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [suggestedAddresses, setSuggestedAddresses] = useState([]) + const [addresses, setAddresses] = useState([]) + const [currentAddress, setCurrentAddress] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [scanStatus, setScanStatus] = useState('') + const [isOptimizing, setIsOptimizing] = useState(false) + const [showSuggestionModal, setShowSuggestionModal] = useState(false) + const [isAutoScanEnabled, setIsAutoScanEnabled] = useState(false) + const [autoScanIntervalId, setAutoScanIntervalId] = useState(null) + const [messagesData, setMessagesData] = useState(null) // Zustand für Übersetzungen + const [isCameraViewVisible, setIsCameraViewVisible] = useState(false); // Steuert Sichtbarkeit des Video-Bereichs + const [mediaStream, setMediaStream] = useState(null); // Hält das Stream-Objekt + + // Referenzen (Refs) + const videoRef = useRef(null) + const canvasRef = useRef(null) + const workerRef = useRef(null) + const router = useRouter() + + // Effect zum Laden der Übersetzungen + useEffect(() => { + const loadMessages = async () => { + const defaultLocale = 'de'; // Standard wie in Middleware + const lang = Cookies.get('NEXT_LOCALE') || defaultLocale; // Fallback auf 'de' + try { + const messagesModule = await import(`@/messages/${lang}.json`); + setMessagesData(messagesModule.default); + } catch (error) { + console.error(`Nachrichtendatei konnte nicht geladen werden: ${lang}`, error); + // Bei Fehler Standard (Deutsch) laden + try { + const defaultMessages = await import(`@/messages/${defaultLocale}.json`); + setMessagesData(defaultMessages.default); + } catch (defaultError) { + console.error(`Standard-Nachrichtendatei (${defaultLocale}) konnte nicht geladen werden:`, defaultError); + setMessagesData({}); // Leeres Objekt, wenn nichts geladen werden kann + } + } + }; + loadMessages(); + }, []); + + // Effect zum Initialisieren und Aufräumen des Tesseract Workers + useEffect(() => { + const initializeWorker = async () => { + try { + console.log("[WORKER] Initializing Tesseract worker..."); + const worker = await createWorker({ + logger: (m: any) => console.log(m) + }); + await worker.loadLanguage('deu'); + await worker.initialize('deu'); + // Set parameters after initialization + await worker.setParameters({ + tessedit_pageseg_mode: PSM.SINGLE_BLOCK, // Treat image as a single uniform block of text + // Add character whitelist for German addresses + tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÄÖÜäöüß0123456789.,- ', + }); + console.log("[WORKER] Tesseract parameters set."); + workerRef.current = worker; + console.log("[WORKER] Tesseract worker initialized and assigned to ref.", workerRef.current); + } catch (err) { + console.error("[WORKER] Tesseract Worker konnte nicht initialisiert werden:", err); + setError(messagesData?.newRoute?.scanError || "Fehler beim Initialisieren des Scanners"); + } + }; + initializeWorker(); + + return () => { + // Worker beim Verlassen der Komponente beenden + console.log("[WORKER] Terminating Tesseract worker..."); + workerRef.current?.terminate() + .then(() => console.log('[WORKER] Tesseract Worker beendet.')) + .catch(err => console.error('[WORKER] Fehler beim Beenden des Tesseract Workers:', err)); + workerRef.current = null; + } + }, []); // Empty dependency array - run only once on mount + + // Neuer useEffect zum Anhängen des Streams an das Video-Element + useEffect(() => { + const videoElement = videoRef.current; + + // Funktion zum Entfernen des Event Listeners + const cleanupListener = () => { + if (videoElement) { + videoElement.removeEventListener('play', handleVideoPlay); + console.log("[EFFECT] Removed play event listener"); + } + }; + + if (isCameraViewVisible && mediaStream && videoElement) { + console.log("[EFFECT] Attaching stream to video element"); + + // Wichtig: Alle alten Tracks stoppen + if (videoElement.srcObject) { + const oldStream = videoElement.srcObject as MediaStream; + oldStream.getTracks().forEach(track => { + track.stop(); + console.log("[EFFECT] Stopped old track:", track.kind); + }); + } + + videoElement.srcObject = mediaStream; + console.log("[EFFECT] Stream attached to video element"); + + // Video autoplay attribute setzen und abspielen + videoElement.autoplay = true; + videoElement.muted = true; // Wichtig für mobile Browser + videoElement.playsInline = true; // Wichtig für iOS + + // Timeout für Video-Start (gibt dem Browser etwas Zeit) + setTimeout(() => { + videoElement.play() + .then(() => { + console.log("[EFFECT] Video playback started successfully"); + + // Canvas initial einrichten + if (canvasRef.current) { + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + + if (ctx) { + // Canvas initial mit Video-Dimensionen einrichten + setTimeout(() => { + if (videoElement.videoWidth && videoElement.videoHeight) { + canvas.width = videoElement.videoWidth; + canvas.height = videoElement.videoHeight; + console.log(`[EFFECT] Canvas dimensions set to: ${canvas.width}x${canvas.height}`); + } + }, 500); // Kurze Verzögerung um sicherzustellen, dass Metadaten geladen sind + } + } + + // Event Listener hinzufügen + videoElement.addEventListener('play', handleVideoPlay); + console.log("[EFFECT] Added play event listener"); + }) + .catch(err => { + console.error("[EFFECT] Video play failed:", err); + setError("Video konnte nicht abgespielt werden: " + (err.message || "Unbekannter Fehler")); + setIsCameraViewVisible(false); + setMediaStream(null); + }); + }, 100); + + // Cleanup-Funktion zurückgeben + return cleanupListener; + } else { + // Log why the condition failed + console.log("[EFFECT] Not attaching stream.", { + isVisible: isCameraViewVisible, + hasStream: !!mediaStream, + hasVideoElement: !!videoElement + }); + + // Stream und Tracks stoppen, wenn die Komponente nicht sichtbar ist + if (videoElement && videoElement.srcObject) { + const currentStream = videoElement.srcObject as MediaStream; + currentStream.getTracks().forEach(track => { + track.stop(); + console.log("[EFFECT] Stopped track:", track.kind); + }); + videoElement.srcObject = null; + console.log("[EFFECT] Stream stopped and detached"); + } + + // Listener entfernen, falls er noch existiert + cleanupListener(); + } + }, [isCameraViewVisible, mediaStream]); // Abhängigkeiten: Sichtbarkeit und Stream + + // Funktion zum Abrufen von Adressvorschlägen + const fetchAddressSuggestions = async (term: string) => { + if (term.length < 3) { + setSuggestedAddresses([]); + return; + } + try { + setScanStatus(messagesData?.newRoute?.searchingAddresses || 'Adressen werden gesucht...'); + const results = await searchAddress(term); + setSuggestedAddresses(results); + setScanStatus(''); + } catch (error) { + console.error('Fehler bei der Adresssuche:', error); + setError(messagesData?.newRoute?.searchError || 'Fehler bei der Adresssuche'); + setScanStatus(''); + } + }; + + // Callback für Video-Wiedergabe + const handleVideoPlay = () => { + // Startet das Zeichnen auf dem Canvas, wenn das Video abgespielt wird + requestAnimationFrame(updateCanvas); + }; + + // Funktion zum Aktualisieren des Canvas (für visuelles Feedback) + const updateCanvas = (timestamp: number) => { + if (!videoRef.current || !canvasRef.current) return; + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Canvas-Größe an Video anpassen + if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + } + + // Videoframe auf Canvas zeichnen + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + // Hilfslinie zeichnen + const guideRect = drawGuideLine(canvasRef); + + // Nächsten Frame anfordern + requestAnimationFrame(updateCanvas); + }; + + // Funktion zum Starten des automatischen Scannens + const startAutoScan = () => { + if (autoScanIntervalId) return; // Verhindern, dass mehrere Intervalle gestartet werden + setIsAutoScanEnabled(true); + const intervalId = setInterval(() => { + scanDocument(true); // true übergeben, um im Auto-Scan-Modus zu kennzeichnen + }, 5000); // Alle 5 Sekunden scannen + setAutoScanIntervalId(intervalId); + }; + + // Funktion zum Stoppen des automatischen Scannens + const stopAutoScan = () => { + if (autoScanIntervalId) { + clearInterval(autoScanIntervalId); + setAutoScanIntervalId(null); + } + setIsAutoScanEnabled(false); + }; + + // Funktion zum Starten der Kamera (modifiziert) + const startCamera = async () => { + setLoading(true); + setError(null); + setCurrentAddress(''); // Aktuelle Adresse löschen + setMediaStream(null); // Alten Stream zurücksetzen + setIsCameraViewVisible(false); // Ansicht vorerst ausblenden + + // Vorhandene Tracks stoppen, falls vorhanden + if (videoRef.current && videoRef.current.srcObject) { + const oldStream = videoRef.current.srcObject as MediaStream; + oldStream.getTracks().forEach(track => track.stop()); + videoRef.current.srcObject = null; + } + + try { + // Zuerst prüfen, ob Browser getUserMedia unterstützt + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + console.error('Browser unterstützt getUserMedia nicht!'); + throw new Error('Ihr Browser unterstützt die Kamerafunktion nicht. Bitte versuchen Sie es mit einem aktuellen Browser wie Chrome, Firefox oder Safari.'); + } + + console.log("Requesting camera stream with environment facing mode..."); + // Zuerst mit Rückkamera versuchen + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', // Bevorzugt Rückkamera für Mobilgeräte + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }); + + console.log("Stream obtained with environment camera:", stream); + setMediaStream(stream); + setIsCameraViewVisible(true); + } catch (envError) { + console.warn("Couldn't access environment camera, trying fallback to any camera:", envError); + // Fallback: Irgendeine verfügbare Kamera verwenden + const stream = await navigator.mediaDevices.getUserMedia({ + video: true + }); + + console.log("Stream obtained with fallback camera:", stream); + setMediaStream(stream); + setIsCameraViewVisible(true); + } + } catch (err) { + console.error('Kamerafehler:', err); + + if (err instanceof Error) { + // Detaillierte Fehlermeldungen basierend auf Error.name + if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { + setError('Kamerazugriff verweigert. Bitte erteilen Sie die Berechtigung und versuchen Sie es erneut.'); + } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { + setError('Keine Kamera gefunden. Bitte stellen Sie sicher, dass eine Kamera verbunden ist.'); + } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { + setError('Die Kamera ist möglicherweise bereits von einer anderen Anwendung in Gebrauch.'); + } else if (err.name === 'OverconstrainedError') { + setError('Die angeforderten Kameraeinstellungen werden nicht unterstützt.'); + } else { + setError(`Kamerafehler: ${err.message}`); + } + } else { + setError(messagesData?.newRoute?.cameraError || 'Fehler beim Starten der Kamera'); + } + + setMediaStream(null); + setIsCameraViewVisible(false); + } finally { + setLoading(false); + } + }; + + // Funktion zum Scannen des Dokuments (manuell oder automatisch) + const scanDocument = async (isAuto = false) => { + // Entry log + console.log(`[SCAN] scanDocument called. isAuto: ${isAuto}, loading: ${loading}, worker ready: ${!!workerRef.current}`); + + // Check prerequisites + if (!videoRef.current || !canvasRef.current || !workerRef.current || loading) { + console.log('[SCAN] Exiting: Scanner nicht bereit oder beschäftigt.', { + hasVideo: !!videoRef.current, + hasCanvas: !!canvasRef.current, + hasWorker: !!workerRef.current, + isLoading: loading + }); + + if (!isAuto) { + setError('Scanner ist nicht bereit. Bitte starten Sie die Kamera und versuchen Sie es erneut.'); + } + return; + } + + const video = videoRef.current; + + // Check video readiness + if (video.readyState < video.HAVE_METADATA || video.videoWidth === 0 || video.videoHeight === 0) { + console.log('[SCAN] Exiting: Videostream noch nicht bereit oder Dimensionen ungültig.', { + readyState: video.readyState, + width: video.videoWidth, + height: video.videoHeight + }); + + if (!isAuto) { + setError('Videostream nicht bereit. Bitte warten Sie einen Moment oder versuchen Sie, die Kamera neu zu starten.'); + } + return; + } + + console.log("[SCAN] Proceeding with scan..."); + setLoading(true); + setError(null); + setScanStatus(messagesData?.newRoute?.scanning || 'Scannen...'); + + try { + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Canvas context nicht verfügbar'); + } + + // Capture video frame to canvas + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + console.log('[SCAN] Video frame captured to canvas'); + + // Get guide rectangle for cropping + const guideRect = drawGuideLine(canvasRef); + + // Initialize variables for OCR + let imageDataUrl = ''; + let rawOcrText = ''; + + // Process image with guide rectangle if available + if (guideRect) { + console.log("[SCAN] Processing cropped image with guide frame"); + + // Create temporary canvas for the cropped area + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = guideRect.width; + tempCanvas.height = guideRect.height; + const tempCtx = tempCanvas.getContext('2d'); + + if (!tempCtx) { + throw new Error('Temporary canvas context not available'); + } + + // Apply image processing to improve OCR + tempCtx.filter = 'contrast(150%) brightness(110%) grayscale(1)'; + + // Copy cropped area to temp canvas + tempCtx.drawImage( + canvas, + guideRect.x, guideRect.y, guideRect.width, guideRect.height, + 0, 0, guideRect.width, guideRect.height + ); + + tempCtx.filter = 'none'; + imageDataUrl = tempCanvas.toDataURL('image/png', 1.0); + } else { + // If no guide rectangle, use the full frame + console.log("[SCAN] No guide rectangle, using full image"); + imageDataUrl = canvas.toDataURL('image/png', 1.0); + } + + // Perform OCR + console.log("[SCAN] Starting OCR recognition..."); + const worker = workerRef.current; + const { data: { text } } = await worker.recognize(imageDataUrl); + rawOcrText = text; + console.log('[SCAN] OCR raw result:', rawOcrText); + + // Exit if OCR returned empty text + if (!rawOcrText.trim()) { + throw new Error('OCR returned empty text'); + } + + // Pre-process the OCR text + const processedText = await extractAddress(rawOcrText); + console.log('[SCAN] Processed address:', processedText); + + if (!processedText) { + throw new Error('Could not extract address from OCR result'); + } + + // Validate address + console.log("[SCAN] Validating extracted address..."); + const validatedAddress = await validateWithAPI( + processedText, + setSuggestedAddresses, + setShowSuggestionModal, + setCurrentAddress + ); + + // Handle validation result + if (validatedAddress) { + console.log("[SCAN] Successfully validated address:", validatedAddress); + setCurrentAddress(validatedAddress); + setScanStatus(messagesData?.newRoute?.addressFound || 'Adresse gefunden. Bitte überprüfen und "Hinzufügen" klicken.'); + + // Stop auto-scan if enabled + if (isAutoScanEnabled) { + stopAutoScan(); + } + } else if (showSuggestionModal) { + // If showing suggestions modal, status is handled by the modal + console.log("[SCAN] Showing address suggestions modal"); + setScanStatus(messagesData?.newRoute?.chooseSuggestion || 'Bitte wählen Sie eine Adresse aus den Vorschlägen.'); + } else { + // If no valid address and no suggestions, let user edit the text + console.log("[SCAN] No validated address, showing OCR text for editing"); + setCurrentAddress(processedText); + setScanStatus(messagesData?.newRoute?.checkAddress || 'Adresse erkannt, bitte überprüfen und bearbeiten.'); + } + } catch (error) { + console.error('[SCAN] Error during scanning:', error); + + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + console.log('[SCAN] Error details:', errorMessage); + + // Provide user-friendly error messages + if (errorMessage.includes('empty text')) { + setError('Keine Adresse im Bild erkannt. Bitte positionieren Sie die Adresse innerhalb des markierten Bereichs.'); + } else { + setError(messagesData?.newRoute?.scanError || 'Fehler beim Scannen des Dokuments'); + } + + setScanStatus(messagesData?.newRoute?.scanErrorStatus || 'Fehler aufgetreten'); + } finally { + setLoading(false); + } + }; + + // Funktion zum Hinzufügen einer Adresse zur Liste + const addAddress = () => { + if (currentAddress.trim()) { + // Extract only the essential address components + const addressParts = currentAddress.split(/[\n,]/).map(part => part.trim()).filter(part => part.length > 0); + + // Identify street and postal/city parts + let streetPart = ''; + let postalCityPart = ''; + + // Look for street and postal/city in each part + for (const part of addressParts) { + const lowerPart = part.toLowerCase(); + + // Street with number + if (/strasse|str\.|weg|platz|gasse/i.test(lowerPart) && /\d+/.test(part)) { + streetPart = part.replace(/\s+/g, ' ').trim(); + } + + // Postal code with city + if (/\b\d{4}\b/.test(part)) { + postalCityPart = part.replace(/\s+/g, ' ').trim(); + } + } + + // If we couldn't find both parts, look in the full text + if (!streetPart || !postalCityPart) { + const fullText = addressParts.join(' '); + + // Try to extract street + if (!streetPart) { + const streetMatch = fullText.match(/([a-zäöüß]+(?:strasse|str\.|weg|platz|gasse))\s+(\d+)/i); + if (streetMatch) { + streetPart = streetMatch[0].trim(); + } + } + + // Try to extract postal code + if (!postalCityPart) { + const postalMatch = fullText.match(/\b(\d{4})\s+([a-zäöüß]+)\b/i); + if (postalMatch) { + postalCityPart = postalMatch[0].trim(); + } + } + } + + // Format address with only essential parts + let formattedAddress = ''; + + if (streetPart && postalCityPart) { + formattedAddress = `${streetPart}, ${postalCityPart}`; + } else if (streetPart) { + formattedAddress = streetPart; + } else if (postalCityPart) { + formattedAddress = postalCityPart; + } else { + // If we couldn't extract specific components, use the original cleaned text + // But try to remove any name parts (typically in the first line) + if (addressParts.length > 1) { + // Check if first part looks like a name + const firstPart = addressParts[0].toLowerCase(); + if (!/\d/.test(firstPart) && !/strasse|str\.|weg|platz|gasse/i.test(firstPart)) { + // Skip the first part which likely contains the name + formattedAddress = addressParts.slice(1).join(', '); + } else { + formattedAddress = addressParts.join(', '); + } + } else { + formattedAddress = currentAddress.trim(); + } + } + + // Final cleanup of formatted address + formattedAddress = formattedAddress + .replace(/,\s*,/g, ',') // Remove double commas + .replace(/\s+,/g, ',') // Remove spaces before commas + .replace(/,(\S)/g, ', $1') // Ensure space after commas + .replace(/(\d{4})\s+([A-Za-zäöüÄÖÜ]+)/g, '$1 $2') // Fix spacing in postal code + city + .replace(/l(\d+)/gi, '1$1') // Replace lowercase L with 1 when preceding digits + .replace(/l\s+(\d+)/gi, '1 $1'); // Replace lowercase L with 1 when before digits + + console.log('[ADD] Adding essential address:', formattedAddress); + + // Check if this address already exists + if (!addresses.includes(formattedAddress)) { + // Add to addresses list + setAddresses([...addresses, formattedAddress]); + + // Reset inputs + setCurrentAddress(''); + setSearchTerm(''); + setSuggestedAddresses([]); + setError(null); + setScanStatus(messagesData?.newRoute?.addressAdded || 'Adresse hinzugefügt'); + + // Clear success message after a delay + setTimeout(() => { + if (scanStatus === messagesData?.newRoute?.addressAdded || scanStatus === 'Adresse hinzugefügt') { + setScanStatus(''); + } + }, 2000); + } else { + // Address already exists in the list + setError(messagesData?.newRoute?.duplicateAddress || 'Diese Adresse ist bereits in der Liste'); + setTimeout(() => setError(null), 3000); + } + } + }; + + // Funktion zur Optimierung und Speicherung der Route + const optimizeAndSaveRoute = async () => { + if (addresses.length < 2) { + setError(messagesData?.newRoute?.minAddressError || 'Mindestens 2 Adressen erforderlich') + return + } + + try { + setIsOptimizing(true) + setScanStatus(messagesData?.newRoute?.optimizing || 'Route wird optimiert...') + setError(null) + + // Route erstellen (API-Aufruf) + const createResponse = await fetch('/api/routes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ addresses }) + }); + const createResult = await createResponse.json(); + + if (!createResponse.ok || !createResult.success || !createResult.data) { + throw new Error(createResult.error || messagesData?.newRoute?.saveError || 'Route konnte nicht gespeichert werden'); + } + + const routeId = createResult.data.id; + setScanStatus(messagesData?.newRoute?.routeCreated || 'Route erstellt, wird optimiert...'); + + // Route optimieren (API-Aufruf) + // Hinweis: Die Optimierung erfolgt serverseitig durch den vorherigen API-Aufruf `/api/routes/optimize`, + // welcher innerhalb der `/api/routes` POST-Logik aufgerufen werden sollte ODER hier separat. + // Aktuell scheint die Optimierung implizit beim Erstellen zu erfolgen oder der Client erwartet das Ergebnis. + // Wir nehmen an, dass `createResult.data` die optimierte Route enthält oder wir auf die Route-Detailseite weiterleiten. + + // Annahme: Die API gibt die optimierten Details zurück oder setzt den Status. + // Wir leiten zur Detailseite weiter, wo die optimierte Route angezeigt wird. + console.log('Route erfolgreich erstellt/optimiert. Route ID:', routeId) + setScanStatus(messagesData?.newRoute?.routeSaved || 'Route erfolgreich gespeichert'); + + // Zur Routen-Detailseite oder Dashboard weiterleiten + router.push(`/dashboard/routes/${routeId}`); + // Optional: Kurze Erfolgsmeldung anzeigen, bevor weitergeleitet wird + // await new Promise(resolve => setTimeout(resolve, 1500)); + + } catch (error) { + console.error('Routenoptimierungs-/Speicherfehler:', error) + setError(error instanceof Error ? error.message : messagesData?.newRoute?.generalError || 'Fehler während der Routenverarbeitung') + setScanStatus(messagesData?.newRoute?.scanErrorStatus || 'Fehler aufgetreten') + } finally { + setIsOptimizing(false) + } + }; + + // Ladezustand für Übersetzungen + if (!messagesData) { + return ( +
+ {/* Verwende Übersetzung für Ladeanzeige */} +

{messagesData?.common?.loading || 'Laden...'}

+
+ ); + } + + // Haupt-JSX der Komponente + return ( +
+ {/* Optimierungs-Overlay */} + {isOptimizing && ( +
+
+
+

{messagesData?.newRoute?.optimizing}

+

{scanStatus}

+
+
+ )} + + {/* Adressvorschlagsmodal - neu gestaltet */} + {showSuggestionModal && ( +
+
+

+ {messagesData?.newRoute?.addressSuggestions || 'Adressvorschläge'} +

+

+ {messagesData?.newRoute?.chooseAddress || 'Bitte wählen Sie die korrekte Adresse aus:'} +

+ +
+ {suggestedAddresses.map((addr, idx) => ( + + ))} +
+ +
+ + +
+
+
+ )} + +
+
+ {/* Seitentitel */} +

{messagesData?.newRoute?.title}

+ {/* Zurück-Button */} + + {messagesData?.common?.back} + +
+ +
+
+ {/* Eingabemodus-Auswahl */} +
+ + +
+ + {/* Kamera-Scan-Ansicht */} + {!isManualEntry ? ( + <> + {/* Kamerabild & Canvas - Conditionally render this block */} + {isCameraViewVisible && ( +
+
+ )} + + {/* Statusanzeige für Scan */} + {scanStatus && ( +
+ {scanStatus} +
+ )} + + {/* Kamerasteuerungsbuttons */} +
+ + + +
+ + {/* Bearbeitung des gescannten Adresstextes */} +
+