Skip to content

Commit 88585ce

Browse files
authored
Exercise 3 accounts (#7)
1 parent 7c1877a commit 88585ce

32 files changed

Lines changed: 1960 additions & 170 deletions

extra/04.with-accounts/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
# Optional (preview uses request origin by default)
55
APP_BASE_URL=
66

7+
# Required for signed host session cookies
8+
COOKIE_SECRET=dev-cookie-secret
9+

extra/04.with-accounts/README.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# With Accounts
22

3-
This is the project after you've added support for accounts.
3+
This is the project after the narrow host-account rollout from exercise 3 step 3.

extra/04.with-accounts/client/app.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { visuallyHiddenCss } from './styles/visually-hidden.ts'
77
function getRouteAnnouncement(pathname: string) {
88
const segments = pathname.split('/').filter(Boolean)
99
if (segments.length === 0) return 'Create schedule page loaded.'
10+
if (segments[0] === 'login') return 'Host login page loaded.'
11+
if (segments[0] === 'account' && segments[1] === 'schedules') {
12+
return segments.length >= 3
13+
? 'Saved host dashboard loaded.'
14+
: 'Your schedules page loaded.'
15+
}
1016
if (segments[0] === 's' && segments.length >= 3)
1117
return 'Host dashboard loaded.'
1218
if (segments[0] === 's' && segments.length >= 2) {
@@ -150,6 +156,9 @@ export function App(handle: Handle) {
150156
<a href="/" css={navLinkCss}>
151157
New schedule
152158
</a>
159+
<a href="/account/schedules" css={navLinkCss}>
160+
Your schedules
161+
</a>
153162
<a href="/how-it-works" css={navLinkCss} data-router-reload>
154163
How it works
155164
</a>
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { type Handle } from 'remix/component'
2+
import { navigate } from '#client/client-router.tsx'
3+
import { setDocumentTitle, toAppTitle } from '#client/document-title.ts'
4+
import {
5+
colors,
6+
radius,
7+
shadows,
8+
spacing,
9+
typography,
10+
} from '#client/styles/tokens.ts'
11+
12+
type HostScheduleSummary = {
13+
shareToken: string
14+
title: string
15+
createdAt: string
16+
claimedAt: string | null
17+
}
18+
19+
function getLocationKey() {
20+
if (typeof window === 'undefined') return '/account/schedules'
21+
return `${window.location.pathname}${window.location.search}`
22+
}
23+
24+
export function AccountSchedulesRoute(handle: Handle) {
25+
let lastLocationKey = ''
26+
let isLoading = true
27+
let email = ''
28+
let errorMessage: string | null = null
29+
let schedules: Array<HostScheduleSummary> = []
30+
31+
handle.queueTask(async () => {
32+
const nextLocationKey = getLocationKey()
33+
if (nextLocationKey === lastLocationKey) return
34+
lastLocationKey = nextLocationKey
35+
isLoading = true
36+
errorMessage = null
37+
handle.update()
38+
39+
try {
40+
const response = await fetch('/api/account/schedules', {
41+
headers: { Accept: 'application/json' },
42+
})
43+
const payload = (await response.json().catch(() => null)) as {
44+
ok?: boolean
45+
email?: string
46+
schedules?: Array<HostScheduleSummary>
47+
error?: string
48+
} | null
49+
if (handle.signal.aborted || nextLocationKey !== lastLocationKey) return
50+
if (response.status === 401) {
51+
navigate(
52+
`/login?redirectTo=${encodeURIComponent('/account/schedules')}`,
53+
)
54+
return
55+
}
56+
if (!response.ok || !payload?.ok || !Array.isArray(payload.schedules)) {
57+
errorMessage =
58+
typeof payload?.error === 'string'
59+
? payload.error
60+
: 'Unable to load your schedules.'
61+
isLoading = false
62+
handle.update()
63+
return
64+
}
65+
email = payload.email ?? ''
66+
schedules = payload.schedules
67+
isLoading = false
68+
handle.update()
69+
} catch {
70+
if (handle.signal.aborted || nextLocationKey !== lastLocationKey) return
71+
errorMessage = 'Network error while loading your schedules.'
72+
isLoading = false
73+
handle.update()
74+
}
75+
})
76+
77+
return () => {
78+
setDocumentTitle(toAppTitle('Your schedules'))
79+
80+
return (
81+
<section css={{ display: 'grid', gap: spacing.lg }}>
82+
<header css={{ display: 'grid', gap: spacing.sm }}>
83+
<h1
84+
css={{
85+
margin: 0,
86+
fontSize: typography.fontSize.xl,
87+
fontWeight: typography.fontWeight.semibold,
88+
color: colors.text,
89+
}}
90+
>
91+
Your schedules
92+
</h1>
93+
<p css={{ margin: 0, color: colors.textMuted }}>
94+
Open any schedule you have already claimed
95+
{email ? ` as ${email}` : ''}.
96+
</p>
97+
</header>
98+
99+
<div css={{ display: 'flex', gap: spacing.sm, flexWrap: 'wrap' }}>
100+
<a href="/">Create a new schedule</a>
101+
<form method="post" action="/logout?redirectTo=/">
102+
<button
103+
type="submit"
104+
css={{
105+
padding: 0,
106+
border: 'none',
107+
background: 'none',
108+
color: colors.primaryText,
109+
cursor: 'pointer',
110+
}}
111+
>
112+
Log out
113+
</button>
114+
</form>
115+
</div>
116+
117+
{isLoading ? (
118+
<p css={{ margin: 0, color: colors.textMuted }}>
119+
Loading your schedules…
120+
</p>
121+
) : errorMessage ? (
122+
<p role="alert" css={{ margin: 0, color: colors.error }}>
123+
{errorMessage}
124+
</p>
125+
) : schedules.length === 0 ? (
126+
<section
127+
css={{
128+
display: 'grid',
129+
gap: spacing.sm,
130+
padding: spacing.lg,
131+
borderRadius: radius.lg,
132+
border: `1px solid ${colors.border}`,
133+
backgroundColor: colors.surface,
134+
boxShadow: shadows.sm,
135+
}}
136+
>
137+
<p css={{ margin: 0, color: colors.text }}>
138+
No claimed schedules yet.
139+
</p>
140+
<p css={{ margin: 0, color: colors.textMuted }}>
141+
Open a private host link, then save that schedule to your account.
142+
</p>
143+
</section>
144+
) : (
145+
<div css={{ display: 'grid', gap: spacing.md }}>
146+
{schedules.map((schedule) => (
147+
<article
148+
key={schedule.shareToken}
149+
css={{
150+
display: 'grid',
151+
gap: spacing.sm,
152+
padding: spacing.lg,
153+
borderRadius: radius.lg,
154+
border: `1px solid ${colors.border}`,
155+
backgroundColor: colors.surface,
156+
boxShadow: shadows.sm,
157+
}}
158+
>
159+
<h2
160+
css={{
161+
margin: 0,
162+
fontSize: typography.fontSize.lg,
163+
color: colors.text,
164+
}}
165+
>
166+
{schedule.title}
167+
</h2>
168+
<p css={{ margin: 0, color: colors.textMuted }}>
169+
Claimed{' '}
170+
{new Date(
171+
schedule.claimedAt ?? schedule.createdAt,
172+
).toLocaleString()}
173+
</p>
174+
<div css={{ display: 'flex', gap: spacing.sm, flexWrap: 'wrap' }}>
175+
<a
176+
href={`/account/schedules/${encodeURIComponent(schedule.shareToken)}`}
177+
>
178+
Open dashboard
179+
</a>
180+
<a href={`/s/${encodeURIComponent(schedule.shareToken)}`}>
181+
Attendee view
182+
</a>
183+
</div>
184+
</article>
185+
))}
186+
</div>
187+
)}
188+
</section>
189+
)
190+
}
191+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import { AccountSchedulesRoute } from './account-schedules.tsx'
12
import { HomeRoute } from './home.tsx'
3+
import { LoginRoute } from './login.tsx'
24
import { ScheduleRoute } from './schedule.tsx'
35
import { ScheduleHostRoute } from './schedule-host.tsx'
46

57
export const clientRoutes = {
68
'/': <HomeRoute />,
9+
'/login': <LoginRoute />,
10+
'/account/schedules': <AccountSchedulesRoute />,
11+
'/account/schedules/:shareToken': <ScheduleHostRoute />,
712
'/s/:shareToken/:hostAccessToken': <ScheduleHostRoute />,
813
'/s/:shareToken': <ScheduleRoute />,
914
}

0 commit comments

Comments
 (0)