feat: room amenities with editable SVG icons
This commit is contained in:
@@ -12,6 +12,7 @@ type Place = { id: number; name: string; description: string; photos: string[];
|
||||
type Review = { id: number; author_name: string; author_email: string | null; rating: number; text: string; approved: boolean };
|
||||
type FooterConfig = { logo: string; favicon: string; tagline: string; facebook_url: string; instagram_url: string; whatsapp_url: string; address: string; phone: string; email: string };
|
||||
type SocialRes = { id: number; event_id: number; username: string; event_name: string; guests: number; notes: string; status: string; created_at: string };
|
||||
type Amenity = { id: number; name: string; icon_svg: string; sort_order: number };
|
||||
|
||||
const C = {
|
||||
gold: '#D4A853', goldLight: 'rgba(212,168,83,0.12)', navy: '#010D1E',
|
||||
@@ -27,6 +28,7 @@ const NAV = [
|
||||
{ id: 'calendar', label: 'Calendar', icon: '📅' },
|
||||
{ id: 'social', label: 'Social Events', icon: '🎪' },
|
||||
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
|
||||
{ id: 'amenities', label: 'Amenities', icon: '✨' },
|
||||
{ id: 'trips', label: 'Trips', icon: '✈️' },
|
||||
{ id: 'messages', label: 'Messages', icon: '💬' },
|
||||
{ id: 'menu', label: 'Menu', icon: '🍽️' },
|
||||
@@ -899,6 +901,134 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Amenities Section ─── */
|
||||
function AmenitiesSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||||
const [amenities, setAmenities] = useState<Amenity[]>([]);
|
||||
const [editing, setEditing] = useState<Amenity | null>(null);
|
||||
const [confirmDel, setConfirmDel] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/amenities')
|
||||
.then(r => r.json())
|
||||
.then(d => setAmenities(d.data || []))
|
||||
.catch(() => onToast('Failed to load amenities', 'error'));
|
||||
}, []);
|
||||
|
||||
const save = async (amenity: Amenity) => {
|
||||
try {
|
||||
const url = amenity.id ? `/api/amenities?id=${amenity.id}` : '/api/amenities';
|
||||
const res = await fetch(url, {
|
||||
method: amenity.id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(amenity),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (amenity.id) {
|
||||
setAmenities(prev => prev.map(a => a.id === amenity.id ? data.data : a));
|
||||
} else {
|
||||
setAmenities(prev => [...prev, data.data]);
|
||||
}
|
||||
setEditing(null);
|
||||
onToast('Amenity saved!', 'success');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
onToast(err.error || 'Failed to save', 'error');
|
||||
}
|
||||
} catch { onToast('Error', 'error'); }
|
||||
};
|
||||
|
||||
const del = async (id: number) => {
|
||||
await fetch(`/api/amenities?id=${id}`, { method: 'DELETE' });
|
||||
setAmenities(prev => prev.filter(a => a.id !== id));
|
||||
onToast('Amenity deleted', 'success');
|
||||
setConfirmDel(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SecHead title="✨ Room Amenities" count={amenities.length}
|
||||
action={<Btn onClick={() => setEditing({ id: 0, name: '', icon_svg: '', sort_order: amenities.length })} size="sm">+ New Amenity</Btn>} />
|
||||
{amenities.length === 0 ? <Empty icon="✨" title="No amenities" subtitle="Add icons for room features like Wi-Fi, TV, etc." /> : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
{amenities.sort((a, b) => a.sort_order - b.sort_order).map((a: Amenity) => (
|
||||
<div key={a.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{ width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.navy }}
|
||||
dangerouslySetInnerHTML={{ __html: a.icon_svg.replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/width="24"/g, 'width="32"').replace(/height="24"/g, 'height="32"') }} />
|
||||
<div style={{ fontWeight: 600, color: C.navy, textAlign: 'center' }}>{a.name}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Btn size="sm" variant="secondary" onClick={() => setEditing(a)}>Edit</Btn>
|
||||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(a.id)}>Del</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editing && <AmenityEditor amenity={editing} setAmenity={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
|
||||
{confirmDel !== null && <Confirm message="Delete this amenity?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AmenityEditor({ amenity, setAmenity, onSave, onCancel }: any) {
|
||||
const defaultIcons = [
|
||||
{ name: 'Wi-Fi', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>' },
|
||||
{ name: 'TV', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>' },
|
||||
{ name: 'Shower', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V4"/><path d="M4 9h4"/><path d="M6 9v12"/><path d="M10 4h10a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H10"/><circle cx="6" cy="14" r="1"/><circle cx="6" cy="18" r="1"/><circle cx="10" cy="14" r="1"/><circle cx="10" cy="18" r="1"/></svg>' },
|
||||
{ name: 'Bathtub', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16a1 1 0 0 1 1 1v3a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4v-3a1 1 0 0 1 1-1z"/><path d="M6 12V5a2 2 0 0 1 2-2h3v2.25"/><circle cx="12" cy="5" r=".5"/></svg>' },
|
||||
{ name: 'Fireplace', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.14-4.052 1.5-5 .5 2 2 4 2 6a2.5 2.5 0 0 0 5 0c0-1.38-.5-2-1-3-.5-1-1-2-1-3.5 1.5 1 3 2.5 3 5a4 4 0 1 1-8 0"/><path d="M4 19h16a2 2 0 0 0 2-2v-2H2v2a2 2 0 0 0 2 2z"/></svg>' },
|
||||
{ name: 'Minibar', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3z"/><path d="M5 9v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>' },
|
||||
{ name: 'Coffee', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>' },
|
||||
{ name: 'Safe', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>' },
|
||||
{ name: 'Heating', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>' },
|
||||
{ name: 'Balcony', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><line x1="3" y1="17" x2="21" y2="17"/><line x1="7" y1="11" x2="7" y2="21"/><line x1="12" y1="11" x2="12" y2="21"/><line x1="17" y1="11" x2="17" y2="21"/></svg>' },
|
||||
{ name: 'King Bed', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4"/><path d="M2 14v-2a2 2 0 0 1 2-2h1"/><path d="M22 14v-2a2 2 0 0 0-2-2h-1"/><path d="M5 4h14a2 2 0 0 1 2 2v2H3V6a2 2 0 0 1 2-2z"/><rect x="3" y="10" width="18" height="4" rx="1"/></svg>' },
|
||||
{ name: 'Mountain View', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>' },
|
||||
{ name: 'Parking', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><path d="M9 17V7h4a3 3 0 0 1 0 6H9"/></svg>' },
|
||||
{ name: 'Restaurant', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/></svg>' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 600, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{amenity.id ? 'Edit Amenity' : 'New Amenity'}</h3>
|
||||
<div style={{ display: 'grid', gap: '14px' }}>
|
||||
<I label="Name" value={amenity.name} onChange={(v: string) => setAmenity({ ...amenity, name: v })} placeholder="e.g., Wi-Fi, TV, Shower" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Icon (SVG)</label>
|
||||
<textarea value={amenity.icon_svg} onChange={e => setAmenity({ ...amenity, icon_svg: e.target.value })}
|
||||
rows={4} placeholder="<svg>...</svg>"
|
||||
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '13px', fontFamily: 'monospace', outline: 'none', background: '#FAFAFA', resize: 'vertical' }} />
|
||||
{amenity.icon_svg && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 64, height: 64, background: C.bg, borderRadius: 8, marginTop: '8px' }}
|
||||
dangerouslySetInnerHTML={{ __html: amenity.icon_svg.replace(/stroke="currentColor"/g, `stroke="${C.navy}"`).replace(/width="24"/g, 'width="48"').replace(/height="24"/g, 'height="48"') }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Quick Icons</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{defaultIcons.map(icon => (
|
||||
<button key={icon.name} onClick={() => setAmenity({ ...amenity, name: icon.name, icon_svg: icon.svg })}
|
||||
style={{ padding: '6px 10px', borderRadius: 6, border: `1px solid ${C.border}`, background: '#fff', cursor: 'pointer', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px', transition: 'all 150ms' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = C.gold; e.currentTarget.style.background = C.goldLight; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = C.border; e.currentTarget.style.background = '#fff'; }}>
|
||||
<span style={{ width: 18, height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }} dangerouslySetInnerHTML={{ __html: icon.svg.replace(/width="24"/g, 'width="18"').replace(/height="24"/g, 'height="18"') }} />
|
||||
{icon.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<I label="Sort Order" type="number" value={amenity.sort_order} onChange={(v: string) => setAmenity({ ...amenity, sort_order: parseInt(v) || 0 })} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
|
||||
<Btn onClick={() => onSave(amenity)}>Save</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Trips Section ─── */
|
||||
type Trip = { id: number; user_id: number; username: string; room_id: number | null; room_name: string | null; check_in: string; check_out: string; notes: string | null; status: string };
|
||||
|
||||
@@ -2064,6 +2194,7 @@ export default function AdminPage() {
|
||||
case 'calendar': return <CalendarSection events={events} setEvents={setEvents} images={images} onToast={showToast} />;
|
||||
case 'social': return <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
|
||||
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} onToast={showToast} />;
|
||||
case 'amenities': return <AmenitiesSection onToast={showToast} />;
|
||||
case 'menu': return <MenuSection items={menuItems} setItems={setMenuItems} images={images} onToast={showToast} />;
|
||||
case 'places': return <PlacesSection places={places} setPlaces={setPlaces} images={images} onToast={showToast} />;
|
||||
case 'reviews': return <ReviewsSection reviews={reviews} setReviews={setReviews} onToast={showToast} />;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, name, icon_svg, sort_order FROM room_amenities ORDER BY sort_order, name'
|
||||
);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch amenities' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { name, icon_svg, sort_order } = await request.json();
|
||||
|
||||
if (!name || !icon_svg) {
|
||||
return NextResponse.json({ error: 'Name and icon_svg are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO room_amenities (name, icon_svg, sort_order) VALUES ($1, $2, $3) RETURNING id, name, icon_svg, sort_order',
|
||||
[name, icon_svg, sort_order || 0]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create amenity:', error);
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json({ error: 'Amenity with this name already exists' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to create amenity' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { id, name, icon_svg, sort_order } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'UPDATE room_amenities SET name = $1, icon_svg = $2, sort_order = $3 WHERE id = $4 RETURNING id, name, icon_svg, sort_order',
|
||||
[name, icon_svg, sort_order || 0, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Amenity not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update amenity:', error);
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json({ error: 'Amenity with this name already exists' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to update amenity' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM room_amenities WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete amenity:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete amenity' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// GET room's amenities
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const roomId = searchParams.get('room_id');
|
||||
|
||||
try {
|
||||
if (roomId) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT a.id, a.name, a.icon_svg, a.sort_order
|
||||
FROM room_amenities a
|
||||
JOIN room_amenity_links r ON a.id = r.amenity_id
|
||||
WHERE r.room_id = $1
|
||||
ORDER BY a.sort_order, a.name`,
|
||||
[roomId]
|
||||
);
|
||||
return NextResponse.json({ data: rows });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, name, icon_svg, sort_order FROM room_amenities ORDER BY sort_order, name'
|
||||
);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch amenities' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Link amenities to a room (replaces all links)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { room_id, amenity_ids } = await request.json();
|
||||
|
||||
if (!room_id || !Array.isArray(amenity_ids)) {
|
||||
return NextResponse.json({ error: 'room_id and amenity_ids array are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete existing links
|
||||
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [room_id]);
|
||||
|
||||
// Insert new links
|
||||
if (amenity_ids.length > 0) {
|
||||
const values = amenity_ids.map((_, i) => `($1, $${i + 2})`).join(', ');
|
||||
await db.query(
|
||||
`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`,
|
||||
[room_id, ...amenity_ids]
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to link amenities:', error);
|
||||
return NextResponse.json({ error: 'Failed to link amenities' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,24 @@ export async function initSchema() {
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_amenities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
icon_svg TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS room_amenity_links (
|
||||
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
amenity_id INTEGER REFERENCES room_amenities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (room_id, amenity_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
export async function migrateFromLegacyPlaces() {
|
||||
@@ -364,6 +382,31 @@ export async function seed() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: amenityRows } = await db.query(`SELECT id FROM room_amenities`);
|
||||
if (amenityRows.length === 0) {
|
||||
const defaultAmenities = [
|
||||
['Wi-Fi', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>', 1],
|
||||
['TV', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>', 2],
|
||||
['Shower', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V4"/><path d="M4 9h4"/><path d="M6 9v12"/><path d="M10 4h10a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H10"/><circle cx="6" cy="14" r="1"/><circle cx="6" cy="18" r="1"/><circle cx="10" cy="14" r="1"/><circle cx="10" cy="18" r="1"/></svg>', 3],
|
||||
['Bathtub', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16a1 1 0 0 1 1 1v3a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4v-3a1 1 0 0 1 1-1z"/><path d="M6 12V5a2 2 0 0 1 2-2h3v2.25"/><circle cx="12" cy="5" r=".5"/></svg>', 4],
|
||||
['Fireplace', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.14-4.052 1.5-5 .5 2 2 4 2 6a2.5 2.5 0 0 0 5 0c0-1.38-.5-2-1-3-.5-1-1-2-1-3.5 1.5 1 3 2.5 3 5a4 4 0 1 1-8 0"/><path d="M4 19h16a2 2 0 0 0 2-2v-2H2v2a2 2 0 0 0 2 2z"/></svg>', 5],
|
||||
['Minibar', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3z"/><path d="M5 9v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>', 6],
|
||||
['Coffee Maker', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>', 7],
|
||||
['Safe', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>', 8],
|
||||
['Heating', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>', 9],
|
||||
['Balcony', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><line x1="3" y1="17" x2="21" y2="17"/><line x1="7" y1="11" x2="7" y2="21"/><line x1="12" y1="11" x2="12" y2="21"/><line x1="17" y1="11" x2="17" y2="21"/></svg>', 10],
|
||||
['King Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4"/><path d="M2 14v-2a2 2 0 0 1 2-2h1"/><path d="M22 14v-2a2 2 0 0 0-2-2h-1"/><path d="M5 4h14a2 2 0 0 1 2 2v2H3V6a2 2 0 0 1 2-2z"/><rect x="3" y="10" width="18" height="4" rx="1"/></svg>', 11],
|
||||
['Mountain View', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>', 12],
|
||||
];
|
||||
|
||||
for (const [name, icon_svg, sort_order] of defaultAmenities) {
|
||||
await db.query(
|
||||
'INSERT INTO room_amenities (name, icon_svg, sort_order) VALUES ($1, $2, $3)',
|
||||
[name, icon_svg, sort_order]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetDatabase() {
|
||||
|
||||
Reference in New Issue
Block a user