diff --git a/apps/web/migrations/0002_fresh_gladiator.sql b/apps/web/migrations/0002_fresh_gladiator.sql
new file mode 100644
index 000000000..c81872eca
--- /dev/null
+++ b/apps/web/migrations/0002_fresh_gladiator.sql
@@ -0,0 +1,47 @@
+CREATE TABLE "favorites" (
+ "id" uuid PRIMARY KEY NOT NULL,
+ "user_id" text NOT NULL,
+ "recommendation_id" uuid NOT NULL,
+ "created_at" timestamp NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "favorites" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "recommendations" (
+ "id" uuid PRIMARY KEY NOT NULL,
+ "title" text NOT NULL,
+ "description" text NOT NULL,
+ "category" text NOT NULL,
+ "target_audience" text NOT NULL,
+ "location" text NOT NULL,
+ "country" text NOT NULL,
+ "city" text NOT NULL,
+ "address" text,
+ "image_url" text,
+ "rating" real DEFAULT 0,
+ "price_level" integer DEFAULT 2,
+ "tags" text[],
+ "website" text,
+ "phone" text,
+ "featured" boolean DEFAULT false,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "recommendations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "reviews" (
+ "id" uuid PRIMARY KEY NOT NULL,
+ "user_id" text NOT NULL,
+ "recommendation_id" uuid NOT NULL,
+ "rating" integer NOT NULL,
+ "comment" text,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "reviews" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "preference_mode" text DEFAULT 'straight' NOT NULL;--> statement-breakpoint
+ALTER TABLE "favorites" ADD CONSTRAINT "favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "favorites" ADD CONSTRAINT "favorites_recommendation_id_recommendations_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "reviews" ADD CONSTRAINT "reviews_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "reviews" ADD CONSTRAINT "reviews_recommendation_id_recommendations_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendations"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/apps/web/migrations/meta/0002_snapshot.json b/apps/web/migrations/meta/0002_snapshot.json
new file mode 100644
index 000000000..acd422db4
--- /dev/null
+++ b/apps/web/migrations/meta/0002_snapshot.json
@@ -0,0 +1,636 @@
+{
+ "id": "ec448a61-e4b5-492e-a48e-27524c542b3c",
+ "prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.favorites": {
+ "name": "favorites",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recommendation_id": {
+ "name": "recommendation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "favorites_user_id_users_id_fk": {
+ "name": "favorites_user_id_users_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "favorites_recommendation_id_recommendations_id_fk": {
+ "name": "favorites_recommendation_id_recommendations_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "recommendations",
+ "columnsFrom": [
+ "recommendation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.recommendations": {
+ "name": "recommendations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_audience": {
+ "name": "target_audience",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "city": {
+ "name": "city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "price_level": {
+ "name": "price_level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 2
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "featured": {
+ "name": "featured",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.reviews": {
+ "name": "reviews",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recommendation_id": {
+ "name": "recommendation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reviews_user_id_users_id_fk": {
+ "name": "reviews_user_id_users_id_fk",
+ "tableFrom": "reviews",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reviews_recommendation_id_recommendations_id_fk": {
+ "name": "reviews_recommendation_id_recommendations_id_fk",
+ "tableFrom": "reviews",
+ "tableTo": "recommendations",
+ "columnsFrom": [
+ "recommendation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preference_mode": {
+ "name": "preference_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'straight'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.waitlist": {
+ "name": "waitlist",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "waitlist_email_unique": {
+ "name": "waitlist_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json
index 9cbceb13c..16e9e1fff 100644
--- a/apps/web/migrations/meta/_journal.json
+++ b/apps/web/migrations/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1750689835736,
"tag": "0001_tricky_jackpot",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1769081261564,
+ "tag": "0002_fresh_gladiator",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 28a91e093..e4fc2156b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -56,8 +56,9 @@
"zustand": "^5.0.2"
},
"devDependencies": {
- "@types/pg": "^8.15.4",
"@types/bun": "latest",
+ "@types/node": "^25.0.10",
+ "@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 743084709..b231b512c 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,18 +1,158 @@
-import { Hero } from "@/components/landing/hero";
+"use client";
+
+import { usePreferenceStore } from "@/stores/preference-store";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
-import { getWaitlistCount } from "@/lib/waitlist";
+import {
+ Plane,
+ Music,
+ UtensilsCrossed,
+ ShoppingBag,
+ Heart,
+ MapPin,
+ Star
+} from "lucide-react";
+import Link from "next/link";
-// Force dynamic rendering so waitlist count updates in real-time
-export const dynamic = "force-dynamic";
+export default function Home() {
+ const { mode, toggleMode } = usePreferenceStore();
-export default async function Home() {
- const signupCount = await getWaitlistCount();
+ const categories = [
+ {
+ title: "旅行",
+ description: "探索全球精选旅行目的地",
+ icon: Plane,
+ href: "/recommendations/travel",
+ color: "from-blue-500 to-cyan-500",
+ },
+ {
+ title: "娱乐",
+ description: "发现最热门的娱乐场所",
+ icon: Music,
+ href: "/recommendations/entertainment",
+ color: "from-purple-500 to-pink-500",
+ },
+ {
+ title: "美食",
+ description: "品尝全球顶级餐厅美食",
+ icon: UtensilsCrossed,
+ href: "/recommendations/dining",
+ color: "from-orange-500 to-red-500",
+ },
+ {
+ title: "购物",
+ description: "精选购物目的地推荐",
+ icon: ShoppingBag,
+ href: "/recommendations/shopping",
+ color: "from-green-500 to-emerald-500",
+ },
+ ];
return (
-
+
-
+
+
+ {/* Hero Section */}
+
+
+ 全球男士生活指南
+
+
+ 发现全球最佳旅行、娱乐、美食和购物推荐
+
+
+ {/* Mode Toggle */}
+
+ 偏好模式:
+
+
+ {mode === "straight" ? "直男模式" : "同志模式"}
+
+
+
+ {/* Quick Stats */}
+
+
+
+ 200+ 城市
+
+
+
+ 10000+ 推荐
+
+
+
+ 精选内容
+
+
+
+
+ {/* Category Cards */}
+
+ {categories.map((category) => {
+ const Icon = category.icon;
+ return (
+
+
+
+
+
+
+
+ {category.title}
+
+
+ {category.description}
+
+
+
+ );
+ })}
+
+
+ {/* Featured Section */}
+
+
开始探索
+
+ 根据你的偏好,为你量身定制的全球推荐
+
+
+ 立即开始
+
+
+
+
);
diff --git a/apps/web/src/app/recommendations/dining/page.tsx b/apps/web/src/app/recommendations/dining/page.tsx
new file mode 100644
index 000000000..84e668976
--- /dev/null
+++ b/apps/web/src/app/recommendations/dining/page.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { usePreferenceStore } from "@/stores/preference-store";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { RecommendationCard } from "@/components/recommendations/recommendation-card";
+import { Filters } from "@/components/recommendations/filters";
+import { UtensilsCrossed, Heart } from "lucide-react";
+
+const mockDiningRecommendations = [
+ {
+ id: "1",
+ title: "Le Bernardin 法式餐厅",
+ description: "纽约米其林三星法式海鲜餐厅,精致优雅",
+ category: "dining",
+ targetAudience: "both",
+ location: "纽约, 美国",
+ country: "美国",
+ city: "纽约",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.9,
+ priceLevel: 5,
+ tags: ["法餐", "米其林", "海鲜"],
+ },
+ {
+ id: "2",
+ title: "次郎寿司",
+ description: "东京顶级寿司店,寿司之神的传奇",
+ category: "dining",
+ targetAudience: "both",
+ location: "东京, 日本",
+ country: "日本",
+ city: "东京",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 5.0,
+ priceLevel: 5,
+ tags: ["日料", "寿司", "米其林"],
+ },
+ {
+ id: "3",
+ title: "Noma 北欧料理",
+ description: "哥本哈根世界排名第一的餐厅,创新北欧料理",
+ category: "dining",
+ targetAudience: "both",
+ location: "哥本哈根, 丹麦",
+ country: "丹麦",
+ city: "哥本哈根",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 5.0,
+ priceLevel: 5,
+ tags: ["北欧", "创新", "米其林"],
+ },
+ {
+ id: "4",
+ title: "Catch LA",
+ description: "洛杉矶时尚海鲜餐厅,名流聚集地",
+ category: "dining",
+ targetAudience: "both",
+ location: "洛杉矶, 美国",
+ country: "美国",
+ city: "洛杉矶",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.6,
+ priceLevel: 4,
+ tags: ["海鲜", "时尚", "社交"],
+ },
+ {
+ id: "5",
+ title: "Nellie's Sports Bar",
+ description: "华盛顿特区热门同志餐厅酒吧,轻松友好",
+ category: "dining",
+ targetAudience: "gay",
+ location: "华盛顿特区, 美国",
+ country: "美国",
+ city: "华盛顿特区",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.5,
+ priceLevel: 2,
+ tags: ["美式", "酒吧", "社交"],
+ },
+ {
+ id: "6",
+ title: "Cafe Flore 巴黎",
+ description: "巴黎经典同志咖啡馆,文艺气息浓厚",
+ category: "dining",
+ targetAudience: "gay",
+ location: "巴黎, 法国",
+ country: "法国",
+ city: "巴黎",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.4,
+ priceLevel: 3,
+ tags: ["咖啡", "文艺", "经典"],
+ },
+];
+
+export default function DiningPage() {
+ const { mode, toggleMode } = usePreferenceStore();
+ const [filteredRecommendations, setFilteredRecommendations] = useState(
+ mockDiningRecommendations
+ );
+ const [searchTerm, setSearchTerm] = useState("");
+ const [locationFilter, setLocationFilter] = useState("");
+ const [ratingFilter, setRatingFilter] = useState(0);
+ const [priceLevelFilter, setPriceLevelFilter] = useState
([1, 5]);
+
+ useEffect(() => {
+ let filtered = mockDiningRecommendations.filter(
+ (rec) =>
+ rec.targetAudience === mode || rec.targetAudience === "both"
+ );
+
+ if (searchTerm) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ rec.description.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+ }
+
+ if (locationFilter) {
+ filtered = filtered.filter((rec) =>
+ rec.location.toLowerCase().includes(locationFilter.toLowerCase())
+ );
+ }
+
+ if (ratingFilter > 0) {
+ filtered = filtered.filter((rec) => rec.rating >= ratingFilter);
+ }
+
+ if (priceLevelFilter) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.priceLevel >= priceLevelFilter[0] &&
+ rec.priceLevel <= priceLevelFilter[1]
+ );
+ }
+
+ setFilteredRecommendations(filtered);
+ }, [mode, searchTerm, locationFilter, ratingFilter, priceLevelFilter]);
+
+ return (
+
+
+
+
+ {/* Page Header */}
+
+
+
+
+
+
+
+ 美食推荐
+
+
+ 品尝全球顶级餐厅美食
+
+
+
+
+ {/* Mode Toggle */}
+
+ 模式:
+
+
+ {mode === "straight" ? "直男模式" : "同志模式"}
+
+
+
+
+ {/* Filters */}
+
+
+ {/* Results Count */}
+
+ 找到 {filteredRecommendations.length} 个推荐
+
+
+ {/* Recommendations Grid */}
+
+ {filteredRecommendations.map((rec) => (
+
+ ))}
+
+
+ {/* Empty State */}
+ {filteredRecommendations.length === 0 && (
+
+
+
+ 未找到推荐
+
+
+ 尝试调整筛选条件或搜索词
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/recommendations/entertainment/page.tsx b/apps/web/src/app/recommendations/entertainment/page.tsx
new file mode 100644
index 000000000..bc90e8dfc
--- /dev/null
+++ b/apps/web/src/app/recommendations/entertainment/page.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { usePreferenceStore } from "@/stores/preference-store";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { RecommendationCard } from "@/components/recommendations/recommendation-card";
+import { Filters } from "@/components/recommendations/filters";
+import { Music, Heart } from "lucide-react";
+
+const mockEntertainmentRecommendations = [
+ {
+ id: "1",
+ title: "蓝色音符爵士俱乐部",
+ description: "纽约最具特色的爵士酒吧,每晚现场演奏",
+ category: "entertainment",
+ targetAudience: "both",
+ location: "纽约, 美国",
+ country: "美国",
+ city: "纽约",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.7,
+ priceLevel: 3,
+ tags: ["音乐", "酒吧", "爵士"],
+ },
+ {
+ id: "2",
+ title: "柏林俱乐部场景",
+ description: "世界知名的电子音乐俱乐部,夜生活的天堂",
+ category: "entertainment",
+ targetAudience: "both",
+ location: "柏林, 德国",
+ country: "德国",
+ city: "柏林",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.9,
+ priceLevel: 3,
+ tags: ["夜店", "电音", "派对"],
+ },
+ {
+ id: "3",
+ title: "伦敦西区音乐剧",
+ description: "观看世界级音乐剧表演,体验戏剧艺术",
+ category: "entertainment",
+ targetAudience: "both",
+ location: "伦敦, 英国",
+ country: "英国",
+ city: "伦敦",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.8,
+ priceLevel: 4,
+ tags: ["音乐剧", "戏剧", "艺术"],
+ },
+ {
+ id: "4",
+ title: "Boxers NYC 体育酒吧",
+ description: "曼哈顿热门同志体育酒吧,友好包容的氛围",
+ category: "entertainment",
+ targetAudience: "gay",
+ location: "纽约, 美国",
+ country: "美国",
+ city: "纽约",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.6,
+ priceLevel: 2,
+ tags: ["酒吧", "体育", "社交"],
+ },
+ {
+ id: "5",
+ title: "Berghain 柏林",
+ description: "柏林最著名的同志友好夜店,电音圣地",
+ category: "entertainment",
+ targetAudience: "gay",
+ location: "柏林, 德国",
+ country: "德国",
+ city: "柏林",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.9,
+ priceLevel: 3,
+ tags: ["夜店", "电音", "派对"],
+ },
+ {
+ id: "6",
+ title: "Heaven 伦敦",
+ description: "伦敦标志性同志夜店,已有40年历史",
+ category: "entertainment",
+ targetAudience: "gay",
+ location: "伦敦, 英国",
+ country: "英国",
+ city: "伦敦",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.5,
+ priceLevel: 2,
+ tags: ["夜店", "派对", "经典"],
+ },
+];
+
+export default function EntertainmentPage() {
+ const { mode, toggleMode } = usePreferenceStore();
+ const [filteredRecommendations, setFilteredRecommendations] = useState(
+ mockEntertainmentRecommendations
+ );
+ const [searchTerm, setSearchTerm] = useState("");
+ const [locationFilter, setLocationFilter] = useState("");
+ const [ratingFilter, setRatingFilter] = useState(0);
+ const [priceLevelFilter, setPriceLevelFilter] = useState([1, 5]);
+
+ useEffect(() => {
+ let filtered = mockEntertainmentRecommendations.filter(
+ (rec) =>
+ rec.targetAudience === mode || rec.targetAudience === "both"
+ );
+
+ if (searchTerm) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ rec.description.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+ }
+
+ if (locationFilter) {
+ filtered = filtered.filter((rec) =>
+ rec.location.toLowerCase().includes(locationFilter.toLowerCase())
+ );
+ }
+
+ if (ratingFilter > 0) {
+ filtered = filtered.filter((rec) => rec.rating >= ratingFilter);
+ }
+
+ if (priceLevelFilter) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.priceLevel >= priceLevelFilter[0] &&
+ rec.priceLevel <= priceLevelFilter[1]
+ );
+ }
+
+ setFilteredRecommendations(filtered);
+ }, [mode, searchTerm, locationFilter, ratingFilter, priceLevelFilter]);
+
+ return (
+
+
+
+
+ {/* Page Header */}
+
+
+
+
+
+
+
+ 娱乐推荐
+
+
+ 发现最热门的娱乐场所
+
+
+
+
+ {/* Mode Toggle */}
+
+ 模式:
+
+
+ {mode === "straight" ? "直男模式" : "同志模式"}
+
+
+
+
+ {/* Filters */}
+
+
+ {/* Results Count */}
+
+ 找到 {filteredRecommendations.length} 个推荐
+
+
+ {/* Recommendations Grid */}
+
+ {filteredRecommendations.map((rec) => (
+
+ ))}
+
+
+ {/* Empty State */}
+ {filteredRecommendations.length === 0 && (
+
+
+
+ 未找到推荐
+
+
+ 尝试调整筛选条件或搜索词
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/recommendations/shopping/page.tsx b/apps/web/src/app/recommendations/shopping/page.tsx
new file mode 100644
index 000000000..b8d54c8d6
--- /dev/null
+++ b/apps/web/src/app/recommendations/shopping/page.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { usePreferenceStore } from "@/stores/preference-store";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { RecommendationCard } from "@/components/recommendations/recommendation-card";
+import { Filters } from "@/components/recommendations/filters";
+import { ShoppingBag, Heart } from "lucide-react";
+
+const mockShoppingRecommendations = [
+ {
+ id: "1",
+ title: "香榭丽舍大道",
+ description: "巴黎最著名的购物街,汇集各大奢侈品牌",
+ category: "shopping",
+ targetAudience: "both",
+ location: "巴黎, 法国",
+ country: "法国",
+ city: "巴黎",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.8,
+ priceLevel: 5,
+ tags: ["奢侈品", "时尚", "购物街"],
+ },
+ {
+ id: "2",
+ title: "银座购物区",
+ description: "东京高端购物区,日本时尚与传统的交汇",
+ category: "shopping",
+ targetAudience: "both",
+ location: "东京, 日本",
+ country: "日本",
+ city: "东京",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.7,
+ priceLevel: 5,
+ tags: ["奢侈品", "日式", "百货"],
+ },
+ {
+ id: "3",
+ title: "第五大道",
+ description: "纽约标志性购物街,从平价到奢侈应有尽有",
+ category: "shopping",
+ targetAudience: "both",
+ location: "纽约, 美国",
+ country: "美国",
+ city: "纽约",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.9,
+ priceLevel: 4,
+ tags: ["购物街", "百货", "品牌"],
+ },
+ {
+ id: "4",
+ title: "Selfridges 伦敦",
+ description: "英国最著名的百货商店,时尚潮流风向标",
+ category: "shopping",
+ targetAudience: "both",
+ location: "伦敦, 英国",
+ country: "英国",
+ city: "伦敦",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.6,
+ priceLevel: 4,
+ tags: ["百货", "时尚", "英伦"],
+ },
+ {
+ id: "5",
+ title: "Castro 区精品店",
+ description: "旧金山卡斯特罗区特色服装店,独特设计",
+ category: "shopping",
+ targetAudience: "gay",
+ location: "旧金山, 美国",
+ country: "美国",
+ city: "旧金山",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.5,
+ priceLevel: 3,
+ tags: ["服装", "设计", "个性"],
+ },
+ {
+ id: "6",
+ title: "P.C. Hooftstraat 阿姆斯特丹",
+ description: "阿姆斯特丹高端购物街,设计师品牌云集",
+ category: "shopping",
+ targetAudience: "both",
+ location: "阿姆斯特丹, 荷兰",
+ country: "荷兰",
+ city: "阿姆斯特丹",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.4,
+ priceLevel: 4,
+ tags: ["奢侈品", "设计", "欧式"],
+ },
+];
+
+export default function ShoppingPage() {
+ const { mode, toggleMode } = usePreferenceStore();
+ const [filteredRecommendations, setFilteredRecommendations] = useState(
+ mockShoppingRecommendations
+ );
+ const [searchTerm, setSearchTerm] = useState("");
+ const [locationFilter, setLocationFilter] = useState("");
+ const [ratingFilter, setRatingFilter] = useState(0);
+ const [priceLevelFilter, setPriceLevelFilter] = useState([1, 5]);
+
+ useEffect(() => {
+ let filtered = mockShoppingRecommendations.filter(
+ (rec) =>
+ rec.targetAudience === mode || rec.targetAudience === "both"
+ );
+
+ if (searchTerm) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ rec.description.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+ }
+
+ if (locationFilter) {
+ filtered = filtered.filter((rec) =>
+ rec.location.toLowerCase().includes(locationFilter.toLowerCase())
+ );
+ }
+
+ if (ratingFilter > 0) {
+ filtered = filtered.filter((rec) => rec.rating >= ratingFilter);
+ }
+
+ if (priceLevelFilter) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.priceLevel >= priceLevelFilter[0] &&
+ rec.priceLevel <= priceLevelFilter[1]
+ );
+ }
+
+ setFilteredRecommendations(filtered);
+ }, [mode, searchTerm, locationFilter, ratingFilter, priceLevelFilter]);
+
+ return (
+
+
+
+
+ {/* Page Header */}
+
+
+
+
+
+
+
+ 购物推荐
+
+
+ 精选购物目的地推荐
+
+
+
+
+ {/* Mode Toggle */}
+
+ 模式:
+
+
+ {mode === "straight" ? "直男模式" : "同志模式"}
+
+
+
+
+ {/* Filters */}
+
+
+ {/* Results Count */}
+
+ 找到 {filteredRecommendations.length} 个推荐
+
+
+ {/* Recommendations Grid */}
+
+ {filteredRecommendations.map((rec) => (
+
+ ))}
+
+
+ {/* Empty State */}
+ {filteredRecommendations.length === 0 && (
+
+
+
+ 未找到推荐
+
+
+ 尝试调整筛选条件或搜索词
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/recommendations/travel/page.tsx b/apps/web/src/app/recommendations/travel/page.tsx
new file mode 100644
index 000000000..6556ba883
--- /dev/null
+++ b/apps/web/src/app/recommendations/travel/page.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { usePreferenceStore } from "@/stores/preference-store";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { RecommendationCard } from "@/components/recommendations/recommendation-card";
+import { Filters } from "@/components/recommendations/filters";
+import { Plane, Heart } from "lucide-react";
+
+// Mock data - in a real app, this would come from an API/database
+const mockTravelRecommendations = [
+ {
+ id: "1",
+ title: "巴黎浪漫之旅",
+ description: "探索光之城的魅力,从埃菲尔铁塔到卢浮宫,体验法式浪漫",
+ category: "travel",
+ targetAudience: "both",
+ location: "巴黎, 法国",
+ country: "法国",
+ city: "巴黎",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.8,
+ priceLevel: 4,
+ tags: ["浪漫", "文化", "艺术"],
+ },
+ {
+ id: "2",
+ title: "东京现代探索",
+ description: "体验日本首都的独特魅力,从传统寺庙到现代科技",
+ category: "travel",
+ targetAudience: "both",
+ location: "东京, 日本",
+ country: "日本",
+ city: "东京",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.9,
+ priceLevel: 4,
+ tags: ["现代", "文化", "美食"],
+ },
+ {
+ id: "3",
+ title: "普罗旺斯田园风光",
+ description: "漫步在薰衣草田间,享受南法的宁静与美好",
+ category: "travel",
+ targetAudience: "both",
+ location: "普罗旺斯, 法国",
+ country: "法国",
+ city: "普罗旺斯",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.7,
+ priceLevel: 3,
+ tags: ["自然", "放松", "摄影"],
+ },
+ {
+ id: "4",
+ title: "纽约都市活力",
+ description: "感受大苹果的活力与多元文化,探索百老汇和中央公园",
+ category: "travel",
+ targetAudience: "both",
+ location: "纽约, 美国",
+ country: "美国",
+ city: "纽约",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.6,
+ priceLevel: 5,
+ tags: ["都市", "娱乐", "购物"],
+ },
+ {
+ id: "5",
+ title: "阿姆斯特丹自由之旅",
+ description: "骑行在运河边,体验荷兰的开放与包容文化",
+ category: "travel",
+ targetAudience: "gay",
+ location: "阿姆斯特丹, 荷兰",
+ country: "荷兰",
+ city: "阿姆斯特丹",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.9,
+ priceLevel: 3,
+ tags: ["包容", "文化", "夜生活"],
+ },
+ {
+ id: "6",
+ title: "旧金山湾区之旅",
+ description: "探索科技与文化交融的湾区,从金门大桥到卡斯特罗区",
+ category: "travel",
+ targetAudience: "gay",
+ location: "旧金山, 美国",
+ country: "美国",
+ city: "旧金山",
+ imageUrl: "/api/placeholder/400/300",
+ rating: 4.8,
+ priceLevel: 5,
+ tags: ["包容", "科技", "美食"],
+ },
+];
+
+export default function TravelPage() {
+ const { mode, toggleMode } = usePreferenceStore();
+ const [filteredRecommendations, setFilteredRecommendations] = useState(
+ mockTravelRecommendations
+ );
+ const [searchTerm, setSearchTerm] = useState("");
+ const [locationFilter, setLocationFilter] = useState("");
+ const [ratingFilter, setRatingFilter] = useState(0);
+ const [priceLevelFilter, setPriceLevelFilter] = useState([1, 5]);
+
+ useEffect(() => {
+ let filtered = mockTravelRecommendations.filter(
+ (rec) =>
+ rec.targetAudience === mode || rec.targetAudience === "both"
+ );
+
+ if (searchTerm) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ rec.description.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+ }
+
+ if (locationFilter) {
+ filtered = filtered.filter((rec) =>
+ rec.location.toLowerCase().includes(locationFilter.toLowerCase())
+ );
+ }
+
+ if (ratingFilter > 0) {
+ filtered = filtered.filter((rec) => rec.rating >= ratingFilter);
+ }
+
+ if (priceLevelFilter) {
+ filtered = filtered.filter(
+ (rec) =>
+ rec.priceLevel >= priceLevelFilter[0] &&
+ rec.priceLevel <= priceLevelFilter[1]
+ );
+ }
+
+ setFilteredRecommendations(filtered);
+ }, [mode, searchTerm, locationFilter, ratingFilter, priceLevelFilter]);
+
+ return (
+
+
+
+
+ {/* Page Header */}
+
+
+
+
+
+ 旅行推荐
+
+
+ 发现全球最佳旅行目的地
+
+
+
+
+ {/* Mode Toggle */}
+
+ 模式:
+
+
+ {mode === "straight" ? "直男模式" : "同志模式"}
+
+
+
+
+ {/* Filters */}
+
+
+ {/* Results Count */}
+
+ 找到 {filteredRecommendations.length} 个推荐
+
+
+ {/* Recommendations Grid */}
+
+ {filteredRecommendations.map((rec) => (
+
+ ))}
+
+
+ {/* Empty State */}
+ {filteredRecommendations.length === 0 && (
+
+
+
+ 未找到推荐
+
+
+ 尝试调整筛选条件或搜索词
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/recommendations/filters.tsx b/apps/web/src/components/recommendations/filters.tsx
new file mode 100644
index 000000000..4c386faa6
--- /dev/null
+++ b/apps/web/src/components/recommendations/filters.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { Search, SlidersHorizontal } from "lucide-react";
+import { useState } from "react";
+
+interface FiltersProps {
+ onSearchChange: (search: string) => void;
+ onLocationChange: (location: string) => void;
+ onPriceLevelChange: (priceLevel: number[]) => void;
+ onRatingChange: (rating: number) => void;
+}
+
+export function Filters({
+ onSearchChange,
+ onLocationChange,
+ onPriceLevelChange,
+ onRatingChange,
+}: FiltersProps) {
+ const [showFilters, setShowFilters] = useState(false);
+ const [search, setSearch] = useState("");
+ const [location, setLocation] = useState("");
+ const [priceLevel, setPriceLevel] = useState([1, 5]);
+ const [rating, setRating] = useState(0);
+
+ const handleSearchChange = (value: string) => {
+ setSearch(value);
+ onSearchChange(value);
+ };
+
+ const handleLocationChange = (value: string) => {
+ setLocation(value);
+ onLocationChange(value);
+ };
+
+ return (
+
+ {/* Search Bar */}
+
+
+
+ handleSearchChange(e.target.value)}
+ className="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+
+
+
+
+
+ {/* Advanced Filters */}
+ {showFilters && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/recommendations/recommendation-card.tsx b/apps/web/src/components/recommendations/recommendation-card.tsx
new file mode 100644
index 000000000..93d83db8c
--- /dev/null
+++ b/apps/web/src/components/recommendations/recommendation-card.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { Star, MapPin, DollarSign, Heart } from "lucide-react";
+import Image from "next/image";
+import { useState } from "react";
+
+interface RecommendationCardProps {
+ id: string;
+ title: string;
+ description: string;
+ location: string;
+ imageUrl?: string;
+ rating: number;
+ priceLevel: number;
+ tags?: string[];
+ isFavorite?: boolean;
+ onFavoriteToggle?: (id: string) => void;
+}
+
+export function RecommendationCard({
+ id,
+ title,
+ description,
+ location,
+ imageUrl,
+ rating,
+ priceLevel,
+ tags = [],
+ isFavorite = false,
+ onFavoriteToggle,
+}: RecommendationCardProps) {
+ const [favorite, setFavorite] = useState(isFavorite);
+
+ const handleFavoriteClick = () => {
+ setFavorite(!favorite);
+ onFavoriteToggle?.(id);
+ };
+
+ return (
+
+ {/* Image */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ {/* Favorite Button */}
+
+
+
+ {/* Content */}
+
+
+ {title}
+
+
+
+ {/* Location */}
+
+
+ {location}
+
+
+ {/* Rating */}
+
+
+ {rating.toFixed(1)}
+
+
+ {/* Price Level */}
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ {description}
+
+
+ {/* Tags */}
+ {tags.length > 0 && (
+
+ {tags.slice(0, 3).map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/lib/font-config.ts b/apps/web/src/lib/font-config.ts
index 0389054a1..1b3192f58 100644
--- a/apps/web/src/lib/font-config.ts
+++ b/apps/web/src/lib/font-config.ts
@@ -1,25 +1,13 @@
-import {
- Inter,
- Roboto,
- Open_Sans,
- Playfair_Display,
- Comic_Neue,
-} from "next/font/google";
-
-// Configure all fonts
-const inter = Inter({ subsets: ["latin"] });
-const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
-const openSans = Open_Sans({ subsets: ["latin"] });
-const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
-const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
+// Font configuration - using system fonts for now
+// Google Fonts disabled due to network issues
// Export font class mapping for use in components
export const FONT_CLASS_MAP = {
- Inter: inter.className,
- Roboto: roboto.className,
- "Open Sans": openSans.className,
- "Playfair Display": playfairDisplay.className,
- "Comic Neue": comicNeue.className,
+ Inter: "",
+ Roboto: "",
+ "Open Sans": "",
+ "Playfair Display": "",
+ "Comic Neue": "",
Arial: "",
Helvetica: "",
"Times New Roman": "",
@@ -28,12 +16,12 @@ export const FONT_CLASS_MAP = {
// Export individual fonts for use in layout
export const fonts = {
- inter,
- roboto,
- openSans,
- playfairDisplay,
- comicNeue,
+ inter: { className: "" },
+ roboto: { className: "" },
+ openSans: { className: "" },
+ playfairDisplay: { className: "" },
+ comicNeue: { className: "" },
};
// Default font for the body
-export const defaultFont = inter;
+export const defaultFont = { className: "" };
diff --git a/apps/web/src/stores/preference-store.ts b/apps/web/src/stores/preference-store.ts
new file mode 100644
index 000000000..0d67ab205
--- /dev/null
+++ b/apps/web/src/stores/preference-store.ts
@@ -0,0 +1,26 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export type PreferenceMode = 'straight' | 'gay';
+
+interface PreferenceState {
+ mode: PreferenceMode;
+ setMode: (mode: PreferenceMode) => void;
+ toggleMode: () => void;
+}
+
+export const usePreferenceStore = create()(
+ persist(
+ (set) => ({
+ mode: 'straight',
+ setMode: (mode) => set({ mode }),
+ toggleMode: () =>
+ set((state) => ({
+ mode: state.mode === 'straight' ? 'gay' : 'straight',
+ })),
+ }),
+ {
+ name: 'preference-mode',
+ }
+ )
+);
diff --git a/bun.lock b/bun.lock
index deff9287d..cb82f7862 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "opencut",
@@ -39,7 +40,6 @@
"motion": "^12.18.1",
"next": "^15.3.4",
"next-themes": "^0.4.4",
- "ollama": "^0.5.16",
"pg": "^8.16.2",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
@@ -59,6 +59,7 @@
},
"devDependencies": {
"@types/bun": "latest",
+ "@types/node": "^25.0.10",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
@@ -415,7 +416,7 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
- "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
+ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
@@ -693,8 +694,6 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
- "ollama": ["ollama@0.5.16", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w=="],
-
"opencut": ["opencut@workspace:apps/web"],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
@@ -911,8 +910,6 @@
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
- "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
-
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
@@ -929,7 +926,7 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
- "@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
+ "@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -939,6 +936,8 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+ "opencut/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
+
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1001,6 +1000,8 @@
"motion/framer-motion/motion-utils": ["motion-utils@12.19.0", "", {}, "sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw=="],
+ "opencut/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index c4af608d1..f141974ae 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -1,4 +1,4 @@
-import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
+import { pgTable, text, timestamp, boolean, integer, real, uuid } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
@@ -8,6 +8,8 @@ export const users = pgTable("users", {
.default(false)
.notNull(),
image: text("image"),
+ // User preference mode: 'straight' or 'gay'
+ preferenceMode: text("preference_mode").default("straight").notNull(),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
@@ -66,4 +68,63 @@ export const waitlist = pgTable("waitlist", {
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
+}).enableRLS();
+
+// Recommendations table for travel, entertainment, dining, shopping
+export const recommendations = pgTable("recommendations", {
+ id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
+ title: text("title").notNull(),
+ description: text("description").notNull(),
+ category: text("category").notNull(), // 'travel', 'entertainment', 'dining', 'shopping'
+ targetAudience: text("target_audience").notNull(), // 'straight', 'gay', 'both'
+ location: text("location").notNull(), // City, Country
+ country: text("country").notNull(),
+ city: text("city").notNull(),
+ address: text("address"),
+ imageUrl: text("image_url"),
+ rating: real("rating").default(0),
+ priceLevel: integer("price_level").default(2), // 1-5 scale
+ tags: text("tags").array(), // Array of tags like 'romantic', 'adventure', 'luxury', etc.
+ website: text("website"),
+ phone: text("phone"),
+ featured: boolean("featured").default(false),
+ createdAt: timestamp("created_at")
+ .$defaultFn(() => new Date())
+ .notNull(),
+ updatedAt: timestamp("updated_at")
+ .$defaultFn(() => new Date())
+ .notNull(),
+}).enableRLS();
+
+// User favorites
+export const favorites = pgTable("favorites", {
+ id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ recommendationId: uuid("recommendation_id")
+ .notNull()
+ .references(() => recommendations.id, { onDelete: "cascade" }),
+ createdAt: timestamp("created_at")
+ .$defaultFn(() => new Date())
+ .notNull(),
+}).enableRLS();
+
+// User reviews and ratings
+export const reviews = pgTable("reviews", {
+ id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ recommendationId: uuid("recommendation_id")
+ .notNull()
+ .references(() => recommendations.id, { onDelete: "cascade" }),
+ rating: integer("rating").notNull(), // 1-5
+ comment: text("comment"),
+ createdAt: timestamp("created_at")
+ .$defaultFn(() => new Date())
+ .notNull(),
+ updatedAt: timestamp("updated_at")
+ .$defaultFn(() => new Date())
+ .notNull(),
}).enableRLS();
\ No newline at end of file