profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
DashboardPage.tsx 7,651 bytes
1 import { useQuery } from "@tanstack/react-query";
2 import { ArrowRight, CalendarDays, Check, MapPin, Plus, Share2, Sparkles } from "lucide-react";
3 import { Link } from "react-router-dom";
4 import { DishCard } from "../components/DishCard";
5 import { ProgressRing } from "../components/ProgressRing";
6 import { useQuickTaste } from "../components/QuickTaste";
7 import { ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui";
8 import { WorldMap } from "../components/WorldMap";
9 import { api } from "../lib/api";
10 import { formatDate, formatDateRange } from "../lib/format";
11 import type { Dashboard } from "../types";
12
13 export function DashboardPage() {
14 const { openTaste } = useQuickTaste();
15 const dashboard = useQuery({
16 queryKey: ["dashboard"],
17 queryFn: () => api<Dashboard>("/api/v1/progress/dashboard")
18 });
19
20 if (dashboard.isLoading) return <LoadingScreen />;
21 if (dashboard.isError || !dashboard.data) {
22 return <ErrorState message="Your Tasteprint could not be loaded." action={
23 <button className="button button-outline" onClick={() => void dashboard.refetch()}>Try again</button>
24 } />;
25 }
26
27 const data = dashboard.data;
28 const explored = data.tasteprint.destinations
29 .filter(item => item.coverage > 0)
30 .sort((a, b) => b.coverage - a.coverage)
31 .slice(0, 4);
32
33 return (
34 <div className="page dashboard-page">
35 <PageHeader
36 eyebrow="Personal food map"
37 title={`Good to see you, ${data.user.displayName.split(" ")[0]}.`}
38 copy="Your next meaningful bite is easier to see from here."
39 action={
40 <button className="button button-primary" type="button" onClick={() => openTaste()}>
41 <Plus size={18} /> Log a taste
42 </button>
43 }
44 />
45
46 <section className="tasteprint-ticket">
47 <div className="ticket-heading">
48 <div>
49 <span className="ticket-code">TP / {data.user.shareSlug.toUpperCase()}</span>
50 <span className="eyebrow eyebrow-light">World culinary coverage</span>
51 <h2>{data.tasteprint.stats.worldCoverage}% of the current atlas tasted</h2>
52 </div>
53 <Link className="ticket-share" to="/app/profile"><Share2 size={16} /> Share Tasteprint</Link>
54 </div>
55 <WorldMap destinations={data.tasteprint.destinations} className="dashboard-map" />
56 <div className="ticket-stats">
57 <div><strong>{data.tasteprint.stats.uniqueDishes}</strong><span>Dishes</span></div>
58 <div><strong>{data.tasteprint.stats.countriesTasted}</strong><span>Countries</span></div>
59 <div><strong>{data.tasteprint.stats.regionsTasted}</strong><span>Regions</span></div>
60 <div><strong>{data.tasteprint.stats.totalTastings}</strong><span>Memories</span></div>
61 </div>
62 <div className="ticket-notch ticket-notch-top" />
63 <div className="ticket-notch ticket-notch-bottom" />
64 </section>
65
66 <div className="dashboard-grid">
67 <section className="panel next-trip-panel">
68 <div className="section-heading">
69 <div>
70 <span className="eyebrow">Next food mission</span>
71 <h2>{data.nextTrip ? `${data.nextTrip.city}, ${data.nextTrip.destination.name}` : "No trip planned"}</h2>
72 </div>
73 {data.nextTrip && <StatusPill status={data.nextTrip.status} />}
74 </div>
75 {data.nextTrip ? (
76 <>
77 <div className="trip-meta-row">
78 <span><CalendarDays size={15} /> {formatDateRange(data.nextTrip.startsOn, data.nextTrip.endsOn)}</span>
79 <span>{data.nextTrip.missionCompleted}/{data.nextTrip.mission.length} bites completed</span>
80 </div>
81 <ol className="mission-list">
82 {data.nextTrip.mission.map(item => (
83 <li key={item.dish.slug} className={item.completed ? "mission-complete" : ""}>
84 <span className="mission-number">{item.completed ? <Check size={14} /> : item.position}</span>
85 <span>
86 <strong>{item.dish.name}</strong>
87 <small>{item.dish.categoryLabel}</small>
88 </span>
89 {!item.completed && (
90 <button type="button" onClick={() => openTaste({ dish: item.dish })}>Log</button>
91 )}
92 </li>
93 ))}
94 </ol>
95 <Link className="panel-link" to={`/app/trips/${data.nextTrip.id}`}>
96 Open trip mission <ArrowRight size={16} />
97 </Link>
98 </>
99 ) : (
100 <div className="compact-empty">
101 <MapPin size={22} />
102 <p>Add a destination and Tasteprint will select five culturally different bites.</p>
103 <Link className="button button-outline" to="/app/trips">Plan a trip</Link>
104 </div>
105 )}
106 </section>
107
108 <section className="panel explored-panel">
109 <div className="section-heading">
110 <div>
111 <span className="eyebrow">Your strongest routes</span>
112 <h2>Food cultures started</h2>
113 </div>
114 <Link className="small-link" to="/app/explore">All countries</Link>
115 </div>
116 <div className="coverage-list">
117 {explored.map(item => (
118 <Link key={item.destination.code} to={`/app/destinations/${item.destination.code}`}>
119 <ProgressRing value={item.coverage} size="small" />
120 <span>
121 <strong>{item.destination.name}</strong>
122 <small>{item.triedDishes}/{item.totalDishes} core dishes</small>
123 </span>
124 <ArrowRight size={16} />
125 </Link>
126 ))}
127 </div>
128 {!explored.length && (
129 <div className="compact-empty"><p>Your first dish will start this list.</p></div>
130 )}
131 </section>
132 </div>
133
134 <section className="dashboard-section">
135 <div className="section-heading">
136 <div>
137 <span className="eyebrow">Important gaps</span>
138 <h2>Do not leave these untasted</h2>
139 </div>
140 <span className="section-note"><Sparkles size={15} /> Based on your map and next trip</span>
141 </div>
142 <div className="dish-grid dish-grid-three">
143 {data.tasteprint.importantMissing.slice(0, 3).map(dish => (
144 <DishCard key={dish.slug} dish={dish} compact />
145 ))}
146 </div>
147 </section>
148
149 <section className="dashboard-section recent-section">
150 <div className="section-heading">
151 <div>
152 <span className="eyebrow">Recent passport marks</span>
153 <h2>Meals worth remembering</h2>
154 </div>
155 <Link className="small-link" to="/app/tastings">Full journal</Link>
156 </div>
157 <div className="recent-tastings">
158 {data.tasteprint.recentTastings.slice(0, 4).map(tasting => (
159 <article key={tasting.id}>
160 <div className="recent-photo" style={{ backgroundColor: tasting.dish.destinationAccent }}>
161 {tasting.photoUrl ? <img src={tasting.photoUrl} alt="" /> : <span>{tasting.dish.destinationCode}</span>}
162 </div>
163 <div>
164 <strong>{tasting.dish.name}</strong>
165 <span><MapPin size={13} /> {tasting.city}</span>
166 </div>
167 <time dateTime={tasting.tastedOn}>{formatDate(tasting.tastedOn)}</time>
168 </article>
169 ))}
170 </div>
171 </section>
172 </div>
173 );
174 }
175