Add admin features, responsive UI, user reviews, visit stats, and channel-colored markers
- Admin: video management with Google Maps match status, manual restaurant mapping, restaurant remap on name change - Admin: user management tab with favorites/reviews detail - Admin: channel deletion fix for IDs with slashes - Frontend: responsive mobile layout (map top, list bottom, 2-row header) - Frontend: channel-colored map markers with legend - Frontend: my reviews list, favorites toggle, visit counter overlay - Frontend: force light mode for dark theme devices - Backend: visit tracking (site_visits table), user reviews endpoint - Backend: bulk transcript/extract streaming, geocode key fixes - Nginx config for production deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
APIProvider,
|
||||
Map,
|
||||
AdvancedMarker,
|
||||
InfoWindow,
|
||||
useMap,
|
||||
} from "@vis.gl/react-google-maps";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
|
||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
|
||||
// Channel color palette
|
||||
const CHANNEL_COLORS = [
|
||||
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
||||
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
||||
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
||||
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
||||
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
||||
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
||||
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
||||
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
||||
];
|
||||
|
||||
function getChannelColorMap(restaurants: Restaurant[]) {
|
||||
const channels = new Set<string>();
|
||||
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
||||
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
||||
let i = 0;
|
||||
for (const ch of channels) {
|
||||
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
i++;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
interface MapViewProps {
|
||||
restaurants: Restaurant[];
|
||||
selected?: Restaurant | null;
|
||||
onSelectRestaurant?: (r: Restaurant) => void;
|
||||
}
|
||||
|
||||
export default function MapView({ restaurants, onSelectRestaurant }: MapViewProps) {
|
||||
const [selected, setSelected] = useState<Restaurant | null>(null);
|
||||
function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
|
||||
const map = useMap();
|
||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(r: Restaurant) => {
|
||||
setSelected(r);
|
||||
setInfoTarget(r);
|
||||
onSelectRestaurant?.(r);
|
||||
},
|
||||
[onSelectRestaurant]
|
||||
);
|
||||
|
||||
// Pan and zoom to selected restaurant
|
||||
useEffect(() => {
|
||||
if (!map || !selected) return;
|
||||
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
||||
map.setZoom(16);
|
||||
setInfoTarget(selected);
|
||||
}, [map, selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{restaurants.map((r) => {
|
||||
const isSelected = selected?.id === r.id;
|
||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||
const chColor = r.channels?.[0] ? channelColors[r.channels[0]] : CHANNEL_COLORS[0];
|
||||
const c = chColor || CHANNEL_COLORS[0];
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={r.id}
|
||||
position={{ lat: r.latitude, lng: r.longitude }}
|
||||
onClick={() => handleMarkerClick(r)}
|
||||
zIndex={isSelected ? 1000 : 1}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
||||
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
borderRadius: 6,
|
||||
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
||||
boxShadow: isSelected
|
||||
? "0 2px 8px rgba(37,99,235,0.4)"
|
||||
: `0 1px 4px ${c.border}40`,
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 120,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textDecoration: isClosed ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{r.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
||||
marginTop: -1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{infoTarget && (
|
||||
<InfoWindow
|
||||
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
||||
onCloseClick={() => setInfoTarget(null)}
|
||||
>
|
||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{infoTarget.name}</h3>
|
||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||
)}
|
||||
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||
)}
|
||||
</div>
|
||||
{infoTarget.rating && (
|
||||
<p className="text-xs mt-0.5">
|
||||
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
||||
{infoTarget.rating_count && (
|
||||
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{infoTarget.cuisine_type && (
|
||||
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p>
|
||||
)}
|
||||
{infoTarget.address && (
|
||||
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p>
|
||||
)}
|
||||
{infoTarget.price_range && (
|
||||
<p className="text-xs text-gray-500">{infoTarget.price_range}</p>
|
||||
)}
|
||||
{infoTarget.phone && (
|
||||
<p className="text-xs text-gray-500">{infoTarget.phone}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSelectRestaurant?.(infoTarget)}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
상세 보기
|
||||
</button>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapView({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<Map
|
||||
@@ -35,41 +174,27 @@ export default function MapView({ restaurants, onSelectRestaurant }: MapViewProp
|
||||
defaultZoom={12}
|
||||
mapId="tasteby-map"
|
||||
className="h-full w-full"
|
||||
colorScheme="LIGHT"
|
||||
>
|
||||
{restaurants.map((r) => (
|
||||
<AdvancedMarker
|
||||
key={r.id}
|
||||
position={{ lat: r.latitude, lng: r.longitude }}
|
||||
onClick={() => handleMarkerClick(r)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selected && (
|
||||
<InfoWindow
|
||||
position={{ lat: selected.latitude, lng: selected.longitude }}
|
||||
onCloseClick={() => setSelected(null)}
|
||||
>
|
||||
<div className="max-w-xs p-1">
|
||||
<h3 className="font-bold text-base">{selected.name}</h3>
|
||||
{selected.cuisine_type && (
|
||||
<p className="text-sm text-gray-600">{selected.cuisine_type}</p>
|
||||
)}
|
||||
{selected.address && (
|
||||
<p className="text-xs text-gray-500 mt-1">{selected.address}</p>
|
||||
)}
|
||||
{selected.price_range && (
|
||||
<p className="text-xs text-gray-500">{selected.price_range}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSelectRestaurant?.(selected)}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
상세 보기
|
||||
</button>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
)}
|
||||
<MapContent
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={onSelectRestaurant}
|
||||
/>
|
||||
</Map>
|
||||
{channelNames.length > 1 && (
|
||||
<div className="absolute top-2 left-2 bg-white/90 rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
||||
{channelNames.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full border"
|
||||
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
||||
/>
|
||||
<span className="text-gray-700">{ch}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</APIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
69
frontend/src/components/MyReviewsList.tsx
Normal file
69
frontend/src/components/MyReviewsList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { Review } from "@/lib/api";
|
||||
|
||||
interface MyReview extends Review {
|
||||
restaurant_id: string;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
interface MyReviewsListProps {
|
||||
reviews: MyReview[];
|
||||
onClose: () => void;
|
||||
onSelectRestaurant: (restaurantId: string) => void;
|
||||
}
|
||||
|
||||
export default function MyReviewsList({
|
||||
reviews,
|
||||
onClose,
|
||||
onSelectRestaurant,
|
||||
}: MyReviewsListProps) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-bold text-lg">내 리뷰 ({reviews.length})</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-8 text-center">
|
||||
아직 작성한 리뷰가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reviews.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => onSelectRestaurant(r.restaurant_id)}
|
||||
className="w-full text-left border rounded-lg p-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{r.restaurant_name || "알 수 없는 식당"}
|
||||
</span>
|
||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
||||
{"★".repeat(Math.round(r.rating))}
|
||||
<span className="text-gray-500 ml-1">{r.rating}</span>
|
||||
</span>
|
||||
</div>
|
||||
{r.review_text && (
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{r.review_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
|
||||
{r.visited_at && <span>방문: {r.visited_at}</span>}
|
||||
{r.created_at && <span>{r.created_at.slice(0, 10)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, getToken } from "@/lib/api";
|
||||
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||
import ReviewSection from "@/components/ReviewSection";
|
||||
|
||||
@@ -16,6 +16,8 @@ export default function RestaurantDetail({
|
||||
}: RestaurantDetailProps) {
|
||||
const [videos, setVideos] = useState<VideoLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -24,12 +26,53 @@ export default function RestaurantDetail({
|
||||
.then(setVideos)
|
||||
.catch(() => setVideos([]))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// Load favorite status if logged in
|
||||
if (getToken()) {
|
||||
api.getFavoriteStatus(restaurant.id)
|
||||
.then((r) => setFavorited(r.favorited))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [restaurant.id]);
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!getToken()) return;
|
||||
setFavLoading(true);
|
||||
try {
|
||||
const res = await api.toggleFavorite(restaurant.id);
|
||||
setFavorited(res.favorited);
|
||||
} catch { /* ignore */ }
|
||||
finally { setFavLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<h2 className="text-lg font-bold">{restaurant.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold">{restaurant.name}</h2>
|
||||
{getToken() && (
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
disabled={favLoading}
|
||||
className={`text-xl leading-none transition-colors ${
|
||||
favorited ? "text-red-500" : "text-gray-300 hover:text-red-400"
|
||||
}`}
|
||||
title={favorited ? "찜 해제" : "찜하기"}
|
||||
>
|
||||
{favorited ? "♥" : "♡"}
|
||||
</button>
|
||||
)}
|
||||
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-semibold">
|
||||
폐업
|
||||
</span>
|
||||
)}
|
||||
{restaurant.business_status === "CLOSED_TEMPORARILY" && (
|
||||
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-semibold">
|
||||
임시휴업
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||||
@@ -38,6 +81,16 @@ export default function RestaurantDetail({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{restaurant.rating && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-yellow-500">{"★".repeat(Math.round(restaurant.rating))}</span>
|
||||
<span className="font-medium">{restaurant.rating}</span>
|
||||
{restaurant.rating_count && (
|
||||
<span className="text-gray-400 text-xs">({restaurant.rating_count.toLocaleString()})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
{restaurant.cuisine_type && (
|
||||
<p>
|
||||
@@ -59,6 +112,26 @@ export default function RestaurantDetail({
|
||||
<span className="text-gray-500">가격대:</span> {restaurant.price_range}
|
||||
</p>
|
||||
)}
|
||||
{restaurant.phone && (
|
||||
<p>
|
||||
<span className="text-gray-500">전화:</span>{" "}
|
||||
<a href={`tel:${restaurant.phone}`} className="text-blue-600 hover:underline">
|
||||
{restaurant.phone}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{restaurant.google_place_id && (
|
||||
<p>
|
||||
<a
|
||||
href={`https://www.google.com/maps/place/?q=place_id:${restaurant.google_place_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -71,11 +144,23 @@ export default function RestaurantDetail({
|
||||
<div className="space-y-3">
|
||||
{videos.map((v) => (
|
||||
<div key={v.video_id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{v.channel_name && (
|
||||
<span className="inline-block px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium">
|
||||
{v.channel_name}
|
||||
</span>
|
||||
)}
|
||||
{v.published_at && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{v.published_at.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={v.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-600 hover:underline"
|
||||
className="block text-sm font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
{v.title}
|
||||
</a>
|
||||
@@ -107,6 +192,17 @@ export default function RestaurantDetail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{videos.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg px-4 py-3 text-center space-y-1">
|
||||
<p className="text-xs text-gray-500">
|
||||
더 자세한 내용은 영상에서 확인하실 수 있습니다.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
맛집을 소개해주신 크리에이터를 구독과 좋아요로 응원해주세요!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReviewSection restaurantId={restaurant.id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,18 @@ export default function RestaurantList({
|
||||
{r.region && <span>{r.region}</span>}
|
||||
{r.price_range && <span>{r.price_range}</span>}
|
||||
</div>
|
||||
{r.channels && r.channels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{r.channels.map((ch) => (
|
||||
<span
|
||||
key={ch}
|
||||
className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium"
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -19,18 +19,18 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="식당, 지역, 음식 종류..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
placeholder="식당, 지역, 음식..."
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"
|
||||
className="shrink-0 px-2 py-2 border border-gray-300 rounded-lg text-sm bg-white"
|
||||
>
|
||||
<option value="hybrid">통합</option>
|
||||
<option value="keyword">키워드</option>
|
||||
@@ -39,7 +39,7 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !query.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
|
||||
className="shrink-0 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{isLoading ? "..." : "검색"}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user