From 8536c306b7692605d368925adab8594cf52dd23f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 11:42:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E5=85=A8?= =?UTF-8?q?=E7=90=83=E7=94=B7=E5=A3=AB=E6=97=85=E8=A1=8C=E5=A8=B1=E4=B9=90?= =?UTF-8?q?=E7=BE=8E=E9=A3=9F=E8=B4=AD=E7=89=A9=E6=8E=A8=E8=8D=90=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了一个全新的推荐系统 app,支持直男和同志模式一键切换 主要功能: - 用户偏好模式切换(直男/同志模式) - 四大推荐类别:旅行、娱乐、美食、购物 - 每个类别都有专门的推荐页面 - 高级筛选功能:搜索、地点、评分、价格区间 - 响应式设计,支持深色模式 - 基于用户偏好的智能推荐过滤 技术实现: - 新增数据库 schema:recommendations, favorites, reviews 表 - 添加 preferenceMode 字段到 users 表 - 使用 Zustand 进行状态管理(preference-store) - 创建可复用组件:RecommendationCard, Filters - Mock 数据演示不同模式下的推荐内容 文件变更: - 重新设计主页,添加模式切换和分类入口 - 创建 4 个推荐页面(travel, entertainment, dining, shopping) - 更新数据库 schema 和迁移文件 - 临时禁用 Google Fonts(使用系统字体) --- apps/web/migrations/0002_fresh_gladiator.sql | 47 ++ apps/web/migrations/meta/0002_snapshot.json | 636 ++++++++++++++++++ apps/web/migrations/meta/_journal.json | 7 + apps/web/package.json | 1 + apps/web/src/app/page.tsx | 156 ++++- .../src/app/recommendations/dining/page.tsx | 230 +++++++ .../recommendations/entertainment/page.tsx | 230 +++++++ .../src/app/recommendations/shopping/page.tsx | 230 +++++++ .../src/app/recommendations/travel/page.tsx | 231 +++++++ .../components/recommendations/filters.tsx | 122 ++++ .../recommendations/recommendation-card.tsx | 125 ++++ apps/web/src/lib/font-config.ts | 38 +- apps/web/src/stores/preference-store.ts | 26 + bun.lock | 17 +- packages/db/src/schema.ts | 63 +- 15 files changed, 2117 insertions(+), 42 deletions(-) create mode 100644 apps/web/migrations/0002_fresh_gladiator.sql create mode 100644 apps/web/migrations/meta/0002_snapshot.json create mode 100644 apps/web/src/app/recommendations/dining/page.tsx create mode 100644 apps/web/src/app/recommendations/entertainment/page.tsx create mode 100644 apps/web/src/app/recommendations/shopping/page.tsx create mode 100644 apps/web/src/app/recommendations/travel/page.tsx create mode 100644 apps/web/src/components/recommendations/filters.tsx create mode 100644 apps/web/src/components/recommendations/recommendation-card.tsx create mode 100644 apps/web/src/stores/preference-store.ts 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..a3ac53af6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "motion": "^12.18.1", + "nanoid": "^5.1.6", "next": "^15.3.4", "next-themes": "^0.4.4", "pg": "^8.16.2", 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 && ( +
+
+ {/* Location Filter */} +
+ + handleLocationChange(e.target.value)} + className="w-full px-3 py-2 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" + /> +
+ + {/* Rating Filter */} +
+ + { + const value = parseFloat(e.target.value); + setRating(value); + onRatingChange(value); + }} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + /> +
+ + {/* Price Level Filter */} +
+ + { + const value = parseInt(e.target.value); + const newPriceLevel = [priceLevel[0], value]; + setPriceLevel(newPriceLevel); + onPriceLevelChange(newPriceLevel); + }} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + /> +
+
+
+ )} +
+ ); +} 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 ? ( + {title} + ) : ( +
+ +
+ )} + + {/* 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..e35a64ddc 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencut", @@ -37,9 +38,9 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "motion": "^12.18.1", + "nanoid": "^5.1.6", "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", @@ -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=="], @@ -679,7 +680,7 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="], @@ -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=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "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=="], + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "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 From 276d87fb7ae5e6547909ddb6753588bf13fe57dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 11:43:40 +0000 Subject: [PATCH 2/2] chore: update package dependencies --- apps/web/package.json | 4 ++-- bun.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index a3ac53af6..e4fc2156b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,7 +36,6 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "motion": "^12.18.1", - "nanoid": "^5.1.6", "next": "^15.3.4", "next-themes": "^0.4.4", "pg": "^8.16.2", @@ -57,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/bun.lock b/bun.lock index e35a64ddc..cb82f7862 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,6 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "motion": "^12.18.1", - "nanoid": "^5.1.6", "next": "^15.3.4", "next-themes": "^0.4.4", "pg": "^8.16.2", @@ -60,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", @@ -680,7 +680,7 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="], @@ -936,7 +936,7 @@ "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=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "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=="], @@ -1000,7 +1000,7 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.19.0", "", {}, "sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "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=="],