feat: geo-device-service, Weather по геопозиции, Discover вкладка GooSeek
Geo Device Service: - Новый сервис определения геопозиции, устройства, браузера - geoip-lite, ua-parser-js, CORS - GET/POST /api/context Frontend: - /api/geo-context — прокси к geo-device, fallback при недоступности - geoDevice.ts — fetchContextWithClient, fetchContextWithGeolocation - Weather: геопозиция через geo-device + GeoJS + ipwhois fallback - Weather API: поддержка city (геокодинг Open-Meteo) - Discover: вкладка GooSeek по умолчанию Документация: - MICROSERVICES.md — секция 3.9 Geo Device Service Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
78
apps/frontend/src/app/api/geo-context/route.ts
Normal file
78
apps/frontend/src/app/api/geo-context/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GEO_DEVICE_URL =
|
||||
process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
|
||||
|
||||
const fallbackContext = (userAgent: string) => ({
|
||||
geo: null,
|
||||
device: { type: 'unknown' as const },
|
||||
browser: { name: 'Unknown', version: '', full: '' },
|
||||
os: { name: 'Unknown', version: '', full: '' },
|
||||
client: {},
|
||||
userAgent,
|
||||
requestedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const realIP = req.headers.get('x-real-ip');
|
||||
|
||||
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
|
||||
headers: {
|
||||
'x-forwarded-for': forwarded ?? '',
|
||||
'x-real-ip': realIP ?? '',
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
'accept-language': req.headers.get('accept-language') ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const realIP = req.headers.get('x-real-ip');
|
||||
|
||||
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-forwarded-for': forwarded ?? '',
|
||||
'x-real-ip': realIP ?? '',
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
'accept-language': req.headers.get('accept-language') ?? '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
fallbackContext(req.headers.get('user-agent') ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,50 @@
|
||||
const geocodeCity = async (city: string): Promise<{ lat: number; lng: number; name: string } | null> => {
|
||||
const res = await fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`,
|
||||
);
|
||||
const data = await res.json();
|
||||
const result = data.results?.[0];
|
||||
if (!result?.latitude || !result?.longitude) return null;
|
||||
return {
|
||||
lat: result.latitude,
|
||||
lng: result.longitude,
|
||||
name: result.name ?? city,
|
||||
};
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
try {
|
||||
const body: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
city?: string;
|
||||
measureUnit: 'Imperial' | 'Metric';
|
||||
} = await req.json();
|
||||
|
||||
if (!body.lat || !body.lng) {
|
||||
let lat: number;
|
||||
let lng: number;
|
||||
let cityName: string | undefined = body.city;
|
||||
|
||||
if (body.lat != null && body.lng != null) {
|
||||
lat = body.lat;
|
||||
lng = body.lng;
|
||||
} else if (body.city?.trim()) {
|
||||
const geo = await geocodeCity(body.city.trim());
|
||||
if (!geo) {
|
||||
return Response.json({ message: 'City not found.' }, { status: 404 });
|
||||
}
|
||||
lat = geo.lat;
|
||||
lng = geo.lng;
|
||||
cityName = geo.name;
|
||||
} else {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Invalid request.',
|
||||
},
|
||||
{ message: 'Invalid request. Provide lat/lng or city.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
|
||||
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
|
||||
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
|
||||
);
|
||||
@@ -159,7 +187,7 @@ export const POST = async (req: Request) => {
|
||||
break;
|
||||
}
|
||||
|
||||
return Response.json(weather);
|
||||
return Response.json({ ...weather, city: cityName });
|
||||
} catch (err) {
|
||||
console.error('An error occurred while getting home widgets', err);
|
||||
return Response.json(
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface Discover {
|
||||
}
|
||||
|
||||
const topics: { key: string; display: string }[] = [
|
||||
{
|
||||
display: 'GooSeek',
|
||||
key: 'gooseek',
|
||||
},
|
||||
{
|
||||
display: 'Tech & Science',
|
||||
key: 'tech',
|
||||
@@ -39,7 +43,7 @@ const topics: { key: string; display: string }[] = [
|
||||
|
||||
const Page = () => {
|
||||
const [discover, setDiscover] = useState<Discover[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
|
||||
const [setupRequired, setSetupRequired] = useState(false);
|
||||
|
||||
@@ -78,6 +82,12 @@ const Page = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTopic === 'gooseek') {
|
||||
setLoading(false);
|
||||
setDiscover(null);
|
||||
setSetupRequired(false);
|
||||
return;
|
||||
}
|
||||
fetchArticles(activeTopic);
|
||||
}, [activeTopic]);
|
||||
|
||||
@@ -133,6 +143,8 @@ const Page = () => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : activeTopic === 'gooseek' ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4" />
|
||||
) : setupRequired ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center">
|
||||
<div className="rounded-full p-4 bg-cyan-300/10 dark:bg-cyan-300/5">
|
||||
|
||||
Reference in New Issue
Block a user