Skip to content

Commit ef997e6

Browse files
Add vacation-rentals example with blog-post-snippets (#78)
* Add vacation-rentals example with blog-post-snippets Adds the Vacation Rental Operations demo app (Wanderbricks) under examples/vacation-rentals/template/ following the existing convention used by rag-chat, saas-tracker, etc. blog-post-snippets/ mirrors the public reference repo github.com/jamesbroadhead/appkit-blog-snippets, which is linked from the AppKit blog walkthrough so readers can curl individual files. Workspace IDs in databricks.yml replaced with placeholders. Co-authored-by: Isaac * Rename wanderbricks-ops deploy/bundle name to vacation-rentals Aligns the package, bundle, and app names with the example directory and devhub naming convention. Internal "Wanderbricks" brand (Genie space, chat component) and the sample dataset references are kept intact. Co-authored-by: Isaac
1 parent 3aba644 commit ef997e6

60 files changed

Lines changed: 18213 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# AppKit Blog Snippets
2+
3+
Code snippets for the "Building a Vacation Rental Operations App with AppKit" blog post.
4+
5+
These files are referenced directly from the blog via raw GitHub URLs. You don't need to clone this repo — use `curl` and the Databricks CLI to pull what you need.
6+
7+
## Quick start (with a coding agent)
8+
9+
```bash
10+
databricks experimental aitools install
11+
curl -sL https://raw.githubusercontent.com/jamesbroadhead/appkit-blog-snippets/master/setup/agent_prompt.md | claude -p
12+
```
13+
14+
## Manual walkthrough
15+
16+
See the blog post for step-by-step instructions. Key files:
17+
18+
| File | Purpose |
19+
| ------------------------------------------- | ------------------------------------------------------------ |
20+
| `setup/configure_env.sh` | Auto-detect host, warehouse, and create Genie space → `.env` |
21+
| `setup/agent_prompt.md` | Full prompt for AI coding agents |
22+
| `config/queries/revenue_by_destination.sql` | Revenue analytics query |
23+
| `config/queries/booking_detail.sql` | Single booking lookup query |
24+
| `server/server.ts` | AppKit server with Lakebase routes for flags and notes |
25+
| `client/src/*.tsx` | React components |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
Card,
3+
CardHeader,
4+
CardTitle,
5+
CardContent,
6+
} from "@databricks/appkit-ui/react";
7+
import { RevenueByDestination } from "./RevenueByDestination";
8+
import { RevenueChart } from "./RevenueChart";
9+
import { BookingManager } from "./BookingManager";
10+
import { WanderbricksChat } from "./WanderbricksChat";
11+
12+
export default function App() {
13+
return (
14+
<div className="max-w-6xl mx-auto p-6 space-y-6">
15+
<h1 className="text-2xl font-bold">Wanderbricks Operations</h1>
16+
17+
<div className="grid grid-cols-2 gap-6">
18+
<Card>
19+
<CardHeader>
20+
<CardTitle>Revenue by Destination</CardTitle>
21+
</CardHeader>
22+
<CardContent>
23+
<RevenueByDestination />
24+
</CardContent>
25+
</Card>
26+
<Card>
27+
<CardHeader>
28+
<CardTitle>Revenue Distribution</CardTitle>
29+
</CardHeader>
30+
<CardContent>
31+
<RevenueChart />
32+
</CardContent>
33+
</Card>
34+
</div>
35+
36+
<div className="grid grid-cols-2 gap-6">
37+
<Card>
38+
<CardHeader>
39+
<CardTitle>Booking Manager</CardTitle>
40+
</CardHeader>
41+
<CardContent>
42+
<BookingManager />
43+
</CardContent>
44+
</Card>
45+
<Card>
46+
<CardHeader>
47+
<CardTitle>Ask about the data</CardTitle>
48+
</CardHeader>
49+
<CardContent>
50+
<WanderbricksChat />
51+
</CardContent>
52+
</Card>
53+
</div>
54+
</div>
55+
);
56+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { useState, useMemo } from "react";
2+
import {
3+
useAnalyticsQuery,
4+
Card,
5+
CardHeader,
6+
CardTitle,
7+
CardContent,
8+
Skeleton,
9+
} from "@databricks/appkit-ui/react";
10+
import { sql } from "@databricks/appkit-ui/js";
11+
12+
export function BookingManager() {
13+
const [bookingId, setBookingId] = useState("");
14+
const [searchId, setSearchId] = useState<string | null>(null);
15+
const [flag, setFlag] = useState<any>(null);
16+
const [notes, setNotes] = useState<any[]>([]);
17+
const [newNote, setNewNote] = useState("");
18+
19+
const params = useMemo(
20+
() => (searchId ? { bookingId: sql.number(Number(searchId)) } : undefined),
21+
[searchId],
22+
);
23+
const { data, loading, error } = useAnalyticsQuery("booking_detail", params, {
24+
autoStart: !!searchId,
25+
});
26+
27+
const booking = data?.[0] ?? null;
28+
29+
const handleLookup = async () => {
30+
setSearchId(bookingId);
31+
const [flagRes, notesRes] = await Promise.all([
32+
fetch(`/api/bookings/${bookingId}/flag`),
33+
fetch(`/api/bookings/${bookingId}/notes`),
34+
]);
35+
setFlag(await flagRes.json());
36+
setNotes(await notesRes.json());
37+
};
38+
39+
const handleFlag = async () => {
40+
if (flag) {
41+
await fetch(`/api/bookings/${bookingId}/flag`, { method: "DELETE" });
42+
setFlag(null);
43+
} else {
44+
const res = await fetch(`/api/bookings/${bookingId}/flag`, {
45+
method: "POST",
46+
headers: { "Content-Type": "application/json" },
47+
body: JSON.stringify({ reason: "flagged for review" }),
48+
});
49+
setFlag(await res.json());
50+
}
51+
};
52+
53+
const handleAddNote = async () => {
54+
const res = await fetch(`/api/bookings/${bookingId}/notes`, {
55+
method: "POST",
56+
headers: { "Content-Type": "application/json" },
57+
body: JSON.stringify({ note: newNote }),
58+
});
59+
const created = await res.json();
60+
setNotes([created, ...notes]);
61+
setNewNote("");
62+
};
63+
64+
return (
65+
<div className="space-y-4">
66+
<div className="flex gap-2">
67+
<input
68+
className="border rounded px-3 py-1.5 text-sm"
69+
placeholder="Booking ID"
70+
value={bookingId}
71+
onChange={(e) => setBookingId(e.target.value)}
72+
/>
73+
<button
74+
className="bg-primary text-primary-foreground px-4 py-1.5 rounded text-sm"
75+
onClick={handleLookup}
76+
>
77+
Look up
78+
</button>
79+
</div>
80+
81+
{loading && <Skeleton className="h-48 w-full" />}
82+
{error && <p className="text-destructive">{error}</p>}
83+
84+
{booking && (
85+
<Card>
86+
<CardHeader>
87+
<CardTitle>{booking.property_title}</CardTitle>
88+
</CardHeader>
89+
<CardContent>
90+
<div className="text-sm text-muted-foreground space-y-1">
91+
<p>
92+
{booking.guest_name} · {booking.guest_email}
93+
</p>
94+
<p>
95+
{booking.destination} · {booking.guests_count} guests
96+
</p>
97+
<p>
98+
{booking.check_in}{booking.check_out} · {booking.status}
99+
</p>
100+
<p className="font-medium">${booking.total_amount}</p>
101+
</div>
102+
103+
<button
104+
className={`mt-3 px-3 py-1 rounded text-sm ${
105+
flag
106+
? "bg-destructive text-destructive-foreground"
107+
: "bg-secondary text-secondary-foreground"
108+
}`}
109+
onClick={handleFlag}
110+
>
111+
{flag ? "Unflag" : "Flag for review"}
112+
</button>
113+
114+
<div className="mt-4 space-y-2">
115+
<h4 className="text-sm font-medium">Notes</h4>
116+
<div className="flex gap-2">
117+
<input
118+
className="border rounded px-3 py-1.5 text-sm flex-1"
119+
placeholder="Add a note..."
120+
value={newNote}
121+
onChange={(e) => setNewNote(e.target.value)}
122+
/>
123+
<button
124+
className="bg-primary text-primary-foreground px-4 py-1.5 rounded text-sm"
125+
onClick={handleAddNote}
126+
>
127+
Add
128+
</button>
129+
</div>
130+
{notes.map((n) => (
131+
<div key={n.note_id} className="text-sm border-l-2 pl-3 py-1">
132+
<p>{n.note}</p>
133+
<p className="text-muted-foreground text-xs">
134+
{n.agent_email} · {new Date(n.created_at).toLocaleString()}
135+
</p>
136+
</div>
137+
))}
138+
</div>
139+
</CardContent>
140+
</Card>
141+
)}
142+
</div>
143+
);
144+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useAnalyticsQuery, Skeleton } from "@databricks/appkit-ui/react";
2+
import { sql } from "@databricks/appkit-ui/js";
3+
import { useMemo } from "react";
4+
5+
export function RevenueByDestination() {
6+
const params = useMemo(() => ({ limit: sql.number(10) }), []);
7+
const { data, loading, error } = useAnalyticsQuery(
8+
"revenue_by_destination",
9+
params,
10+
);
11+
12+
if (loading) return <Skeleton className="h-48 w-full" />;
13+
if (error) return <p className="text-destructive">{error}</p>;
14+
if (!data?.length)
15+
return <p className="text-muted-foreground">No data found.</p>;
16+
17+
return (
18+
<table className="w-full text-sm">
19+
<thead>
20+
<tr className="border-b text-left">
21+
<th className="p-2">Destination</th>
22+
<th className="p-2">Country</th>
23+
<th className="p-2">Bookings</th>
24+
<th className="p-2">Revenue</th>
25+
<th className="p-2">Avg Rating</th>
26+
</tr>
27+
</thead>
28+
<tbody>
29+
{data.map((row) => (
30+
<tr key={row.destination} className="border-b">
31+
<td className="p-2">{row.destination}</td>
32+
<td className="p-2">{row.country}</td>
33+
<td className="p-2">{row.total_bookings.toLocaleString()}</td>
34+
<td className="p-2">${row.total_revenue.toLocaleString()}</td>
35+
<td className="p-2">{row.avg_rating}</td>
36+
</tr>
37+
))}
38+
</tbody>
39+
</table>
40+
);
41+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { BarChart } from "@databricks/appkit-ui/react";
2+
import { sql } from "@databricks/appkit-ui/js";
3+
import { useMemo } from "react";
4+
5+
export function RevenueChart() {
6+
const params = useMemo(() => ({ limit: sql.number(10) }), []);
7+
8+
return (
9+
<BarChart
10+
queryKey="revenue_by_destination"
11+
parameters={params}
12+
xKey="destination"
13+
yKey="total_revenue"
14+
/>
15+
);
16+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { GenieChat } from "@databricks/appkit-ui/react";
2+
3+
export function WanderbricksChat() {
4+
return (
5+
<div style={{ height: 500 }}>
6+
<GenieChat alias="wanderbricks" />
7+
</div>
8+
);
9+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- @param bookingId NUMERIC
2+
3+
SELECT
4+
b.booking_id, b.status, b.check_in, b.check_out,
5+
b.guests_count, b.total_amount,
6+
u.name AS guest_name, u.email AS guest_email,
7+
p.title AS property_title, d.destination
8+
FROM samples.wanderbricks.bookings b
9+
JOIN samples.wanderbricks.users u ON b.user_id = u.user_id
10+
JOIN samples.wanderbricks.properties p ON b.property_id = p.property_id
11+
JOIN samples.wanderbricks.destinations d ON p.destination_id = d.destination_id
12+
WHERE b.booking_id = :bookingId
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- @param limit NUMERIC
2+
3+
SELECT
4+
d.destination,
5+
d.country,
6+
COUNT(DISTINCT b.booking_id) AS total_bookings,
7+
ROUND(SUM(b.total_amount), 2) AS total_revenue,
8+
ROUND(AVG(r.rating), 1) AS avg_rating
9+
FROM samples.wanderbricks.bookings b
10+
JOIN samples.wanderbricks.properties p ON b.property_id = p.property_id
11+
JOIN samples.wanderbricks.destinations d ON p.destination_id = d.destination_id
12+
LEFT JOIN samples.wanderbricks.reviews r ON b.booking_id = r.booking_id
13+
GROUP BY d.destination, d.country
14+
ORDER BY total_revenue DESC
15+
-- sql.number() produces NUMERIC; Spark's LIMIT requires INT, so cast.
16+
LIMIT CAST(:limit AS INT)

0 commit comments

Comments
 (0)