Skip to content

Commit dc8b5e8

Browse files
Merge pull request #236 from CodeForPhilly/feat/project-table-card
feat(web): printable project table card with a QR code
2 parents c8141f2 + a0a297c commit dc8b5e8

8 files changed

Lines changed: 229 additions & 0 deletions

File tree

‎apps/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"clsx": "^2.1.1",
3131
"lucide-react": "^1.16.0",
3232
"marked": "^18.0.4",
33+
"qrcode.react": "^4.2.0",
3334
"radix-ui": "^1.4.3",
3435
"react": "^19.2.6",
3536
"react-dom": "^19.2.6",

‎apps/web/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ProjectsIndex } from '@/screens/ProjectsIndex';
1010
import { ProjectDetail } from '@/screens/ProjectDetail';
1111
import { ProjectEdit } from '@/screens/ProjectEdit';
1212
import { ProjectBuzzNew } from '@/screens/ProjectBuzzNew';
13+
import { ProjectTableCard } from '@/screens/ProjectTableCard';
1314
import { PeopleIndex } from '@/screens/PeopleIndex';
1415
import { PersonDetail } from '@/screens/PersonDetail';
1516
import { ProfileEdit } from '@/screens/ProfileEdit';
@@ -50,6 +51,7 @@ const router = createBrowserRouter([
5051
{ path: '/projects/create', element: <ProjectEdit mode="create" /> },
5152
{ path: '/projects/:slug', element: <ProjectDetail /> },
5253
{ path: '/projects/:slug/edit', element: <ProjectEdit mode="edit" /> },
54+
{ path: '/projects/:slug/table-card', element: <ProjectTableCard /> },
5355
{ path: '/projects/:slug/updates/:number', element: <ProjectDetail anchor="update" /> },
5456
{ path: '/projects/:slug/buzz/:buzzSlug', element: <ProjectDetail anchor="buzz" /> },
5557
{ path: '/projects/:slug/buzz/new', element: <ProjectBuzzNew /> },

‎apps/web/src/screens/ProjectDetail.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,9 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
603603
>
604604
Share to Slack
605605
</Button>
606+
<Button asChild variant="outline">
607+
<Link to={`/projects/${slug}/table-card`}>Table card</Link>
608+
</Button>
606609
</div>
607610
</section>
608611

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { Link, useParams } from 'react-router';
3+
import { QRCodeSVG } from 'qrcode.react';
4+
import { Button } from '@/components/ui/button';
5+
import { api, ApiError } from '@/lib/api';
6+
7+
/**
8+
* Printable table sign for a project (specs/screens/project-table-card.md):
9+
* a QR code to the project page, where a newcomer can sign in and join.
10+
*/
11+
export function ProjectTableCard() {
12+
const { slug = '' } = useParams<{ slug: string }>();
13+
const projectQ = useQuery({
14+
queryKey: ['project', slug],
15+
queryFn: () => api.projects.get(slug),
16+
});
17+
18+
if (projectQ.isLoading) {
19+
return <div className="container mx-auto px-4 py-12 text-muted-foreground">Loading project…</div>;
20+
}
21+
22+
if (projectQ.isError) {
23+
const err = projectQ.error;
24+
if (err instanceof ApiError && err.status === 404) {
25+
return (
26+
<div className="container mx-auto px-4 py-16 text-center">
27+
<h1 className="text-2xl font-bold mb-2">Project not found</h1>
28+
<p className="text-muted-foreground mb-6">No project with the slug “{slug}” exists.</p>
29+
<Link to="/projects" className="text-primary hover:underline">
30+
← Browse all projects
31+
</Link>
32+
</div>
33+
);
34+
}
35+
return <div className="container mx-auto px-4 py-12 text-destructive">Failed to load project.</div>;
36+
}
37+
38+
const project = projectQ.data!.data;
39+
// The origin the card was opened on, so production prints codeforphilly.org
40+
// and a test instance prints its own host.
41+
const url = `${window.location.origin}/projects/${project.slug}`;
42+
const displayUrl = url.replace(/^https?:\/\//, '');
43+
44+
return (
45+
<div className="container mx-auto px-4 py-8 print:p-0">
46+
<div className="flex items-center justify-center gap-3 mb-6 print:hidden">
47+
<Button onClick={() => window.print()}>Print</Button>
48+
<Button asChild variant="outline">
49+
<Link to={`/projects/${project.slug}`}>Back to project</Link>
50+
</Button>
51+
</div>
52+
53+
<article className="mx-auto max-w-2xl rounded-lg border border-border bg-white text-black px-8 py-12 text-center print:max-w-none print:border-0 print:rounded-none print:py-0">
54+
<img
55+
src="/img/logo-horizontal.svg"
56+
alt="Code for Philly"
57+
className="mx-auto h-14 mb-10 print:h-20"
58+
/>
59+
<h1 className="text-4xl font-bold leading-tight mb-3 print:text-5xl">{project.title}</h1>
60+
{project.summary && <p className="text-lg text-neutral-700 mb-8 line-clamp-2">{project.summary}</p>}
61+
<QRCodeSVG
62+
value={url}
63+
size={288}
64+
marginSize={0}
65+
title={`QR code for ${displayUrl}`}
66+
className="mx-auto my-8 h-[3in] w-[3in] print:my-12 print:h-[4in] print:w-[4in]"
67+
/>
68+
<p className="text-3xl font-semibold mb-2 print:text-4xl">Scan to join this project</p>
69+
<p className="text-lg font-mono text-neutral-700 break-all">{displayUrl}</p>
70+
{project.links.chatChannel && (
71+
<p className="text-lg mt-6">
72+
Chat: <span className="font-semibold">#{project.links.chatChannel}</span> on the Code
73+
for Philly Slack
74+
</p>
75+
)}
76+
</article>
77+
</div>
78+
);
79+
}

‎package-lock.json‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎plans/project-table-card.md‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
status: done
3+
depends: []
4+
specs:
5+
- specs/screens/project-table-card.md
6+
- specs/screens/project-detail.md
7+
issues: [232]
8+
pr: 236
9+
---
10+
11+
# Plan: Project table card with a QR code
12+
13+
## Scope
14+
15+
At hack nights each project has a table; newcomers should be able to scan
16+
a code there and land where they can join. Add a printable "table card"
17+
per project.
18+
19+
**In:** `/projects/:slug/table-card` (logo, title, summary, QR code to the
20+
project page, "Scan to join this project", the URL as text, the Slack
21+
channel when set; Print and Back controls that don't print); a "Table
22+
card" button in the project page's Share section. Visible to anyone.
23+
24+
**Out:** a QR that joins on arrival (the project page already walks a
25+
newcomer from "Sign in to contribute" back to "Join Project"); folded
26+
tent-card layouts; batch printing every project's card at once.
27+
28+
## Implements
29+
30+
- [screens/project-table-card.md](../specs/screens/project-table-card.md)
31+
- [screens/project-detail.md](../specs/screens/project-detail.md) — Share
32+
section, Navigation.
33+
34+
## Approach
35+
36+
1. `qrcode.react` (`QRCodeSVG`) renders the code client-side as SVG, so it
37+
prints crisp at any size.
38+
2. The URL uses `window.location.origin` so production cards read
39+
`codeforphilly.org` and test instances encode their own host.
40+
3. The card is black on white in both themes; print hides the shell via
41+
the existing print stylesheet and the controls via `print:hidden`.
42+
43+
## Validation
44+
45+
- [ ] `npm run type-check && npm run lint && npm test` clean.
46+
- [x] The card renders for a project with a summary and chat channel, and
47+
for one without.
48+
- [x] Print preview (PDF) fits one Letter page with only the card.
49+
- [x] The code decoded from the printed PDF (OpenCV) is the project page's
50+
absolute URL.
51+
- [x] An unknown slug shows "Project not found".
52+
53+
## Risks / unknowns
54+
55+
- None significant; the dependency is small (no transitive deps).
56+
57+
## Notes
58+
59+
- Defaults taken on the two open questions in #232, recorded as the
60+
spec's local principles: the code points at the project page rather
61+
than joining on arrival, and anyone can print a card.
62+
- Print scales the card up (4" code, larger title) so it fills a Letter
63+
page; on screen it stays a 3" preview.
64+
- The code encodes `window.location.origin`, so a card printed from a
65+
test instance points at that instance.
66+
- A real phone scan of a paper card is left to the first hack night;
67+
the decode check covers the encoded URL.
68+
69+
## Follow-ups
70+
71+
- **None.**

‎specs/screens/project-detail.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ If slug is unknown or soft-deleted (and caller isn't staff): 404 page.
7878
4. **Share**
7979
- "Copy link" button (copies `https://codeforphilly.org/projects/<slug>`)
8080
- "Share to Slack" button — opens a system share or copies a pre-formatted Slack message
81+
- "Table card" button → `/projects/<slug>/table-card`, a printable sign with a QR code for the project's table at a hack night ([project-table-card.md](project-table-card.md))
8182
- _Twitter/X share removed_, see [deferred.md](../deferred.md)
8283

8384
5. **Info**
@@ -120,6 +121,7 @@ All modals are shadcn `Dialog` components. Forms post via fetch, show server val
120121
- `/projects/<slug>/updates/<number>` (permalink on individual updates)
121122
- `/projects/<slug>/buzz/<buzzSlug>` (permalink on individual buzz)
122123
- `/projects/<slug>/buzz/new`
124+
- `/projects/<slug>/table-card`
123125
- `/tags/<namespace>/<slug>` (tag chips)
124126
- External: `usersUrl`, `developersUrl`, Slack channel
125127

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Screen: Project Table Card
2+
3+
## Route
4+
5+
`/projects/:slug/table-card` — public. A one-page printable sign for a
6+
project's table at a hack night: a newcomer scans the code with their
7+
phone and lands on the project page, where they can sign in and join.
8+
9+
## Data Requirements
10+
11+
- `GET /api/projects/:slug` — `title`, `summary`, `links.chatChannel`
12+
13+
If the slug is unknown or soft-deleted (and the caller isn't staff): the
14+
same 404 page as [project-detail.md](project-detail.md).
15+
16+
## Display Rules
17+
18+
Centered on the page, top to bottom:
19+
20+
1. The Code for Philly logo.
21+
2. The project's `title`, large.
22+
3. The project's `summary`, if any, in one or two lines.
23+
4. A QR code encoding the project page's absolute URL —
24+
`<site origin>/projects/<slug>` (the origin the card was opened on, so
25+
a card printed from production reads `https://codeforphilly.org/…`).
26+
About four inches square when printed.
27+
5. "Scan to join this project".
28+
6. The same URL as text without the scheme (`codeforphilly.org/projects/<slug>`)
29+
for anyone who can't scan.
30+
7. "Chat: #<chatChannel> on the Code for Philly Slack" when the project has
31+
a `chatChannel`.
32+
33+
On screen, above the card, two controls: "Print" (opens the browser's print
34+
dialog) and "Back to project" (→ `/projects/<slug>`). Neither prints.
35+
36+
**In print:** only the card. The app shell is already hidden by the print
37+
stylesheet ([app-shell.md](../behaviors/app-shell.md#print)); the card
38+
fits one US Letter or A4 page in portrait, black on white.
39+
40+
## Actions
41+
42+
| Action | Affects | Caused by |
43+
| ------ | ------- | --------- |
44+
| Print | Browser print dialog | "Print" button |
45+
46+
## Navigation
47+
48+
**To here:** the "Table card" button in the project page's Share section.
49+
50+
**From here:** `/projects/<slug>`.
51+
52+
## Principles
53+
54+
**Local:**
55+
56+
- **The card points at the project page, nothing deeper.** The project page
57+
already walks a newcomer from "Sign in to contribute" back to "Join
58+
Project", and it is also what a curious passer-by should see. A link that
59+
joins on arrival would make scanning a commitment.
60+
- **Anyone can print one.** The project page is public, so its card is
61+
too; whoever is staffing the table doesn't have to be the maintainer.

0 commit comments

Comments
 (0)