create-githat-app 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -770,6 +770,7 @@ var NEXT_LIKE2 = /* @__PURE__ */ new Set([
770
770
  "classroom"
771
771
  ]);
772
772
  var MINIMAL = /* @__PURE__ */ new Set([
773
+ "nextjs",
773
774
  "plain",
774
775
  "saas",
775
776
  "marketplace",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-githat-app",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "GitHat CLI — scaffold apps and manage the skills marketplace",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
  import { useEffect, useState } from 'react';
5
5
  import Link from 'next/link';
6
6
  import { useAuth } from '@githat/nextjs';
7
- import { githatApi } from '../../../githat/api/client{{#unless typescript}}.js{{/unless}}';
7
+ import { githatApi } from '../../githat/api/client{{#unless typescript}}.js{{/unless}}';
8
8
  {{#if typescript}}
9
9
  interface AgentSummary {
10
10
  total: number;
@@ -22,7 +22,7 @@ cp .env.local.example .env.local
22
22
  {{#ifEquals packageManager "npm"}}npm run dev{{else}}{{packageManager}} dev{{/ifEquals}}
23
23
  ```
24
24
 
25
- 4. Open [http://localhost:{{#ifEquals framework "nextjs"}}3000{{else}}5173{{/ifEquals}}](http://localhost:{{#ifEquals framework "nextjs"}}3000{{else}}5173{{/ifEquals}})
25
+ 4. Open [http://localhost:{{#ifNext framework}}3000{{else}}5173{{/ifNext}}](http://localhost:{{#ifNext framework}}3000{{else}}5173{{/ifNext}})
26
26
 
27
27
  Your `NEXT_PUBLIC_GITHAT_PUBLISHABLE_KEY` is required — get it at [githat.io/dashboard/apps](https://githat.io/dashboard/apps) or re-run `create-githat-app` after running `githat login` to have it auto-registered.
28
28
 
@@ -66,8 +66,43 @@ After that, every `git push origin main` triggers a build + rsync + restart. The
66
66
  {{/if}}{{#if includeOrgManagement}}- `/dashboard/members` — Invite members, manage roles
67
67
  {{/if}}
68
68
 
69
+ ## Branded auth emails (recommended)
70
+
71
+ Out of the box, password-reset and verification emails are sent from
72
+ `auth@githat.io` with the **From name** set to your app's brand
73
+ ("{{businessName}}"). Most email clients show the brand prominently and
74
+ hide the address — but for the most polished experience, verify your
75
+ own domain so emails come from `auth@{{domain}}` instead:
76
+
77
+ ```bash
78
+ # 1. Register your domain in SES via the GitHat API
79
+ curl -X POST https://api.githat.io/apps/$APP_ID/email/domains \
80
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
81
+ -H "Content-Type: application/json" \
82
+ -d '{"hostname": "{{domain}}"}'
83
+ ```
84
+
85
+ The response includes 3 DKIM CNAMEs. Add them to your DNS registrar
86
+ (Route 53, Cloudflare, Namecheap, etc.). SES auto-verifies within
87
+ ~5 minutes, after which every auth email ships from `auth@{{domain}}`
88
+ with your brand throughout — subject, body, and reset link.
89
+
90
+ You can also verify domains via the SDK's `useEmailDomains()` hook:
91
+
92
+ ```{{#if typescript}}tsx{{else}}jsx{{/if}}
93
+ import { useEmailDomains } from '@githat/nextjs';
94
+
95
+ const { add, list, status } = useEmailDomains();
96
+ await add('{{domain}}'); // returns { dkimRecords }
97
+ const domains = await list(); // includes verificationStatus
98
+ ```
99
+
100
+ Once verified, links in emails point to `https://{{domain}}/reset-password?token=…`
101
+ — users never see githat.io.
102
+
69
103
  ## Learn More
70
104
 
71
105
  - [GitHat Documentation](https://githat.io/docs)
106
+ - [Branded Auth Emails Guide](https://githat.io/docs/email-domains)
72
107
  - [SDK Reference](https://www.npmjs.com/package/@githat/nextjs)
73
108
  - [API Reference](https://githat.io/docs/api)
@@ -2,11 +2,11 @@
2
2
  'use client';
3
3
 
4
4
  import { useAuth } from '@githat/nextjs';
5
- {{#ifEquals framework "nextjs"}}
5
+ {{#ifNext framework}}
6
6
  import { useRouter } from 'next/navigation';
7
7
  {{else}}
8
8
  import { useNavigate } from 'react-router-dom';
9
- {{/ifEquals}}
9
+ {{/ifNext}}
10
10
  import { githatConfig } from '../config{{#unless typescript}}.js{{/unless}}';
11
11
 
12
12
  export function AuthGuard({ children, requiredRole }{{#if typescript}}: {
@@ -14,11 +14,11 @@ export function AuthGuard({ children, requiredRole }{{#if typescript}}: {
14
14
  requiredRole?: 'owner' | 'admin' | 'member';
15
15
  }{{/if}}) {
16
16
  const { isSignedIn, isLoading, org } = useAuth();
17
- {{#ifEquals framework "nextjs"}}
17
+ {{#ifNext framework}}
18
18
  const router = useRouter();
19
19
  {{else}}
20
20
  const navigate = useNavigate();
21
- {{/ifEquals}}
21
+ {{/ifNext}}
22
22
 
23
23
  if (isLoading) {
24
24
  return (
@@ -29,11 +29,11 @@ export function AuthGuard({ children, requiredRole }{{#if typescript}}: {
29
29
  }
30
30
 
31
31
  if (!isSignedIn) {
32
- {{#ifEquals framework "nextjs"}}
32
+ {{#ifNext framework}}
33
33
  router.push(githatConfig.signInUrl);
34
34
  {{else}}
35
35
  navigate(githatConfig.signInUrl);
36
- {{/ifEquals}}
36
+ {{/ifNext}}
37
37
  return null;
38
38
  }
39
39
 
@@ -4,12 +4,12 @@
4
4
  import { UserButton, OrgSwitcher } from '@githat/nextjs';
5
5
  import { AuthGuard } from '../auth/guard{{#unless typescript}}.jsx{{/unless}}';
6
6
  import { githatConfig } from '../config{{#unless typescript}}.js{{/unless}}';
7
- {{#ifEquals framework "nextjs"}}
7
+ {{#ifNext framework}}
8
8
  import Link from 'next/link';
9
9
  import { usePathname } from 'next/navigation';
10
10
  {{else}}
11
11
  import { Link, useLocation } from 'react-router-dom';
12
- {{/ifEquals}}
12
+ {{/ifNext}}
13
13
 
14
14
  const navItems = [
15
15
  { label: 'Overview', href: '/dashboard' },
@@ -27,11 +27,11 @@ const navItems = [
27
27
  ];
28
28
 
29
29
  export function DashboardLayout({ children }{{#if typescript}}: { children: React.ReactNode }{{/if}}) {
30
- {{#ifEquals framework "nextjs"}}
30
+ {{#ifNext framework}}
31
31
  const pathname = usePathname();
32
32
  {{else}}
33
33
  const { pathname } = useLocation();
34
- {{/ifEquals}}
34
+ {{/ifNext}}
35
35
 
36
36
  return (
37
37
  <AuthGuard>
@@ -45,11 +45,11 @@ export function DashboardLayout({ children }{{#if typescript}}: { children: Reac
45
45
  {navItems.map((item) => (
46
46
  <Link
47
47
  key={item.href}
48
- {{#ifEquals framework "nextjs"}}
48
+ {{#ifNext framework}}
49
49
  href={item.href}
50
50
  {{else}}
51
51
  to={item.href}
52
- {{/ifEquals}}
52
+ {{/ifNext}}
53
53
  style=\{{
54
54
  padding: '0.5rem 0.75rem',
55
55
  borderRadius: '0.375rem',
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Link from 'next/link';
5
+
6
+ {{#if typescript}}
7
+ interface FeedbackContentProps {
8
+ id: string;
9
+ }
10
+ {{/if}}
11
+
12
+ export default function FeedbackContent({ id }{{#if typescript}}: FeedbackContentProps{{/if}}) {
13
+ const [submitted, setSubmitted] = useState(false);
14
+ const [oneThing, setOneThing] = useState('');
15
+ const [stuck, setStuck] = useState('');
16
+ const [scale, setScale] = useState(7);
17
+
18
+ if (submitted) {
19
+ return (
20
+ <div style=\{{
21
+ background: 'var(--bg)',
22
+ color: 'var(--fg)',
23
+ minHeight: 'calc(100vh - 64px)',
24
+ display: 'flex',
25
+ alignItems: 'center',
26
+ justifyContent: 'center',
27
+ padding: 'var(--space-4)',
28
+ }}>
29
+ <div style=\{{ maxWidth: '32rem', textAlign: 'center' }}>
30
+ <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '2rem', marginBottom: 'var(--space-3)' }}>
31
+ ¡Gracias!
32
+ </h1>
33
+ <p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-4)' }}>
34
+ The presenter sees your feedback live on their screen.
35
+ You can submit again with a new thought any time.
36
+ </p>
37
+ <button
38
+ onClick={() => { setSubmitted(false); setOneThing(''); setStuck(''); setScale(7); }}
39
+ style=\{{
40
+ padding: 'var(--space-3) var(--space-5)',
41
+ borderRadius: 'var(--radius-md, 0.5rem)',
42
+ background: 'var(--primary)',
43
+ color: 'var(--bg)',
44
+ border: 'none',
45
+ fontWeight: 600,
46
+ cursor: 'pointer',
47
+ }}
48
+ >
49
+ Add another thought
50
+ </button>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <div style=\{{ background: 'var(--bg)', color: 'var(--fg)', minHeight: 'calc(100vh - 64px)', padding: 'var(--space-8) var(--space-4)' }}>
58
+ <div style=\{{ maxWidth: '32rem', margin: '0 auto' }}>
59
+ <Link href={`/projects/${id}`} style=\{{ color: 'var(--fg-muted)', fontSize: '0.875rem', display: 'inline-block', marginBottom: 'var(--space-4)' }}>
60
+ ← Project
61
+ </Link>
62
+ <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '1.75rem', marginBottom: 'var(--space-2)' }}>
63
+ Tell them what you think
64
+ </h1>
65
+ <p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-6)' }}>
66
+ No account, no email. Just three quick questions while
67
+ they're presenting.
68
+ </p>
69
+
70
+ <form
71
+ onSubmit={(e) => {
72
+ e.preventDefault();
73
+ /* TODO: POST /api/projects/[id]/feedback { oneThing, stuck, scale } */
74
+ setSubmitted(true);
75
+ }}
76
+ style=\{{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}
77
+ >
78
+ <Field label="One thing that landed for you">
79
+ <input
80
+ type="text"
81
+ required
82
+ value={oneThing}
83
+ onChange={(e) => setOneThing(e.target.value)}
84
+ placeholder="The thing you'll remember tomorrow"
85
+ style=\{{
86
+ width: '100%',
87
+ padding: 'var(--space-3)',
88
+ borderRadius: 'var(--radius-md, 0.5rem)',
89
+ border: '1px solid var(--border)',
90
+ background: 'var(--surface)',
91
+ color: 'var(--fg)',
92
+ fontSize: '1rem',
93
+ }}
94
+ />
95
+ </Field>
96
+
97
+ <Field label="One thing you'd want them to dig into more">
98
+ <textarea
99
+ value={stuck}
100
+ onChange={(e) => setStuck(e.target.value)}
101
+ rows={3}
102
+ placeholder="A question, a doubt, a what-if"
103
+ style=\{{
104
+ width: '100%',
105
+ padding: 'var(--space-3)',
106
+ borderRadius: 'var(--radius-md, 0.5rem)',
107
+ border: '1px solid var(--border)',
108
+ background: 'var(--surface)',
109
+ color: 'var(--fg)',
110
+ fontSize: '1rem',
111
+ fontFamily: 'inherit',
112
+ resize: 'vertical',
113
+ }}
114
+ />
115
+ </Field>
116
+
117
+ <Field label={`How likely are you to share this with someone? (${scale}/10)`}>
118
+ <input
119
+ type="range"
120
+ min={1}
121
+ max={10}
122
+ value={scale}
123
+ onChange={(e) => setScale(Number(e.target.value))}
124
+ style=\{{ width: '100%' }}
125
+ />
126
+ </Field>
127
+
128
+ <button type="submit" style=\{{
129
+ padding: 'var(--space-3) var(--space-5)',
130
+ borderRadius: 'var(--radius-md, 0.5rem)',
131
+ background: 'var(--primary)',
132
+ color: 'var(--bg)',
133
+ border: 'none',
134
+ fontWeight: 600,
135
+ fontSize: '1rem',
136
+ cursor: 'pointer',
137
+ }}>
138
+ Send feedback
139
+ </button>
140
+ </form>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ function Field({ label, children }{{#if typescript}}: { label: string; children: React.ReactNode }{{/if}}) {
147
+ return (
148
+ <label style=\{{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
149
+ <span style=\{{ fontSize: '0.875rem', fontWeight: 600 }}>{label}</span>
150
+ {children}
151
+ </label>
152
+ );
153
+ }
@@ -1,159 +1,20 @@
1
- 'use client';
2
-
3
- import { use, useState } from 'react';
4
- import Link from 'next/link';
1
+ import FeedbackContent from './feedback-content{{#unless typescript}}.jsx{{/unless}}';
5
2
 
6
3
  /**
7
4
  * Audience feedback form — `/projects/[id]/feedback`.
8
5
  *
9
- * Anyone with the link can fill this out. NO sign-in required
10
- * that's the whole point. Anonymous responses go to a queue the
11
- * presenter watches in real time on /present.
12
- *
13
- * The form questions are deliberately short — long forms kill
14
- * response rates and the value of this template is volume of
15
- * feedback over time, not depth per response.
6
+ * generateStaticParams tells Next.js which ids to pre-render at build
7
+ * time (required for `output: "export"`). FeedbackContent is a client
8
+ * component that renders the form with React state.
16
9
  */
17
- export default function FeedbackPage({ params }: { params: Promise<{ id: string }> }) {
18
- const { id } = use(params);
19
- const [submitted, setSubmitted] = useState(false);
20
- const [oneThing, setOneThing] = useState('');
21
- const [stuck, setStuck] = useState('');
22
- const [scale, setScale] = useState(7);
23
-
24
- if (submitted) {
25
- return (
26
- <div style=\{{
27
- background: 'var(--bg)',
28
- color: 'var(--fg)',
29
- minHeight: 'calc(100vh - 64px)',
30
- display: 'flex',
31
- alignItems: 'center',
32
- justifyContent: 'center',
33
- padding: 'var(--space-4)',
34
- }}>
35
- <div style=\{{ maxWidth: '32rem', textAlign: 'center' }}>
36
- <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '2rem', marginBottom: 'var(--space-3)' }}>
37
- ¡Gracias!
38
- </h1>
39
- <p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-4)' }}>
40
- The presenter sees your feedback live on their screen.
41
- You can submit again with a new thought any time.
42
- </p>
43
- <button
44
- onClick={() => { setSubmitted(false); setOneThing(''); setStuck(''); setScale(7); }}
45
- style=\{{
46
- padding: 'var(--space-3) var(--space-5)',
47
- borderRadius: 'var(--radius-md, 0.5rem)',
48
- background: 'var(--primary)',
49
- color: 'var(--bg)',
50
- border: 'none',
51
- fontWeight: 600,
52
- cursor: 'pointer',
53
- }}
54
- >
55
- Add another thought
56
- </button>
57
- </div>
58
- </div>
59
- );
60
- }
61
-
62
- return (
63
- <div style=\{{ background: 'var(--bg)', color: 'var(--fg)', minHeight: 'calc(100vh - 64px)', padding: 'var(--space-8) var(--space-4)' }}>
64
- <div style=\{{ maxWidth: '32rem', margin: '0 auto' }}>
65
- <Link href={`/projects/${id}`} style=\{{ color: 'var(--fg-muted)', fontSize: '0.875rem', display: 'inline-block', marginBottom: 'var(--space-4)' }}>
66
- ← Project
67
- </Link>
68
- <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '1.75rem', marginBottom: 'var(--space-2)' }}>
69
- Tell them what you think
70
- </h1>
71
- <p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-6)' }}>
72
- No account, no email. Just three quick questions while
73
- they're presenting.
74
- </p>
75
-
76
- <form
77
- onSubmit={(e) => {
78
- e.preventDefault();
79
- /* TODO: POST /api/projects/[id]/feedback { oneThing, stuck, scale } */
80
- setSubmitted(true);
81
- }}
82
- style=\{{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}
83
- >
84
- <Field label="One thing that landed for you">
85
- <input
86
- type="text"
87
- required
88
- value={oneThing}
89
- onChange={(e) => setOneThing(e.target.value)}
90
- placeholder="The thing you'll remember tomorrow"
91
- style=\{{
92
- width: '100%',
93
- padding: 'var(--space-3)',
94
- borderRadius: 'var(--radius-md, 0.5rem)',
95
- border: '1px solid var(--border)',
96
- background: 'var(--surface)',
97
- color: 'var(--fg)',
98
- fontSize: '1rem',
99
- }}
100
- />
101
- </Field>
102
-
103
- <Field label="One thing you'd want them to dig into more">
104
- <textarea
105
- value={stuck}
106
- onChange={(e) => setStuck(e.target.value)}
107
- rows={3}
108
- placeholder="A question, a doubt, a what-if"
109
- style=\{{
110
- width: '100%',
111
- padding: 'var(--space-3)',
112
- borderRadius: 'var(--radius-md, 0.5rem)',
113
- border: '1px solid var(--border)',
114
- background: 'var(--surface)',
115
- color: 'var(--fg)',
116
- fontSize: '1rem',
117
- fontFamily: 'inherit',
118
- resize: 'vertical',
119
- }}
120
- />
121
- </Field>
122
10
 
123
- <Field label={`How likely are you to share this with someone? (${scale}/10)`}>
124
- <input
125
- type="range"
126
- min={1}
127
- max={10}
128
- value={scale}
129
- onChange={(e) => setScale(Number(e.target.value))}
130
- style=\{{ width: '100%' }}
131
- />
132
- </Field>
11
+ const SAMPLE_PROJECT_IDS = ['sustainable-bodega', 'after-school-tutor', 'community-fridge'];
133
12
 
134
- <button type="submit" style=\{{
135
- padding: 'var(--space-3) var(--space-5)',
136
- borderRadius: 'var(--radius-md, 0.5rem)',
137
- background: 'var(--primary)',
138
- color: 'var(--bg)',
139
- border: 'none',
140
- fontWeight: 600,
141
- fontSize: '1rem',
142
- cursor: 'pointer',
143
- }}>
144
- Send feedback
145
- </button>
146
- </form>
147
- </div>
148
- </div>
149
- );
13
+ export function generateStaticParams() {
14
+ return SAMPLE_PROJECT_IDS.map((id) => ({ id }));
150
15
  }
151
16
 
152
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
153
- return (
154
- <label style=\{{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
155
- <span style=\{{ fontSize: '0.875rem', fontWeight: 600 }}>{label}</span>
156
- {children}
157
- </label>
158
- );
17
+ export default async function FeedbackPage({ params }: { params: Promise<{ id: string }> }) {
18
+ const { id } = await params;
19
+ return <FeedbackContent id={id} />;
159
20
  }
@@ -1,113 +1,20 @@
1
- 'use client';
2
-
3
- import { use, useEffect, useState } from 'react';
1
+ import PresenterContent from './presenter-content{{#unless typescript}}.jsx{{/unless}}';
4
2
 
5
3
  /**
6
4
  * Presenter view — `/projects/[id]/present`.
7
5
  *
8
- * The student opens this on their second screen (or a phone next
9
- * to the laptop) and presents. As the audience submits feedback
10
- * via /projects/[id]/feedback, responses fade in here in real
11
- * time — newest first, color-coded by the scale rating.
12
- *
13
- * Real implementations:
14
- * - Replace the mock interval with a polling fetch (every 3-5s)
15
- * against your backend, OR a websocket / SSE stream.
16
- * - Persist responses to your DB so the project can reiterate
17
- * over multiple cohorts.
6
+ * generateStaticParams tells Next.js which ids to pre-render at build
7
+ * time (required for `output: "export"`). PresenterContent is a client
8
+ * component that renders the live feedback view with polling.
18
9
  */
19
10
 
20
- interface FeedbackEntry {
21
- id: string;
22
- oneThing: string;
23
- stuck: string;
24
- scale: number;
25
- receivedAt: number;
26
- }
27
-
28
- const MOCK_FEEDBACK: FeedbackEntry[] = [
29
- { id: 'a', oneThing: 'The slide on supply chain bottlenecks', stuck: 'How do you measure trust at scale?', scale: 9, receivedAt: Date.now() - 60_000 },
30
- { id: 'b', oneThing: 'Loved the bilingual UX argument', stuck: 'What about Mexican misceláneas?', scale: 8, receivedAt: Date.now() - 120_000 },
31
- ];
32
-
33
- export default function PresenterPage({ params }: { params: Promise<{ id: string }> }) {
34
- const { id } = use(params);
35
- const [feedback, setFeedback] = useState<FeedbackEntry[]>(MOCK_FEEDBACK);
11
+ const SAMPLE_PROJECT_IDS = ['sustainable-bodega', 'after-school-tutor', 'community-fridge'];
36
12
 
37
- // Simulated incoming feedback. Replace with a real fetch poll or
38
- // websocket subscription. The shape is the same.
39
- useEffect(() => {
40
- const t = setInterval(() => {
41
- // setFeedback((prev) => [{ ...newOne, id: crypto.randomUUID() }, ...prev]);
42
- }, 5000);
43
- return () => clearInterval(t);
44
- }, []);
45
-
46
- const audienceUrl = typeof window !== 'undefined'
47
- ? `${window.location.origin}/projects/${id}/feedback`
48
- : `/projects/${id}/feedback`;
49
-
50
- return (
51
- <div style=\{{ background: 'var(--bg)', color: 'var(--fg)', minHeight: '100vh', padding: 'var(--space-6) var(--space-4)' }}>
52
- <div style=\{{ maxWidth: '64rem', margin: '0 auto' }}>
53
- <header style=\{{
54
- padding: 'var(--space-5)',
55
- borderRadius: 'var(--radius-md, 0.5rem)',
56
- background: 'var(--surface-sub)',
57
- marginBottom: 'var(--space-6)',
58
- textAlign: 'center',
59
- }}>
60
- <p style=\{{ color: 'var(--fg-muted)', fontSize: '0.875rem', marginBottom: 'var(--space-2)' }}>
61
- Audience submits feedback at:
62
- </p>
63
- <code style=\{{
64
- fontSize: '1.25rem',
65
- fontFamily: 'var(--font-mono, monospace)',
66
- color: 'var(--primary)',
67
- wordBreak: 'break-all',
68
- }}>
69
- {audienceUrl}
70
- </code>
71
- </header>
72
-
73
- <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '1.75rem', marginBottom: 'var(--space-4)' }}>
74
- Live feedback ({feedback.length})
75
- </h1>
13
+ export function generateStaticParams() {
14
+ return SAMPLE_PROJECT_IDS.map((id) => ({ id }));
15
+ }
76
16
 
77
- <ul style=\{{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
78
- {feedback.map((f) => (
79
- <li key={f.id} style=\{{
80
- padding: 'var(--space-5)',
81
- borderRadius: 'var(--radius-md, 0.5rem)',
82
- border: `2px solid ${f.scale >= 8 ? 'var(--success)' : f.scale >= 5 ? 'var(--info)' : 'var(--warn)'}`,
83
- background: 'var(--surface)',
84
- }}>
85
- <div style=\{{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 'var(--space-3)', marginBottom: 'var(--space-2)' }}>
86
- <div style=\{{ flex: 1 }}>
87
- <div style=\{{ fontSize: '0.75rem', color: 'var(--fg-muted)', marginBottom: 'var(--space-1)' }}>What landed</div>
88
- <div style=\{{ fontWeight: 600 }}>{f.oneThing}</div>
89
- </div>
90
- <div style=\{{
91
- padding: 'var(--space-1) var(--space-3)',
92
- borderRadius: 'var(--radius-full, 9999px)',
93
- background: f.scale >= 8 ? 'var(--success)' : f.scale >= 5 ? 'var(--info)' : 'var(--warn)',
94
- color: 'var(--bg)',
95
- fontSize: '0.75rem',
96
- fontWeight: 700,
97
- }}>
98
- {f.scale}/10
99
- </div>
100
- </div>
101
- {f.stuck && (
102
- <div style=\{{ marginTop: 'var(--space-3)', paddingTop: 'var(--space-3)', borderTop: '1px solid var(--border)' }}>
103
- <div style=\{{ fontSize: '0.75rem', color: 'var(--fg-muted)', marginBottom: 'var(--space-1)' }}>Wants more on</div>
104
- <div style=\{{ fontSize: '0.9375rem', color: 'var(--fg)' }}>{f.stuck}</div>
105
- </div>
106
- )}
107
- </li>
108
- ))}
109
- </ul>
110
- </div>
111
- </div>
112
- );
17
+ export default async function PresentPage({ params }: { params: Promise<{ id: string }> }) {
18
+ const { id } = await params;
19
+ return <PresenterContent id={id} />;
113
20
  }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ {{#if typescript}}
6
+ interface FeedbackEntry {
7
+ id: string;
8
+ oneThing: string;
9
+ stuck: string;
10
+ scale: number;
11
+ receivedAt: number;
12
+ }
13
+
14
+ interface PresenterContentProps {
15
+ id: string;
16
+ }
17
+ {{/if}}
18
+
19
+ /**
20
+ * Presenter view — `/projects/[id]/present`.
21
+ *
22
+ * The student opens this on their second screen (or a phone next
23
+ * to the laptop) and presents. As the audience submits feedback
24
+ * via /projects/[id]/feedback, responses fade in here in real
25
+ * time — newest first, color-coded by the scale rating.
26
+ *
27
+ * Real implementations:
28
+ * - Replace the mock interval with a polling fetch (every 3-5s)
29
+ * against your backend, OR a websocket / SSE stream.
30
+ * - Persist responses to your DB so the project can reiterate
31
+ * over multiple cohorts.
32
+ */
33
+
34
+ const MOCK_FEEDBACK{{#if typescript}}: FeedbackEntry[]{{/if}} = [
35
+ { id: 'a', oneThing: 'The slide on supply chain bottlenecks', stuck: 'How do you measure trust at scale?', scale: 9, receivedAt: Date.now() - 60_000 },
36
+ { id: 'b', oneThing: 'Loved the bilingual UX argument', stuck: 'What about Mexican misceláneas?', scale: 8, receivedAt: Date.now() - 120_000 },
37
+ ];
38
+
39
+ export default function PresenterContent({ id }{{#if typescript}}: PresenterContentProps{{/if}}) {
40
+ const [feedback, setFeedback] = useState{{#if typescript}}<FeedbackEntry[]>{{/if}}(MOCK_FEEDBACK);
41
+
42
+ // Simulated incoming feedback. Replace with a real fetch poll or
43
+ // websocket subscription. The shape is the same.
44
+ useEffect(() => {
45
+ const t = setInterval(() => {
46
+ // setFeedback((prev) => [{ ...newOne, id: crypto.randomUUID() }, ...prev]);
47
+ }, 5000);
48
+ return () => clearInterval(t);
49
+ }, []);
50
+
51
+ const audienceUrl = typeof window !== 'undefined'
52
+ ? `${window.location.origin}/projects/${id}/feedback`
53
+ : `/projects/${id}/feedback`;
54
+
55
+ return (
56
+ <div style=\{{ background: 'var(--bg)', color: 'var(--fg)', minHeight: '100vh', padding: 'var(--space-6) var(--space-4)' }}>
57
+ <div style=\{{ maxWidth: '64rem', margin: '0 auto' }}>
58
+ <header style=\{{
59
+ padding: 'var(--space-5)',
60
+ borderRadius: 'var(--radius-md, 0.5rem)',
61
+ background: 'var(--surface-sub)',
62
+ marginBottom: 'var(--space-6)',
63
+ textAlign: 'center',
64
+ }}>
65
+ <p style=\{{ color: 'var(--fg-muted)', fontSize: '0.875rem', marginBottom: 'var(--space-2)' }}>
66
+ Audience submits feedback at:
67
+ </p>
68
+ <code style=\{{
69
+ fontSize: '1.25rem',
70
+ fontFamily: 'var(--font-mono, monospace)',
71
+ color: 'var(--primary)',
72
+ wordBreak: 'break-all',
73
+ }}>
74
+ {audienceUrl}
75
+ </code>
76
+ </header>
77
+
78
+ <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '1.75rem', marginBottom: 'var(--space-4)' }}>
79
+ Live feedback ({feedback.length})
80
+ </h1>
81
+
82
+ <ul style=\{{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
83
+ {feedback.map((f) => (
84
+ <li key={f.id} style=\{{
85
+ padding: 'var(--space-5)',
86
+ borderRadius: 'var(--radius-md, 0.5rem)',
87
+ border: `2px solid ${f.scale >= 8 ? 'var(--success)' : f.scale >= 5 ? 'var(--info)' : 'var(--warn)'}`,
88
+ background: 'var(--surface)',
89
+ }}>
90
+ <div style=\{{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 'var(--space-3)', marginBottom: 'var(--space-2)' }}>
91
+ <div style=\{{ flex: 1 }}>
92
+ <div style=\{{ fontSize: '0.75rem', color: 'var(--fg-muted)', marginBottom: 'var(--space-1)' }}>What landed</div>
93
+ <div style=\{{ fontWeight: 600 }}>{f.oneThing}</div>
94
+ </div>
95
+ <div style=\{{
96
+ padding: 'var(--space-1) var(--space-3)',
97
+ borderRadius: 'var(--radius-full, 9999px)',
98
+ background: f.scale >= 8 ? 'var(--success)' : f.scale >= 5 ? 'var(--info)' : 'var(--warn)',
99
+ color: 'var(--bg)',
100
+ fontSize: '0.75rem',
101
+ fontWeight: 700,
102
+ }}>
103
+ {f.scale}/10
104
+ </div>
105
+ </div>
106
+ {f.stuck && (
107
+ <div style=\{{ marginTop: 'var(--space-3)', paddingTop: 'var(--space-3)', borderTop: '1px solid var(--border)' }}>
108
+ <div style=\{{ fontSize: '0.75rem', color: 'var(--fg-muted)', marginBottom: 'var(--space-1)' }}>Wants more on</div>
109
+ <div style=\{{ fontSize: '0.9375rem', color: 'var(--fg)' }}>{f.stuck}</div>
110
+ </div>
111
+ )}
112
+ </li>
113
+ ))}
114
+ </ul>
115
+ </div>
116
+ </div>
117
+ );
118
+ }
@@ -1,19 +1,11 @@
1
- 'use client';
2
-
3
- import { use } from 'react';
4
- import Link from 'next/link';
5
- import { useAuth } from '@githat/nextjs';
1
+ import PostContent from './post-content{{#unless typescript}}.jsx{{/unless}}';
6
2
 
7
3
  /**
8
4
  * Post viewer with paywall.
9
5
  *
10
- * Three states a reader can be in:
11
- * 1. Post is free show full content
12
- * 2. Post is gated + reader is signed in + has active subscription → show full
13
- * 3. Post is gated + reader is anonymous OR not subscribed → show preview + CTA
14
- *
15
- * Subscription check is stubbed (`hasActiveSubscription` always
16
- * false). Wire to your backend that watches Sebastn webhooks.
6
+ * generateStaticParams tells Next.js which slugs to pre-render at
7
+ * build time (required for `output: "export"`). PostContent is a
8
+ * client component that handles the auth-gated paywall at runtime.
17
9
  */
18
10
 
19
11
  const SAMPLE_POSTS: Record<string, { title: string; gated: boolean; body: string }> = {
@@ -40,80 +32,12 @@ const SAMPLE_POSTS: Record<string, { title: string; gated: boolean; body: string
40
32
  },
41
33
  };
42
34
 
43
- export default function PostPage({ params }: { params: Promise<{ slug: string }> }) {
44
- const { slug } = use(params);
45
- const post = SAMPLE_POSTS[slug];
46
- const { isSignedIn } = useAuth();
47
-
48
- // Stub: real apps fetch the user's subscription state from your
49
- // backend (which watches Sebastn webhooks for the source of truth).
50
- const hasActiveSubscription = false;
51
-
52
- if (!post) {
53
- return (
54
- <div style=\{{ padding: 'var(--space-12)', textAlign: 'center' }}>
55
- <p>Post not found.</p>
56
- <Link href="/" style=\{{ color: 'var(--primary)' }}>← Home</Link>
57
- </div>
58
- );
59
- }
60
-
61
- const showPaywall = post.gated && (!isSignedIn || !hasActiveSubscription);
62
-
63
- return (
64
- <article style=\{{ maxWidth: '40rem', margin: '0 auto', padding: 'var(--space-8) var(--space-4)' }}>
65
- <Link href="/" style=\{{ color: 'var(--fg-muted)', fontSize: '0.875rem', display: 'inline-block', marginBottom: 'var(--space-4)' }}>
66
- ← All posts
67
- </Link>
68
- <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '2.25rem', lineHeight: 1.2, marginBottom: 'var(--space-4)' }}>
69
- {post.title}
70
- </h1>
71
-
72
- <div style=\{{ color: 'var(--fg)', lineHeight: 1.8, fontSize: '1.0625rem' }}>
73
- {showPaywall ? (
74
- <>
75
- <p style=\{{ marginBottom: 'var(--space-4)' }}>{post.body.slice(0, 200)}…</p>
76
- <Paywall />
77
- </>
78
- ) : (
79
- <p>{post.body}</p>
80
- )}
81
- </div>
82
- </article>
83
- );
35
+ export function generateStaticParams() {
36
+ return Object.keys(SAMPLE_POSTS).map((slug) => ({ slug }));
84
37
  }
85
38
 
86
- function Paywall() {
87
- return (
88
- <div style=\{{
89
- marginTop: 'var(--space-8)',
90
- padding: 'var(--space-6)',
91
- borderRadius: 'var(--radius-md, 0.5rem)',
92
- border: '2px solid var(--accent)',
93
- background: 'var(--surface-sub)',
94
- textAlign: 'center',
95
- }}>
96
- <h3 style=\{{ fontFamily: 'var(--font-wordmark)', marginBottom: 'var(--space-2)' }}>
97
- This post is for subscribers
98
- </h3>
99
- <p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-4)' }}>
100
- Get access to every paid essay, plus the newsletter.
101
- </p>
102
- <button
103
- onClick={() => { /* TODO: open Sebastn checkout */ }}
104
- style=\{{
105
- padding: 'var(--space-3) var(--space-6)',
106
- borderRadius: 'var(--radius-md, 0.5rem)',
107
- border: 'none',
108
- background: 'var(--primary)',
109
- color: 'var(--bg)',
110
- fontWeight: 600,
111
- fontSize: '1rem',
112
- cursor: 'pointer',
113
- }}
114
- >
115
- Subscribe — $5/mo
116
- </button>
117
- </div>
118
- );
39
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
40
+ const { slug } = await params;
41
+ const post = SAMPLE_POSTS[slug] ?? null;
42
+ return <PostContent slug={slug} post={post} />;
119
43
  }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useAuth } from '@githat/nextjs';
5
+
6
+ {{#if typescript}}
7
+ interface Post {
8
+ title: string;
9
+ gated: boolean;
10
+ body: string;
11
+ }
12
+
13
+ interface PostContentProps {
14
+ slug: string;
15
+ post: Post | null;
16
+ }
17
+ {{/if}}
18
+
19
+ export default function PostContent({ slug, post }{{#if typescript}}: PostContentProps{{/if}}) {
20
+ const { isSignedIn } = useAuth();
21
+
22
+ // Stub: real apps fetch the user's subscription state from your
23
+ // backend (which watches Sebastn webhooks for the source of truth).
24
+ const hasActiveSubscription = false;
25
+
26
+ if (!post) {
27
+ return (
28
+ <div style=\{{ padding: 'var(--space-12)', textAlign: 'center' }}>
29
+ <p>Post not found.</p>
30
+ <Link href="/" style=\{{ color: 'var(--primary)' }}>← Home</Link>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ const showPaywall = post.gated && (!isSignedIn || !hasActiveSubscription);
36
+
37
+ return (
38
+ <article style=\{{ maxWidth: '40rem', margin: '0 auto', padding: 'var(--space-8) var(--space-4)' }}>
39
+ <Link href="/" style=\{{ color: 'var(--fg-muted)', fontSize: '0.875rem', display: 'inline-block', marginBottom: 'var(--space-4)' }}>
40
+ ← All posts
41
+ </Link>
42
+ <h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '2.25rem', lineHeight: 1.2, marginBottom: 'var(--space-4)' }}>
43
+ {post.title}
44
+ </h1>
45
+
46
+ <div style=\{{ color: 'var(--fg)', lineHeight: 1.8, fontSize: '1.0625rem' }}>
47
+ {showPaywall ? (
48
+ <>
49
+ <p style=\{{ marginBottom: 'var(--space-4)' }}>{post.body.slice(0, 200)}…</p>
50
+ <Paywall />
51
+ </>
52
+ ) : (
53
+ <p>{post.body}</p>
54
+ )}
55
+ </div>
56
+ </article>
57
+ );
58
+ }
59
+
60
+ function Paywall() {
61
+ return (
62
+ <div style=\{{
63
+ marginTop: 'var(--space-8)',
64
+ padding: 'var(--space-6)',
65
+ borderRadius: 'var(--radius-md, 0.5rem)',
66
+ border: '2px solid var(--accent)',
67
+ background: 'var(--surface-sub)',
68
+ textAlign: 'center',
69
+ }}>
70
+ <h3 style=\{{ fontFamily: 'var(--font-wordmark)', marginBottom: 'var(--space-2)' }}>
71
+ This post is for subscribers
72
+ </h3>
73
+ <p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-4)' }}>
74
+ Get access to every paid essay, plus the newsletter.
75
+ </p>
76
+ <button
77
+ onClick={() => { /* TODO: open Sebastn checkout */ }}
78
+ style=\{{
79
+ padding: 'var(--space-3) var(--space-6)',
80
+ borderRadius: 'var(--radius-md, 0.5rem)',
81
+ border: 'none',
82
+ background: 'var(--primary)',
83
+ color: 'var(--bg)',
84
+ fontWeight: 600,
85
+ fontSize: '1rem',
86
+ cursor: 'pointer',
87
+ }}
88
+ >
89
+ Subscribe — $5/mo
90
+ </button>
91
+ </div>
92
+ );
93
+ }
@@ -8,7 +8,20 @@ import { listEntities } from '../../../../src/lib/db';
8
8
  * implement in src/lib/db.ts) and renders rows as a basic table.
9
9
  * Auth-gating happens client-side via `useAuth` in the static export;
10
10
  * if you need entity-level RBAC, check `verifyToken` claims here.
11
+ *
12
+ * generateStaticParams is required for `output: "export"`. Add the
13
+ * entity names your app actually uses. Unknown slugs are blocked by
14
+ * `dynamicParams = false`.
11
15
  */
16
+
17
+ const ENTITIES = ['users', 'products', 'orders'];
18
+
19
+ export function generateStaticParams() {
20
+ return ENTITIES.map((entity) => ({ entity }));
21
+ }
22
+
23
+ export const dynamicParams = false;
24
+
12
25
  export default async function EntityPage({
13
26
  params,
14
27
  }: {
@@ -1,8 +1,77 @@
1
- @import "@githat/ui/tokens.css";
1
+ /*
2
+ * Tailwind v4 — required because @githat/nextjs/styles is processed
3
+ * through @tailwindcss/postcss. Import the utilities if you want them;
4
+ * the auth pages only need the CSS variables below.
5
+ */
2
6
  {{#if useTailwind}}
3
7
  @import "tailwindcss";
4
8
  {{/if}}
5
9
 
10
+ /*
11
+ * Self-contained design tokens.
12
+ *
13
+ * Defines the CSS variables used by @githat/nextjs/styles and all
14
+ * template components. Override these to re-skin the whole app.
15
+ */
16
+
17
+ :root {
18
+ /* Surface */
19
+ --bg: #ffffff;
20
+ --surface: #fafafa;
21
+ --surface-sub: #f4f4f5;
22
+
23
+ /* Borders */
24
+ --border: #e5e7eb;
25
+
26
+ /* Foreground */
27
+ --fg: #0a0a0a;
28
+ --fg-muted: #525252;
29
+ --fg-subtle: #737373;
30
+
31
+ /* Brand — change these two to re-skin the whole auth flow */
32
+ --primary: #6366f1;
33
+ --accent: #f59e0b;
34
+
35
+ /* Semantic */
36
+ --success: #16a34a;
37
+ --warn: #d97706;
38
+ --danger: #dc2626;
39
+ --info: #0ea5e9;
40
+
41
+ /* Spacing — used by @githat/nextjs/styles */
42
+ --space-1: 0.25rem;
43
+ --space-2: 0.5rem;
44
+ --space-3: 0.75rem;
45
+ --space-4: 1rem;
46
+ --space-5: 1.25rem;
47
+ --space-6: 1.5rem;
48
+ --space-8: 2rem;
49
+ --space-12: 3rem;
50
+
51
+ /* Radius */
52
+ --radius: 0.5rem;
53
+ --radius-md: 0.5rem;
54
+ --radius-lg: 0.75rem;
55
+ --radius-full: 9999px;
56
+
57
+ /* Fonts */
58
+ --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
59
+ --font-wordmark: 'Instrument Serif', Georgia, serif;
60
+ --font-mono: 'Fira Code', 'Cascadia Code', Consolas, monospace;
61
+ }
62
+
63
+ @media (prefers-color-scheme: dark) {
64
+ :root {
65
+ --bg: #09090b;
66
+ --surface: #111113;
67
+ --surface-sub: #1e1e2e;
68
+ --border: #27272a;
69
+ --fg: #fafafa;
70
+ --fg-muted: #a1a1aa;
71
+ --fg-subtle: #71717a;
72
+ }
73
+ }
74
+
6
75
  * {
7
76
  box-sizing: border-box;
8
77
  margin: 0;
@@ -13,6 +82,7 @@ body {
13
82
  font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
14
83
  background: var(--bg, #09090b);
15
84
  color: var(--fg, #fafafa);
85
+ line-height: 1.5;
16
86
  }
17
87
 
18
88
  a {
@@ -1,5 +1,4 @@
1
1
  import { GitHatProvider } from '@githat/nextjs';
2
- import { Wordmark } from '@githat/ui';
3
2
  import '@githat/nextjs/styles';
4
3
  import './globals.css';
5
4
  {{#if includeGithatFolder}}
@@ -33,7 +32,9 @@ export default function RootLayout({ children }{{#if typescript}}: { children: R
33
32
  {{/if}}
34
33
  }}>
35
34
  <header style=\{{ padding: 'var(--space-4, 1rem) var(--space-6, 1.5rem)', borderBottom: '1px solid var(--border, #e5e7eb)' }}>
36
- <Wordmark name="{{businessName}}" size="md" href="/" />
35
+ <a href="/" style=\{{ textDecoration: 'none', color: 'inherit', fontWeight: 700, fontSize: '1.125rem' }}>
36
+ {{businessName}}
37
+ </a>
37
38
  </header>
38
39
  <main>{children}</main>
39
40
  </GitHatProvider>
@@ -1,5 +1,4 @@
1
1
  import { SignInButton, SignUpButton } from '@githat/nextjs';
2
- import { Wordmark } from '@githat/ui';
3
2
 
4
3
  const hasKey = !!process.env.NEXT_PUBLIC_GITHAT_PUBLISHABLE_KEY;
5
4
 
@@ -7,7 +6,9 @@ function SetupGuide() {
7
6
  return (
8
7
  <main {{#if useTailwind}}className="flex flex-col items-center justify-center min-h-screen gap-8 bg-[#09090b] text-[#fafafa] px-6"{{else}}style=\{{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', gap: '2rem', background: '#09090b', color: '#fafafa', padding: '0 1.5rem' }}{{/if}}>
9
8
  <div {{#if useTailwind}}className="text-center"{{else}}style=\{{ textAlign: 'center' }}{{/if}}>
10
- <Wordmark name="{{businessName}}" size="xl" />
9
+ <h1 {{#if useTailwind}}className="text-2xl font-bold"{{else}}style=\{{ fontSize: '1.5rem', fontWeight: 700 }}{{/if}}>
10
+ {{businessName}}
11
+ </h1>
11
12
  <p {{#if useTailwind}}className="text-zinc-400"{{else}}style=\{{ color: '#a1a1aa' }}{{/if}}>
12
13
  Get started in 3 steps
13
14
  </p>
@@ -57,7 +58,9 @@ export default function Home() {
57
58
 
58
59
  return (
59
60
  <main {{#if useTailwind}}className="flex flex-col items-center justify-center min-h-screen gap-6 bg-[#09090b] text-[#fafafa]"{{else}}style=\{{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', gap: '1.5rem', background: '#09090b', color: '#fafafa' }}{{/if}}>
60
- <Wordmark name="{{businessName}}" size="xl" />
61
+ <h1 {{#if useTailwind}}className="text-2xl font-bold"{{else}}style=\{{ fontSize: '1.5rem', fontWeight: 700 }}{{/if}}>
62
+ {{businessName}}
63
+ </h1>
61
64
  <p {{#if useTailwind}}className="text-zinc-400 max-w-lg text-center"{{else}}style=\{{ color: '#a1a1aa', maxWidth: '32rem', textAlign: 'center' }}{{/if}}>
62
65
  {{description}}
63
66
  </p>