Fix login 401, admin permission, video links serialization, and admin UI styling

- Fix UserInfo boolean field naming (isAdmin → admin) for proper Jackson/MyBatis mapping
- Configure Google OAuth audience with actual client ID to fix token verification
- Parse CLOB fields and convert Oracle TIMESTAMP in restaurant video links API
- Add explicit bg-white/text-gray-900 to admin page inputs, selects, and table headers
- Add keyPrefix to RestaurantList to avoid duplicate React keys across desktop/mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-09 21:41:57 +09:00
parent c16add08c3
commit 16bd83c570
9 changed files with 51 additions and 37 deletions

View File

@@ -16,7 +16,7 @@ public class UserInfo {
private String nickname; private String nickname;
private String avatarUrl; private String avatarUrl;
@JsonProperty("is_admin") @JsonProperty("is_admin")
private boolean isAdmin; private boolean admin;
private String provider; private String provider;
private String providerId; private String providerId;
private String createdAt; private String createdAt;

View File

@@ -6,6 +6,7 @@ import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.json.gson.GsonFactory;
import com.tasteby.domain.UserInfo; import com.tasteby.domain.UserInfo;
import com.tasteby.security.JwtTokenProvider; import com.tasteby.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -20,12 +21,13 @@ public class AuthService {
private final JwtTokenProvider jwtProvider; private final JwtTokenProvider jwtProvider;
private final GoogleIdTokenVerifier verifier; private final GoogleIdTokenVerifier verifier;
public AuthService(UserService userService, JwtTokenProvider jwtProvider) { public AuthService(UserService userService, JwtTokenProvider jwtProvider,
@Value("${app.google.client-id}") String googleClientId) {
this.userService = userService; this.userService = userService;
this.jwtProvider = jwtProvider; this.jwtProvider = jwtProvider;
this.verifier = new GoogleIdTokenVerifier.Builder( this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance()) new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(Collections.emptyList()) // Accept any audience .setAudience(Collections.singletonList(googleClientId))
.build(); .build();
} }

View File

@@ -35,7 +35,13 @@ public class RestaurantService {
public List<Map<String, Object>> findVideoLinks(String restaurantId) { public List<Map<String, Object>> findVideoLinks(String restaurantId) {
var rows = mapper.findVideoLinks(restaurantId); var rows = mapper.findVideoLinks(restaurantId);
return rows.stream().map(JsonUtil::lowerKeys).toList(); return rows.stream().map(row -> {
var m = JsonUtil.lowerKeys(row);
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
m.put("evaluation", JsonUtil.parseMap(m.get("evaluation")));
m.put("guests", JsonUtil.parseStringList(m.get("guests")));
return m;
}).toList();
} }
public void update(String id, Map<String, Object> fields) { public void update(String id, Map<String, Object> fields) {

View File

@@ -54,6 +54,7 @@ app:
google: google:
maps-api-key: ${GOOGLE_MAPS_API_KEY} maps-api-key: ${GOOGLE_MAPS_API_KEY}
youtube-api-key: ${YOUTUBE_DATA_API_KEY} youtube-api-key: ${YOUTUBE_DATA_API_KEY}
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
cache: cache:
ttl-seconds: 600 ttl-seconds: 600

View File

@@ -60,7 +60,8 @@
</select> </select>
<select id="findVideoLinks" resultType="map"> <select id="findVideoLinks" resultType="map">
SELECT v.video_id, v.title, v.url, v.published_at, SELECT v.video_id, v.title, v.url,
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
vr.foods_mentioned, vr.evaluation, vr.guests, vr.foods_mentioned, vr.evaluation, vr.guests,
c.channel_name, c.channel_id c.channel_name, c.channel_id
FROM video_restaurants vr FROM video_restaurants vr

View File

@@ -7,7 +7,7 @@
<result property="email" column="email"/> <result property="email" column="email"/>
<result property="nickname" column="nickname"/> <result property="nickname" column="nickname"/>
<result property="avatarUrl" column="avatar_url"/> <result property="avatarUrl" column="avatar_url"/>
<result property="isAdmin" column="is_admin" javaType="boolean"/> <result property="admin" column="is_admin" javaType="boolean"/>
<result property="provider" column="provider"/> <result property="provider" column="provider"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/> <result property="favoriteCount" column="favorite_count"/>

View File

@@ -29,7 +29,7 @@ export default function AdminPage() {
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 text-gray-900">
<header className="bg-white border-b px-6 py-4"> <header className="bg-white border-b px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -133,19 +133,19 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
placeholder="YouTube Channel ID" placeholder="YouTube Channel ID"
value={newId} value={newId}
onChange={(e) => setNewId(e.target.value)} onChange={(e) => setNewId(e.target.value)}
className="border rounded px-3 py-2 flex-1 text-sm" className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
/> />
<input <input
placeholder="채널 이름" placeholder="채널 이름"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
className="border rounded px-3 py-2 flex-1 text-sm" className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
/> />
<input <input
placeholder="제목 필터 (선택)" placeholder="제목 필터 (선택)"
value={newFilter} value={newFilter}
onChange={(e) => setNewFilter(e.target.value)} onChange={(e) => setNewFilter(e.target.value)}
className="border rounded px-3 py-2 w-40 text-sm" className="border rounded px-3 py-2 w-40 text-sm bg-white text-gray-900"
/> />
<button <button
onClick={handleAdd} onClick={handleAdd}
@@ -159,7 +159,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b"> <thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
<tr> <tr>
<th className="text-left px-4 py-3"> </th> <th className="text-left px-4 py-3"> </th>
<th className="text-left px-4 py-3">Channel ID</th> <th className="text-left px-4 py-3">Channel ID</th>
@@ -661,7 +661,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<select <select
value={channelFilter} value={channelFilter}
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }} onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
className="border rounded px-3 py-2 text-sm" className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
> >
<option value=""> </option> <option value=""> </option>
{channels.map((ch) => ( {channels.map((ch) => (
@@ -671,7 +671,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="border rounded px-3 py-2 text-sm" className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
> >
<option value=""> </option> <option value=""> </option>
<option value="pending"></option> <option value="pending"></option>
@@ -687,7 +687,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
value={titleSearch} value={titleSearch}
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }} onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")} onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48" className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
/> />
{titleSearch ? ( {titleSearch ? (
<button <button
@@ -779,7 +779,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]"> <div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b"> <thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
<tr> <tr>
<th className="px-4 py-3 w-8"> <th className="px-4 py-3 w-8">
<input <input
@@ -1187,34 +1187,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[10px] text-gray-500"> *</label> <label className="text-[10px] text-gray-500"> *</label>
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="식당 이름" /> <input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="식당 이름" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="주소 (없으면 지역)" /> <input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="주소 (없으면 지역)" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="서울 강남" /> <input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="서울 강남" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"> </label> <label className="text-[10px] text-gray-500"> </label>
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="한식, 일식..." /> <input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="한식, 일식..." />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="메뉴1, 메뉴2" /> <input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="게스트1, 게스트2" /> <input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="게스트1, 게스트2" />
</div> </div>
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500">/</label> <label className="text-[10px] text-gray-500">/</label>
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" rows={2} placeholder="맛집 평가 내용" /> <textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} placeholder="맛집 평가 내용" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -1257,7 +1257,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<textarea <textarea
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-gray-50" className="w-full border rounded p-2 text-xs font-mono mb-2 bg-white text-gray-900"
rows={12} rows={12}
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)" placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
/> />
@@ -1271,39 +1271,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm" /> <input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-white text-gray-900" />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" /> <input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" /> <input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" /> <input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" /> <input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"> ( )</label> <label className="text-xs text-gray-500"> ( )</label>
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" placeholder="메뉴1, 메뉴2, ..." /> <input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500">/</label> <label className="text-xs text-gray-500">/</label>
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" rows={2} /> <textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"> ( )</label> <label className="text-xs text-gray-500"> ( )</label>
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" /> <input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -1442,7 +1442,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<select <select
value={transcriptMode} value={transcriptMode}
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")} onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
className="border rounded px-2 py-1 text-xs" className="border rounded px-2 py-1 text-xs bg-white text-gray-900"
> >
<option value="auto"> ()</option> <option value="auto"> ()</option>
<option value="manual"> </option> <option value="manual"> </option>
@@ -1598,7 +1598,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
value={nameSearch} value={nameSearch}
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }} onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")} onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48" className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
/> />
{nameSearch ? ( {nameSearch ? (
<button <button
@@ -1624,7 +1624,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="bg-white rounded-lg shadow overflow-auto"> <div className="bg-white rounded-lg shadow overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b"> <thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
<tr> <tr>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>{sortIcon("name")}</th> <th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>{sortIcon("name")}</th>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>{sortIcon("region")}</th> <th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>{sortIcon("region")}</th>
@@ -1706,7 +1706,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<input <input
value={editForm[key] || ""} value={editForm[key] || ""}
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))} onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
className="w-full border rounded px-2 py-1.5 text-sm" className="w-full border rounded px-2 py-1.5 text-sm bg-white text-gray-900"
disabled={!isAdmin} disabled={!isAdmin}
/> />
</div> </div>
@@ -1866,7 +1866,7 @@ function UsersPanel() {
{/* Users Table */} {/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600"> <thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
<tr> <tr>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>

View File

@@ -392,6 +392,7 @@ export default function Home() {
selectedId={selected?.id} selectedId={selected?.id}
onSelect={handleSelectRestaurant} onSelect={handleSelectRestaurant}
loading={loading} loading={loading}
keyPrefix="d-"
/> />
); );
@@ -415,6 +416,7 @@ export default function Home() {
selectedId={selected?.id} selectedId={selected?.id}
onSelect={handleSelectRestaurant} onSelect={handleSelectRestaurant}
loading={loading} loading={loading}
keyPrefix="m-"
/> />
); );

View File

@@ -9,6 +9,7 @@ interface RestaurantListProps {
selectedId?: string; selectedId?: string;
onSelect: (r: Restaurant) => void; onSelect: (r: Restaurant) => void;
loading?: boolean; loading?: boolean;
keyPrefix?: string;
} }
export default function RestaurantList({ export default function RestaurantList({
@@ -16,6 +17,7 @@ export default function RestaurantList({
selectedId, selectedId,
onSelect, onSelect,
loading, loading,
keyPrefix = "",
}: RestaurantListProps) { }: RestaurantListProps) {
if (loading) { if (loading) {
return <RestaurantListSkeleton />; return <RestaurantListSkeleton />;
@@ -33,7 +35,7 @@ export default function RestaurantList({
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
{restaurants.map((r) => ( {restaurants.map((r) => (
<button <button
key={r.id} key={`${keyPrefix}${r.id}`}
onClick={() => onSelect(r)} onClick={() => onSelect(r)}
className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${ className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${
selectedId === r.id selectedId === r.id