Skip to content

Commit 3446415

Browse files
authored
Merge pull request #9199 from Couchers-org/na/web/public-trips-offerhost
Public trips - Build offer to host - step 1
2 parents 47f2341 + 0a940bb commit 3446415

10 files changed

Lines changed: 493 additions & 14 deletions

File tree

app/backend/src/couchers/servicers/public_trips.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ def public_trip_to_pb(
8282
.where(HostRequest.public_trip_id == public_trip.id)
8383
.where(HostRequest.status != HostRequestStatus.cancelled)
8484
).scalar_one()
85+
else:
86+
# The viewer's own existing offer on this trip (if any), so the client can
87+
# show an "already offered" state and link to the thread.
88+
pb.viewer_host_request_id = (
89+
session.execute(
90+
select(HostRequest.conversation_id)
91+
.where(HostRequest.public_trip_id == public_trip.id)
92+
.where(HostRequest.initiator_user_id == context.user_id)
93+
.where(HostRequest.status != HostRequestStatus.cancelled)
94+
).scalar_one_or_none()
95+
or 0
96+
)
8597
return pb
8698

8799

app/backend/src/tests/test_public_trips.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,3 +983,46 @@ def test_list_public_trips_by_user_offers_count_not_set_for_others(db):
983983
res = api.ListPublicTripsByUser(public_trips_pb2.ListPublicTripsByUserReq(user_id=traveler.id))
984984
assert len(res.public_trips) == 1
985985
assert not res.public_trips[0].HasField("offers_count")
986+
987+
988+
def test_viewer_host_request_id_reflects_viewers_own_offer(db):
989+
traveler, _ = generate_user()
990+
host, host_token = generate_user()
991+
_, other_token = generate_user()
992+
node_id = _make_node()
993+
994+
trip_id = _create_trip_directly(traveler.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10))
995+
996+
# Before offering, the host sees 0.
997+
with public_trips_session(host_token) as api:
998+
res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
999+
trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1000+
assert trip.viewer_host_request_id == 0
1001+
1002+
# The host makes an offer on the trip.
1003+
with requests_session(host_token) as api:
1004+
create_res = api.CreateHostRequest(
1005+
requests_pb2.CreateHostRequestReq(
1006+
host_user_id=traveler.id,
1007+
from_date=(today() + timedelta(days=5)).isoformat(),
1008+
to_date=(today() + timedelta(days=10)).isoformat(),
1009+
text=_valid_request_text(),
1010+
public_trip_id=trip_id,
1011+
)
1012+
)
1013+
host_request_id = create_res.host_request_id
1014+
1015+
with public_trips_session(host_token) as api:
1016+
# The offering host now sees their own host request id (List and Get).
1017+
res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1018+
trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1019+
assert trip.viewer_host_request_id == host_request_id
1020+
1021+
get_res = api.GetPublicTrip(public_trips_pb2.GetPublicTripReq(trip_id=trip_id))
1022+
assert get_res.viewer_host_request_id == host_request_id
1023+
1024+
# A different viewer who hasn't offered still sees 0.
1025+
with public_trips_session(other_token) as api:
1026+
res = api.ListPublicTrips(public_trips_pb2.ListPublicTripsReq(community_id=node_id))
1027+
trip = next(t for t in res.public_trips if t.trip_id == trip_id)
1028+
assert trip.viewer_host_request_id == 0

app/proto/public_trips.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ message PublicTrip {
6363
optional int32 offers_count = 11;
6464
// Display name of the community this trip belongs to
6565
string community_name = 12;
66+
// The id of the viewer's own non-cancelled host request (offer) on this trip,
67+
// or 0 if they haven't offered. Lets the client show an "already offered"
68+
// state and link to the existing thread. Not set for the trip owner.
69+
int64 viewer_host_request_id = 13;
6670
}
6771

6872
message CreatePublicTripReq {

app/web/components/Datepicker.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ const Datepicker = ({
180180
"aria-label": ariaLabel,
181181
},
182182
},
183+
// Shrink the calendar button so its circular hover/ripple stays
184+
// within the input's content box instead of overlapping the
185+
// (standard variant) underline.
186+
openPickerButton: {
187+
size: "small",
188+
},
183189
},
184190
})}
185191
/>

app/web/components/Icons/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { default as CalendarIcon } from "@mui/icons-material/EventOutlined";
1515
export { default as ChatBubbleIcon } from "@mui/icons-material/ChatBubble";
1616
export { default as CheckCircleIcon } from "@mui/icons-material/CheckCircle";
1717
export { default as CheckIcon } from "@mui/icons-material/CheckOutlined";
18+
export { default as ChevronRightIcon } from "@mui/icons-material/ChevronRightOutlined";
1819
export { default as ClockIcon } from "@mui/icons-material/ScheduleOutlined";
1920
export {
2021
default as CloseIcon,
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { styled } from "@mui/material";
2+
import { useMutation, useQueryClient } from "@tanstack/react-query";
3+
import Alert from "components/Alert";
4+
import Button from "components/Button";
5+
import Datepicker from "components/Datepicker";
6+
import {
7+
Dialog,
8+
DialogActions,
9+
DialogContent,
10+
DialogTitle,
11+
} from "components/Dialog";
12+
import TextField from "components/TextField";
13+
import {
14+
publicTripsBaseKey,
15+
publicTripsByUserBaseKey,
16+
} from "features/queryKeys";
17+
import { useTranslation } from "i18n";
18+
import { GLOBAL, PUBLIC_TRIPS } from "i18n/namespaces";
19+
import { useRouter } from "next/router";
20+
import { useForm } from "react-hook-form";
21+
import { routeToHostRequest } from "routes";
22+
import { service } from "service";
23+
import { theme } from "theme";
24+
import dayjs, { Dayjs } from "utils/dayjs";
25+
26+
// Must match the backend host request minimum (and normal host requests).
27+
const MESSAGE_MIN_LENGTH = 250;
28+
29+
const DATE_FIELD_ID = "offer-to-host-dates";
30+
31+
interface FormValues {
32+
fromDate: Dayjs | null;
33+
toDate: Dayjs | null;
34+
text: string;
35+
}
36+
37+
interface OfferToHostDialogProps {
38+
open: boolean;
39+
onClose: () => void;
40+
tripId: number;
41+
hostUserId: number;
42+
hostName: string;
43+
tripFromDate: string;
44+
tripToDate: string;
45+
}
46+
47+
const FieldStack = styled("div")(({ theme }) => ({
48+
display: "flex",
49+
flexDirection: "column",
50+
gap: theme.spacing(2),
51+
}));
52+
53+
const DateRow = styled("div")(({ theme }) => ({
54+
display: "flex",
55+
gap: theme.spacing(2),
56+
[theme.breakpoints.down("sm")]: {
57+
flexDirection: "column",
58+
},
59+
}));
60+
61+
export default function OfferToHostDialog({
62+
open,
63+
onClose,
64+
tripId,
65+
hostUserId,
66+
hostName,
67+
tripFromDate,
68+
tripToDate,
69+
}: OfferToHostDialogProps) {
70+
const { t } = useTranslation([PUBLIC_TRIPS, GLOBAL]);
71+
const router = useRouter();
72+
const queryClient = useQueryClient();
73+
74+
const tripFrom = dayjs(tripFromDate);
75+
const tripTo = dayjs(tripToDate);
76+
const today = dayjs().startOf("day");
77+
// The host can offer within the trip's window (shorten, not extend), and
78+
// never in the past. The backend enforces these too.
79+
const earliest = tripFrom.isAfter(today) ? tripFrom : today;
80+
81+
const {
82+
control,
83+
handleSubmit,
84+
register,
85+
reset,
86+
watch,
87+
formState: { errors },
88+
} = useForm<FormValues>({
89+
mode: "onBlur",
90+
defaultValues: { fromDate: tripFrom, toDate: tripTo, text: "" },
91+
});
92+
93+
const watchFromDate = watch("fromDate");
94+
const text = watch("text") ?? "";
95+
const charsRemaining = MESSAGE_MIN_LENGTH - text.length;
96+
97+
const {
98+
mutate,
99+
isPending,
100+
error,
101+
reset: resetMutation,
102+
} = useMutation({
103+
mutationFn: ({ fromDate, toDate, text }: FormValues) =>
104+
service.requests.createHostRequest({
105+
hostUserId,
106+
// Both are required by the form, so non-null at submit time.
107+
fromDate: fromDate!,
108+
toDate: toDate!,
109+
text: text.trim(),
110+
publicTripId: tripId,
111+
stayType: 0,
112+
}),
113+
onSuccess: (hostRequestId) => {
114+
// Refetch trips so the card's viewerHostRequestId updates (the button
115+
// flips to "Already offered") next time the list is shown.
116+
queryClient.invalidateQueries({ queryKey: [publicTripsBaseKey] });
117+
queryClient.invalidateQueries({ queryKey: [publicTripsByUserBaseKey] });
118+
reset();
119+
onClose();
120+
router.push(routeToHostRequest(hostRequestId));
121+
},
122+
});
123+
124+
const handleClose = () => {
125+
reset();
126+
resetMutation();
127+
onClose();
128+
};
129+
130+
const onSubmit = handleSubmit((data) => mutate(data));
131+
132+
const titleId = "offer-to-host-dialog-title";
133+
const formId = "offer-to-host-form";
134+
135+
return (
136+
<Dialog aria-labelledby={titleId} open={open} onClose={handleClose}>
137+
<DialogTitle id={titleId} onClose={handleClose}>
138+
{t("publicTrips:offer_dialog_title", { name: hostName })}
139+
</DialogTitle>
140+
<DialogContent>
141+
{error && (
142+
<Alert severity="error" sx={{ mb: 2 }}>
143+
{error.message}
144+
</Alert>
145+
)}
146+
<form id={formId} onSubmit={onSubmit} noValidate>
147+
<FieldStack>
148+
<DateRow>
149+
<Datepicker
150+
control={control}
151+
error={!!errors.fromDate}
152+
helperText={errors.fromDate?.message}
153+
id={`${DATE_FIELD_ID}-from`}
154+
label={t("publicTrips:from_date_label")}
155+
name="fromDate"
156+
defaultValue={tripFrom}
157+
minDate={earliest}
158+
maxDate={tripTo}
159+
rules={{ required: t("publicTrips:from_date_required") }}
160+
/>
161+
<Datepicker
162+
control={control}
163+
error={!!errors.toDate}
164+
helperText={errors.toDate?.message}
165+
id={`${DATE_FIELD_ID}-to`}
166+
label={t("publicTrips:to_date_label")}
167+
name="toDate"
168+
defaultValue={tripTo}
169+
minDate={watchFromDate ?? earliest}
170+
maxDate={tripTo}
171+
rules={{ required: t("publicTrips:to_date_required") }}
172+
/>
173+
</DateRow>
174+
<TextField
175+
id="offer-to-host-message"
176+
{...register("text", {
177+
required: t("publicTrips:offer_dialog_message_required"),
178+
minLength: {
179+
value: MESSAGE_MIN_LENGTH,
180+
message: t("publicTrips:offer_dialog_chars_remaining", {
181+
count: charsRemaining,
182+
}),
183+
},
184+
})}
185+
label={t("publicTrips:offer_dialog_message_label")}
186+
placeholder={t("publicTrips:offer_dialog_message_placeholder")}
187+
minRows={6}
188+
multiline
189+
fullWidth
190+
error={!!errors.text}
191+
helperText={
192+
errors.text?.message
193+
? errors.text.message
194+
: charsRemaining > 0
195+
? t("publicTrips:offer_dialog_chars_remaining", {
196+
count: charsRemaining,
197+
})
198+
: ""
199+
}
200+
/>
201+
</FieldStack>
202+
</form>
203+
</DialogContent>
204+
<DialogActions sx={{ padding: theme.spacing(0, 3, 2) }}>
205+
<Button variant="outlined" onClick={handleClose}>
206+
{t("global:cancel")}
207+
</Button>
208+
<Button type="submit" form={formId} loading={isPending}>
209+
{t("publicTrips:offer_dialog_submit")}
210+
</Button>
211+
</DialogActions>
212+
</Dialog>
213+
);
214+
}

0 commit comments

Comments
 (0)