Files
gitlab-instance-0a899031_cn…/app/[locale]/city/[slug]/page.tsx

405 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { use, useEffect, useMemo, useState } from "react";
import { Link } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import NewsletterSubscribe from "@/app/components/NewsletterSubscribe";
import { apiFetch } from "@/app/lib/api-client";
interface CityGuide {
slug: string;
nameZh: string;
nameEn: string;
emoji: string;
descriptionZh: string;
descriptionEn: string;
costOfLiving: {
item: string;
priceZh: string;
priceEn: string;
}[];
coworking: {
name: string;
address: string;
price: string;
rating: number;
}[];
neighborhoods: {
name: string;
pros: string;
cons: string;
}[];
tips: string[];
}
const CITY_GUIDES: CityGuide[] = [
{
slug: "dali",
nameZh: "大理",
nameEn: "Dali",
emoji: "🏯",
descriptionZh: "大理是中国最受欢迎的的数字游民目的地之一,以其悠闲的生活方式、美丽的自然风光和实惠的生活成本著称。",
descriptionEn: "Dali is one of China's most popular digital nomad destinations, known for its laid-back lifestyle, beautiful scenery, and affordable cost of living.",
costOfLiving: [
{ item: "租房 (单间)", priceZh: "¥800-1500/月", priceEn: "¥800-1500/month" },
{ item: "共享办公", priceZh: "¥300-500/月", priceEn: "¥300-500/month" },
{ item: "餐饮", priceZh: "¥30-50/天", priceEn: "¥30-50/day" },
{ item: "咖啡", priceZh: "¥15-30/杯", priceEn: "¥15-30/cup" },
],
coworking: [
{ name: "空岛空间", address: "大理古城人民路", price: "¥400/月", rating: 4.8 },
{ name: "Dreamland", address: "大理古城博爱路", price: "¥350/月", rating: 4.6 },
{ name: "柴米多", address: "大理古城叶榆路", price: "¥500/月", rating: 4.7 },
],
neighborhoods: [
{ name: "大理古城", pros: "配套设施齐全,咖啡馆多", cons: "游客较多" },
{ name: "海东", pros: "安静,海景房", cons: "交通不便" },
{ name: "下关市区", pros: "生活便利,性价比高", cons: "缺乏氛围" },
],
tips: [
"推荐10月-次年4月前往天气最佳",
"古城内租房建议提前实地看房",
"办理民宿短租备案更合规",
],
},
{
slug: "chengdu",
nameZh: "成都",
nameEn: "Chengdu",
emoji: "🐼",
descriptionZh: "成都被誉为天府之国,是中国西部的科技中心,也是一座充满生活气息的城市,非常适合数字游民。",
descriptionEn: "Chengdu, known as the 'Land of Abundance', is the tech hub of western China and a city full of life, perfect for digital nomads.",
costOfLiving: [
{ item: "租房 (单间)", priceZh: "¥1200-2500/月", priceEn: "¥1200-2500/month" },
{ item: "共享办公", priceZh: "¥400-800/月", priceEn: "¥400-800/month" },
{ item: "餐饮", priceZh: "¥40-60/天", priceEn: "¥40-60/day" },
{ item: "咖啡", priceZh: "¥20-35/杯", priceEn: "¥20-35/cup" },
],
coworking: [
{ name: "Workfrom成都", address: "高新区天府大道", price: "¥600/月", rating: 4.7 },
{ name: "Feng Coffee", address: "锦江区春熙路", price: "¥500/月", rating: 4.5 },
{ name: "方糖小镇", address: "武侯区科华北路", price: "¥450/月", rating: 4.6 },
],
neighborhoods: [
{ name: "高新区", pros: "科技公司多,年轻人多", cons: "房价较高" },
{ name: "锦江区", pros: "美食众多,生活便利", cons: "较拥挤" },
{ name: "玉林路", pros: "文艺氛围,咖啡馆多", cons: "老小区" },
],
tips: [
"推荐3-5月或9-11月前往",
"高新区互联网公司聚集,便于社交",
"美食之都,餐饮选择丰富",
],
},
{
slug: "shenzhen",
nameZh: "深圳",
nameEn: "Shenzhen",
emoji: "🌃",
descriptionZh: "深圳是中国最具活力的科技创新城市,毗邻香港,是年轻创业者的天堂。",
descriptionEn: "Shenzhen is China's most dynamic tech innovation city, adjacent to Hong Kong, a paradise for young entrepreneurs.",
costOfLiving: [
{ item: "租房 (单间)", priceZh: "¥1500-3500/月", priceEn: "¥1500-3500/month" },
{ item: "共享办公", priceZh: "¥600-1200/月", priceEn: "¥600-1200/month" },
{ item: "餐饮", priceZh: "¥50-80/天", priceEn: "¥50-80/day" },
{ item: "咖啡", priceZh: "¥25-40/杯", priceEn: "¥25-40/cup" },
],
coworking: [
{ name: "WeWork", address: "南山区科技园", price: "¥1000/月", rating: 4.8 },
{ name: "思微SimplyWork", address: "南山区科兴科学园", price: "¥800/月", rating: 4.6 },
{ name: "柴火创客空间", address: "南山区高新南七道", price: "¥700/月", rating: 4.7 },
],
neighborhoods: [
{ name: "南山区", pros: "科技公司集中,氛围好", cons: "房价最高" },
{ name: "福田区", pros: "市中心,交通便利", cons: "缺乏创业氛围" },
{ name: "龙华区", pros: "性价比高", cons: "较偏远" },
],
tips: [
"全年气候宜人,随时适合前往",
"创业氛围浓厚,便于资源对接",
"离香港近,可一日往返",
],
},
{
slug: "hangzhou",
nameZh: "杭州",
nameEn: "Hangzhou",
emoji: "⛲",
descriptionZh: "杭州是中国最美的城市之一,阿里巴巴总部所在地,数字经济发达。",
descriptionEn: "Hangzhou is one of China's most beautiful cities, home to Alibaba, with a developed digital economy.",
costOfLiving: [
{ item: "租房 (单间)", priceZh: "¥1400-2800/月", priceEn: "¥1400-2800/month" },
{ item: "共享办公", priceZh: "¥500-900/月", priceEn: "¥500-900/month" },
{ item: "餐饮", priceZh: "¥45-70/天", priceEn: "¥45-70/day" },
{ item: "咖啡", priceZh: "¥22-38/杯", priceEn: "¥22-38/cup" },
],
coworking: [
{ name: "Binggo咖啡", address: "西湖区文二路", price: "¥600/月", rating: 4.6 },
{ name: "创业邦", address: "滨江区江南大道", price: "¥700/月", rating: 4.5 },
{ name: "福云咖啡", address: "西湖区教工路", price: "¥550/月", rating: 4.4 },
],
neighborhoods: [
{ name: "滨江区", pros: "阿里附近,创业氛围浓", cons: "房价较高" },
{ name: "西湖区", pros: "环境优美,生活便利", cons: "游客较多" },
{ name: "余杭区", pros: "性价比高,阿里新总部", cons: "配套不全" },
],
tips: [
"推荐4-5月前往西湖景色最美",
"阿里巴巴带动数字经济发展",
"生活节奏适中,工作生活平衡好",
],
},
];
type CityApiRecord = {
name?: string;
nameEn?: string;
icon?: string;
description?: string;
costPerMonth?: number;
internetSpeed?: number;
nomadsNow?: number;
tags?: string[];
};
type CityDetailRecord = {
guide?: {
summary?: string;
arrivalChecklist?: string[];
workSetup?: string;
bestFor?: string[];
};
cost?: {
breakdown?: Array<{ label?: string; amount?: number }>;
monthlyTotal?: number;
tip?: string;
};
pros?: {
pros?: string[];
cons?: string[];
};
};
function mergeCityGuide(slug: string, apiCity?: CityApiRecord | null, detail?: CityDetailRecord | null): CityGuide | null {
const fallback = CITY_GUIDES.find((c) => c.slug === slug);
if (!apiCity && !fallback) return null;
const breakdown = detail?.cost?.breakdown || [];
const costOfLiving =
breakdown.length > 0
? breakdown.map((item) => ({
item: String(item.label || "项目"),
priceZh: item.amount ? `¥${Number(item.amount).toLocaleString("zh-CN")}/月` : "—",
priceEn: item.amount ? `¥${Number(item.amount).toLocaleString("en-US")}/month` : "—",
}))
: fallback?.costOfLiving || [];
const tips =
(detail?.guide?.arrivalChecklist?.length ? detail.guide.arrivalChecklist : null) ||
fallback?.tips ||
[];
return {
slug,
nameZh: apiCity?.name || fallback?.nameZh || slug,
nameEn: apiCity?.nameEn || fallback?.nameEn || slug,
emoji: apiCity?.icon || fallback?.emoji || "🏙️",
descriptionZh:
detail?.guide?.summary ||
apiCity?.description ||
fallback?.descriptionZh ||
"",
descriptionEn: fallback?.descriptionEn || "",
costOfLiving,
coworking: fallback?.coworking || [],
neighborhoods:
detail?.pros?.pros?.length || detail?.pros?.cons?.length
? [
...(detail?.pros?.pros || []).slice(0, 2).map((pros, index) => ({
name: `优势 ${index + 1}`,
pros,
cons: detail?.pros?.cons?.[index] || "—",
})),
]
: fallback?.neighborhoods || [],
tips,
};
}
export default function CityGuidePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const [loading, setLoading] = useState(true);
const [apiError, setApiError] = useState(false);
const [apiCity, setApiCity] = useState<CityApiRecord | null>(null);
const [apiDetail, setApiDetail] = useState<CityDetailRecord | null>(null);
useEffect(() => {
let cancelled = false;
apiFetch<{
city?: CityApiRecord;
detail?: CityDetailRecord | null;
}>(`/api/cities/${slug}`)
.then((data) => {
if (cancelled) return;
setApiCity(data.city || null);
setApiDetail(data.detail || null);
setApiError(false);
})
.catch(() => {
if (!cancelled) setApiError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [slug]);
const city = useMemo(
() => mergeCityGuide(slug, apiCity, apiDetail),
[slug, apiCity, apiDetail]
);
if (loading) {
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="max-w-[1200px] mx-auto px-4 sm:px-6 py-6 sm:py-8">
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
<div className="mt-4 overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-gray-900">
<div className="h-40 animate-pulse bg-gray-200 dark:bg-gray-800" />
<div className="space-y-4 p-6 sm:p-8">
<div className="h-6 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-800/80" />
<div className="h-4 w-5/6 animate-pulse rounded bg-gray-100 dark:bg-gray-800/80" />
</div>
</div>
</main>
<Footer />
</div>
);
}
if (!city) {
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="max-w-[1200px] mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"></h1>
<Link href="/" className="text-[#ff4d4f] mt-4 inline-block"></Link>
</main>
<Footer />
</div>
);
}
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="max-w-[1200px] mx-auto px-4 sm:px-6 py-6 sm:py-8">
<Link href="/" className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-[#ff4d4f] mb-4">
</Link>
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden shadow-lg border border-gray-100 dark:border-gray-800">
<div className="h-32 sm:h-48 bg-gradient-to-r from-purple-500 to-pink-500 flex items-end p-6 sm:p-8">
<span className="text-5xl sm:text-7xl mb-2">{city.emoji}</span>
<h1 className="text-3xl sm:text-4xl font-bold text-white ml-4">{city.nameZh}</h1>
</div>
<div className="p-6 sm:p-8">
{apiError && (
<p className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
</p>
)}
<p className="text-gray-600 dark:text-gray-400 text-base sm:text-lg mb-8">
{city.descriptionZh}
</p>
<section className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
💰
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{city.costOfLiving.map((item, i) => (
<div key={i} className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<span className="text-gray-600 dark:text-gray-400">{item.item}</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{item.priceZh}</span>
</div>
))}
</div>
</section>
<section className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
💻
</h2>
<div className="space-y-3">
{city.coworking.map((cw, i) => (
<div key={i} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg gap-2">
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">{cw.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{cw.address}</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[#ff4d4f] font-medium">{cw.price}</span>
<span className="text-amber-500"> {cw.rating}</span>
</div>
</div>
))}
</div>
</section>
<section className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
🏠
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{city.neighborhoods.map((n, i) => (
<div key={i} className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-2">{n.name}</div>
<div className="text-sm text-green-600 dark:text-green-400 mb-1"> {n.pros}</div>
<div className="text-sm text-red-500 dark:text-red-400"> {n.cons}</div>
</div>
))}
</div>
</section>
<section className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
💡 Tips
</h2>
<ul className="space-y-2">
{city.tips.map((tip, i) => (
<li key={i} className="flex items-start gap-2 text-gray-600 dark:text-gray-400">
<span className="text-[#ff4d4f]"></span>
{tip}
</li>
))}
</ul>
</section>
<NewsletterSubscribe locale="zh" citySlug={slug} list="city" className="mb-6" />
<div className="flex flex-col sm:flex-row gap-3">
<Link
href="/join"
className="flex-1 text-center bg-[#ff4d4f] text-white py-3 px-6 rounded-full font-medium hover:bg-[#ff7a45] transition-colors"
>
</Link>
<Link
href="/meetups"
className="flex-1 text-center bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 py-3 px-6 rounded-full font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
线
</Link>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}