405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|