@uidu/skills 0.2.0
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/README.md +42 -0
- package/package.json +25 -0
- package/skills/uidu/SKILL.md +321 -0
- package/skills/uidu/evals/evals.json +115 -0
- package/skills/uidu/metadata.json +8 -0
- package/skills/uidu-workspace/iteration-1/benchmark.md +43 -0
- package/skills/uidu-workspace/iteration-1/eval-0-event-detail/eval_metadata.json +13 -0
- package/skills/uidu-workspace/iteration-1/eval-0-event-detail/with_skill/grading.json +11 -0
- package/skills/uidu-workspace/iteration-1/eval-0-event-detail/with_skill/outputs/page.tsx +43 -0
- package/skills/uidu-workspace/iteration-1/eval-0-event-detail/without_skill/grading.json +11 -0
- package/skills/uidu-workspace/iteration-1/eval-0-event-detail/without_skill/outputs/page.tsx +46 -0
- package/skills/uidu-workspace/iteration-1/eval-1-stories-listing/eval_metadata.json +12 -0
- package/skills/uidu-workspace/iteration-1/eval-1-stories-listing/with_skill/grading.json +10 -0
- package/skills/uidu-workspace/iteration-1/eval-1-stories-listing/with_skill/outputs/page.tsx +33 -0
- package/skills/uidu-workspace/iteration-1/eval-1-stories-listing/without_skill/grading.json +10 -0
- package/skills/uidu-workspace/iteration-1/eval-1-stories-listing/without_skill/outputs/page.tsx +38 -0
- package/skills/uidu-workspace/iteration-1/eval-2-donations-debug/eval_metadata.json +10 -0
- package/skills/uidu-workspace/iteration-1/eval-2-donations-debug/with_skill/grading.json +8 -0
- package/skills/uidu-workspace/iteration-1/eval-2-donations-debug/with_skill/outputs/answer.md +63 -0
- package/skills/uidu-workspace/iteration-1/eval-2-donations-debug/without_skill/grading.json +9 -0
- package/skills/uidu-workspace/iteration-1/eval-2-donations-debug/without_skill/outputs/answer.md +47 -0
- package/skills/uidu-workspace/iteration-2/benchmark.md +73 -0
- package/skills/uidu-workspace/iteration-2/eval-0-event-detail/with_skill/outputs/page.tsx +37 -0
- package/skills/uidu-workspace/iteration-2/eval-0-event-detail/without_skill/outputs/page.tsx +60 -0
- package/skills/uidu-workspace/iteration-2/eval-1-stories-listing/with_skill/outputs/page.tsx +33 -0
- package/skills/uidu-workspace/iteration-2/eval-1-stories-listing/without_skill/outputs/page.tsx +36 -0
- package/skills/uidu-workspace/iteration-2/eval-2-donations-debug/with_skill/outputs/answer.md +51 -0
- package/skills/uidu-workspace/iteration-2/eval-2-donations-debug/without_skill/outputs/answer.md +77 -0
- package/skills/uidu-workspace/iteration-2/eval-3-client-setup/with_skill/outputs/uidu.ts +13 -0
- package/skills/uidu-workspace/iteration-2/eval-3-client-setup/without_skill/outputs/uidu.ts +47 -0
- package/skills/uidu-workspace/iteration-2/eval-4-cms-pageblocks/with_skill/grading.json +11 -0
- package/skills/uidu-workspace/iteration-2/eval-4-cms-pageblocks/with_skill/outputs/Hero.tsx +17 -0
- package/skills/uidu-workspace/iteration-2/eval-4-cms-pageblocks/with_skill/outputs/page.tsx +21 -0
- package/skills/uidu-workspace/iteration-2/eval-4-cms-pageblocks/without_skill/outputs/Hero.tsx +32 -0
- package/skills/uidu-workspace/iteration-2/eval-4-cms-pageblocks/without_skill/outputs/page.tsx +35 -0
- package/skills/uidu-workspace/iteration-2/eval-5-help-center/with_skill/outputs/page.tsx +54 -0
- package/skills/uidu-workspace/iteration-2/eval-5-help-center/without_skill/outputs/page.tsx +80 -0
- package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/with_skill/grading.json +11 -0
- package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/with_skill/outputs/ContactForm.tsx +277 -0
- package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/with_skill/outputs/page.tsx +48 -0
- package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/without_skill/outputs/ContactForm.tsx +144 -0
- package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/without_skill/outputs/page.tsx +45 -0
- package/skills/uidu-workspace/iteration-2/eval-7-routing-gotcha/with_skill/outputs/answer.md +63 -0
- package/skills/uidu-workspace/iteration-2/eval-7-routing-gotcha/without_skill/outputs/answer.md +131 -0
package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/with_skill/outputs/ContactForm.tsx
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/components/ContactForm.tsx
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useForm, type SubmitHandler } from 'react-hook-form';
|
|
6
|
+
import { createFormResponse, type Form } from '@uidu/client';
|
|
7
|
+
import { uidu } from '@/lib/uidu';
|
|
8
|
+
|
|
9
|
+
type FormQuestion = NonNullable<
|
|
10
|
+
NonNullable<Form['inputs']['edges']>[number]
|
|
11
|
+
>['node'];
|
|
12
|
+
|
|
13
|
+
type FieldOption = NonNullable<
|
|
14
|
+
NonNullable<NonNullable<FormQuestion>['field']>['options']
|
|
15
|
+
>[number];
|
|
16
|
+
|
|
17
|
+
interface ContactFormProps {
|
|
18
|
+
form: Form;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ContactFormValues {
|
|
22
|
+
__firstName: string;
|
|
23
|
+
__lastName: string;
|
|
24
|
+
__email: string;
|
|
25
|
+
// Per-question answers, keyed by question id.
|
|
26
|
+
[questionId: string]: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inputTypeFor(question: NonNullable<FormQuestion>): string {
|
|
30
|
+
const kind = (question.fieldKind ?? '').toLowerCase();
|
|
31
|
+
switch (kind) {
|
|
32
|
+
case 'email':
|
|
33
|
+
return 'email';
|
|
34
|
+
case 'number':
|
|
35
|
+
case 'integer':
|
|
36
|
+
return 'number';
|
|
37
|
+
case 'date':
|
|
38
|
+
return 'date';
|
|
39
|
+
case 'datetime':
|
|
40
|
+
case 'datetime-local':
|
|
41
|
+
return 'datetime-local';
|
|
42
|
+
case 'tel':
|
|
43
|
+
case 'phone':
|
|
44
|
+
return 'tel';
|
|
45
|
+
case 'url':
|
|
46
|
+
return 'url';
|
|
47
|
+
default:
|
|
48
|
+
return 'text';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isTextarea(question: NonNullable<FormQuestion>): boolean {
|
|
53
|
+
const kind = (question.fieldKind ?? '').toLowerCase();
|
|
54
|
+
return kind === 'textarea' || kind === 'longtext' || kind === 'multiline';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ContactForm({ form }: ContactFormProps) {
|
|
58
|
+
const [submitted, setSubmitted] = useState(false);
|
|
59
|
+
const [serverError, setServerError] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
const {
|
|
62
|
+
register,
|
|
63
|
+
handleSubmit,
|
|
64
|
+
formState: { errors, isSubmitting },
|
|
65
|
+
reset,
|
|
66
|
+
} = useForm<ContactFormValues>();
|
|
67
|
+
|
|
68
|
+
// Visible, user-editable questions, ordered by `position`.
|
|
69
|
+
const questions = (form.inputs.edges ?? [])
|
|
70
|
+
.map((edge) => edge?.node)
|
|
71
|
+
.filter((q): q is NonNullable<FormQuestion> => Boolean(q))
|
|
72
|
+
.filter((q) => q.field.type !== 'system' && !q.hidden)
|
|
73
|
+
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
74
|
+
|
|
75
|
+
const onSubmit: SubmitHandler<ContactFormValues> = async (values) => {
|
|
76
|
+
setServerError(null);
|
|
77
|
+
|
|
78
|
+
const fieldValues = questions.map((q) => ({
|
|
79
|
+
fieldId: q.field.id,
|
|
80
|
+
questionId: q.id,
|
|
81
|
+
value: String(values[q.id] ?? ''),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const result = await createFormResponse(uidu, {
|
|
86
|
+
input: {
|
|
87
|
+
formId: form.id,
|
|
88
|
+
contact: {
|
|
89
|
+
firstName: values.__firstName,
|
|
90
|
+
lastName: values.__lastName,
|
|
91
|
+
email: values.__email,
|
|
92
|
+
},
|
|
93
|
+
fieldValues,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (result && result.errors && result.errors.length > 0) {
|
|
98
|
+
setServerError(
|
|
99
|
+
result.errors
|
|
100
|
+
.map((e) => `${e.key ?? ''} ${e.name ?? ''}`.trim())
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join(', ') || 'Submission failed.',
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setSubmitted(true);
|
|
108
|
+
reset();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setServerError(
|
|
111
|
+
err instanceof Error ? err.message : 'Something went wrong.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (submitted) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="rounded-lg bg-green-50 p-6 text-green-900">
|
|
119
|
+
<p className="font-semibold">Thanks!</p>
|
|
120
|
+
<p className="mt-1 text-sm">
|
|
121
|
+
Your message has been received. We'll be in touch soon.
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const labelCls = 'mb-1 block text-sm font-medium';
|
|
128
|
+
const inputCls =
|
|
129
|
+
'w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none transition focus:border-slate-400 focus:ring-1 focus:ring-slate-200';
|
|
130
|
+
const errorCls = 'mt-1 text-xs text-red-600';
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
|
134
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
135
|
+
<div>
|
|
136
|
+
<label htmlFor="__firstName" className={labelCls}>
|
|
137
|
+
First name <span className="text-red-500">*</span>
|
|
138
|
+
</label>
|
|
139
|
+
<input
|
|
140
|
+
id="__firstName"
|
|
141
|
+
type="text"
|
|
142
|
+
className={inputCls}
|
|
143
|
+
{...register('__firstName', { required: 'First name is required' })}
|
|
144
|
+
/>
|
|
145
|
+
{errors.__firstName && (
|
|
146
|
+
<p className={errorCls}>{errors.__firstName.message}</p>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
<div>
|
|
150
|
+
<label htmlFor="__lastName" className={labelCls}>
|
|
151
|
+
Last name
|
|
152
|
+
</label>
|
|
153
|
+
<input
|
|
154
|
+
id="__lastName"
|
|
155
|
+
type="text"
|
|
156
|
+
className={inputCls}
|
|
157
|
+
{...register('__lastName')}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div>
|
|
163
|
+
<label htmlFor="__email" className={labelCls}>
|
|
164
|
+
Email <span className="text-red-500">*</span>
|
|
165
|
+
</label>
|
|
166
|
+
<input
|
|
167
|
+
id="__email"
|
|
168
|
+
type="email"
|
|
169
|
+
className={inputCls}
|
|
170
|
+
{...register('__email', {
|
|
171
|
+
required: 'Email is required',
|
|
172
|
+
pattern: {
|
|
173
|
+
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
174
|
+
message: 'Enter a valid email address',
|
|
175
|
+
},
|
|
176
|
+
})}
|
|
177
|
+
/>
|
|
178
|
+
{errors.__email && (
|
|
179
|
+
<p className={errorCls}>{errors.__email.message}</p>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{questions.map((q) => {
|
|
184
|
+
const label = String(q.label ?? '');
|
|
185
|
+
const required = q.required ?? false;
|
|
186
|
+
const options = q.field.options ?? [];
|
|
187
|
+
const validation = required ? { required: `${label} is required` } : {};
|
|
188
|
+
const fieldError = errors[q.id];
|
|
189
|
+
|
|
190
|
+
if (options.length > 0) {
|
|
191
|
+
return (
|
|
192
|
+
<div key={q.id}>
|
|
193
|
+
<label htmlFor={q.id} className={labelCls}>
|
|
194
|
+
{label}
|
|
195
|
+
{required && <span className="ml-1 text-red-500">*</span>}
|
|
196
|
+
</label>
|
|
197
|
+
<select
|
|
198
|
+
id={q.id}
|
|
199
|
+
className={inputCls}
|
|
200
|
+
defaultValue=""
|
|
201
|
+
{...register(q.id, validation)}
|
|
202
|
+
>
|
|
203
|
+
<option value="" disabled>
|
|
204
|
+
{q.placeholder || 'Choose an option…'}
|
|
205
|
+
</option>
|
|
206
|
+
{options.map((opt: FieldOption) => (
|
|
207
|
+
<option key={opt.id} value={opt.id}>
|
|
208
|
+
{opt.name}
|
|
209
|
+
</option>
|
|
210
|
+
))}
|
|
211
|
+
</select>
|
|
212
|
+
{q.hint && <p className="mt-1 text-xs text-slate-500">{q.hint}</p>}
|
|
213
|
+
{fieldError && (
|
|
214
|
+
<p className={errorCls}>{String(fieldError.message)}</p>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isTextarea(q)) {
|
|
221
|
+
return (
|
|
222
|
+
<div key={q.id}>
|
|
223
|
+
<label htmlFor={q.id} className={labelCls}>
|
|
224
|
+
{label}
|
|
225
|
+
{required && <span className="ml-1 text-red-500">*</span>}
|
|
226
|
+
</label>
|
|
227
|
+
<textarea
|
|
228
|
+
id={q.id}
|
|
229
|
+
placeholder={q.placeholder ?? ''}
|
|
230
|
+
className={`${inputCls} min-h-[120px]`}
|
|
231
|
+
{...register(q.id, validation)}
|
|
232
|
+
/>
|
|
233
|
+
{q.hint && <p className="mt-1 text-xs text-slate-500">{q.hint}</p>}
|
|
234
|
+
{fieldError && (
|
|
235
|
+
<p className={errorCls}>{String(fieldError.message)}</p>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div key={q.id}>
|
|
243
|
+
<label htmlFor={q.id} className={labelCls}>
|
|
244
|
+
{label}
|
|
245
|
+
{required && <span className="ml-1 text-red-500">*</span>}
|
|
246
|
+
</label>
|
|
247
|
+
<input
|
|
248
|
+
id={q.id}
|
|
249
|
+
type={inputTypeFor(q)}
|
|
250
|
+
placeholder={q.placeholder ?? ''}
|
|
251
|
+
className={inputCls}
|
|
252
|
+
{...register(q.id, validation)}
|
|
253
|
+
/>
|
|
254
|
+
{q.hint && <p className="mt-1 text-xs text-slate-500">{q.hint}</p>}
|
|
255
|
+
{fieldError && (
|
|
256
|
+
<p className={errorCls}>{String(fieldError.message)}</p>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
|
|
262
|
+
{serverError && (
|
|
263
|
+
<div className="rounded-md bg-red-50 p-3 text-sm text-red-900">
|
|
264
|
+
{serverError}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
<button
|
|
269
|
+
type="submit"
|
|
270
|
+
disabled={isSubmitting}
|
|
271
|
+
className="inline-flex w-full items-center justify-center rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-xs transition hover:bg-slate-800 disabled:opacity-50"
|
|
272
|
+
>
|
|
273
|
+
{isSubmitting ? 'Sending…' : 'Send message'}
|
|
274
|
+
</button>
|
|
275
|
+
</form>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/app/contact/page.tsx
|
|
2
|
+
import { getForm } from '@uidu/client';
|
|
3
|
+
import { uidu } from '@/lib/uidu';
|
|
4
|
+
import { ContactForm } from '@/components/ContactForm';
|
|
5
|
+
|
|
6
|
+
export default async function ContactPage() {
|
|
7
|
+
const formId = process.env.UIDU_CONTACT_FORM_ID;
|
|
8
|
+
|
|
9
|
+
if (!formId) {
|
|
10
|
+
return (
|
|
11
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
12
|
+
<h1 className="text-3xl font-semibold tracking-tight">Contact</h1>
|
|
13
|
+
<p className="mt-4 text-sm text-slate-600">
|
|
14
|
+
Set <code className="rounded bg-slate-100 px-1.5 py-0.5">UIDU_CONTACT_FORM_ID</code>{' '}
|
|
15
|
+
in your environment to enable the contact form.
|
|
16
|
+
</p>
|
|
17
|
+
</main>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const form = await getForm(uidu, { id: formId });
|
|
22
|
+
|
|
23
|
+
if (!form) {
|
|
24
|
+
return (
|
|
25
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
26
|
+
<h1 className="text-3xl font-semibold tracking-tight">Contact</h1>
|
|
27
|
+
<p className="mt-4 text-sm text-slate-600">
|
|
28
|
+
Form not found. Check that <code>UIDU_CONTACT_FORM_ID</code> is correct.
|
|
29
|
+
</p>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
36
|
+
<header className="mb-8">
|
|
37
|
+
<h1 className="text-3xl font-semibold tracking-tight">
|
|
38
|
+
{form.name ?? 'Get in touch'}
|
|
39
|
+
</h1>
|
|
40
|
+
{form.description && (
|
|
41
|
+
<p className="mt-2 text-sm text-slate-600">{form.description}</p>
|
|
42
|
+
)}
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<ContactForm form={form} />
|
|
46
|
+
</main>
|
|
47
|
+
);
|
|
48
|
+
}
|
package/skills/uidu-workspace/iteration-2/eval-6-forms-contact/without_skill/outputs/ContactForm.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useForm as useUiduForm } from '@uidu/react/apps/forms';
|
|
4
|
+
import { useForm } from 'react-hook-form';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import type { Form } from '@uidu/client/forms';
|
|
7
|
+
|
|
8
|
+
type ContactFormProps = {
|
|
9
|
+
form: Form;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type FormValues = Record<string, string>;
|
|
13
|
+
|
|
14
|
+
export function ContactForm({ form }: ContactFormProps) {
|
|
15
|
+
const { handleSubmit: submitToUidu } = useUiduForm({ form });
|
|
16
|
+
const [submitState, setSubmitState] = useState<
|
|
17
|
+
{ status: 'idle' } | { status: 'success' } | { status: 'error'; message: string }
|
|
18
|
+
>({ status: 'idle' });
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
register,
|
|
22
|
+
handleSubmit,
|
|
23
|
+
formState: { errors, isSubmitting },
|
|
24
|
+
reset,
|
|
25
|
+
} = useForm<FormValues>();
|
|
26
|
+
|
|
27
|
+
const questions =
|
|
28
|
+
form.formQuestions?.edges?.map((edge) => edge.node).filter(Boolean) ?? [];
|
|
29
|
+
|
|
30
|
+
const onSubmit = async (values: FormValues) => {
|
|
31
|
+
try {
|
|
32
|
+
await submitToUidu(values);
|
|
33
|
+
setSubmitState({ status: 'success' });
|
|
34
|
+
reset();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setSubmitState({
|
|
37
|
+
status: 'error',
|
|
38
|
+
message: err instanceof Error ? err.message : 'Submission failed',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (submitState.status === 'success') {
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
role="status"
|
|
47
|
+
className="rounded-lg border border-green-200 bg-green-50 p-6 text-green-900"
|
|
48
|
+
>
|
|
49
|
+
<h2 className="text-lg font-semibold">Thanks for reaching out!</h2>
|
|
50
|
+
<p className="mt-1 text-sm">
|
|
51
|
+
We’ve received your message and will be in touch soon.
|
|
52
|
+
</p>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={() => setSubmitState({ status: 'idle' })}
|
|
56
|
+
className="mt-4 text-sm font-medium underline"
|
|
57
|
+
>
|
|
58
|
+
Send another message
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" noValidate>
|
|
66
|
+
{questions.map((question) => {
|
|
67
|
+
const field = question.field;
|
|
68
|
+
const shortname = field?.shortname ?? question.id;
|
|
69
|
+
const label = field?.name ?? question.name ?? shortname;
|
|
70
|
+
const required = question.required ?? false;
|
|
71
|
+
const kind = field?.kind ?? 'string';
|
|
72
|
+
|
|
73
|
+
const registration = register(shortname, {
|
|
74
|
+
required: required ? `${label} is required` : false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const baseProps = {
|
|
78
|
+
id: shortname,
|
|
79
|
+
'aria-invalid': errors[shortname] ? true : undefined,
|
|
80
|
+
className:
|
|
81
|
+
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div key={question.id} className="space-y-2">
|
|
86
|
+
<label
|
|
87
|
+
htmlFor={shortname}
|
|
88
|
+
className="block text-sm font-medium text-foreground"
|
|
89
|
+
>
|
|
90
|
+
{label}
|
|
91
|
+
{required ? <span className="ml-1 text-red-500">*</span> : null}
|
|
92
|
+
</label>
|
|
93
|
+
|
|
94
|
+
{kind === 'text' ? (
|
|
95
|
+
<textarea
|
|
96
|
+
{...baseProps}
|
|
97
|
+
{...registration}
|
|
98
|
+
rows={5}
|
|
99
|
+
placeholder={question.placeholder ?? ''}
|
|
100
|
+
/>
|
|
101
|
+
) : kind === 'email' ? (
|
|
102
|
+
<input
|
|
103
|
+
{...baseProps}
|
|
104
|
+
{...registration}
|
|
105
|
+
type="email"
|
|
106
|
+
placeholder={question.placeholder ?? ''}
|
|
107
|
+
/>
|
|
108
|
+
) : (
|
|
109
|
+
<input
|
|
110
|
+
{...baseProps}
|
|
111
|
+
{...registration}
|
|
112
|
+
type="text"
|
|
113
|
+
placeholder={question.placeholder ?? ''}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{errors[shortname] ? (
|
|
118
|
+
<p className="text-sm text-red-600" role="alert">
|
|
119
|
+
{errors[shortname]?.message as string}
|
|
120
|
+
</p>
|
|
121
|
+
) : null}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
|
|
126
|
+
{submitState.status === 'error' ? (
|
|
127
|
+
<div
|
|
128
|
+
role="alert"
|
|
129
|
+
className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-900"
|
|
130
|
+
>
|
|
131
|
+
{submitState.message}
|
|
132
|
+
</div>
|
|
133
|
+
) : null}
|
|
134
|
+
|
|
135
|
+
<button
|
|
136
|
+
type="submit"
|
|
137
|
+
disabled={isSubmitting}
|
|
138
|
+
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 disabled:opacity-50"
|
|
139
|
+
>
|
|
140
|
+
{isSubmitting ? 'Sending…' : 'Send message'}
|
|
141
|
+
</button>
|
|
142
|
+
</form>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getForm } from '@uidu/client/forms';
|
|
2
|
+
import { ContactForm } from '@/components/ContactForm';
|
|
3
|
+
|
|
4
|
+
export default async function ContactPage() {
|
|
5
|
+
const formId = process.env.UIDU_CONTACT_FORM_ID;
|
|
6
|
+
|
|
7
|
+
if (!formId) {
|
|
8
|
+
throw new Error('UIDU_CONTACT_FORM_ID environment variable is not set');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const endpoint = process.env.UIDU_GRAPHQL_ENDPOINT ?? 'https://uidu.org/graphql';
|
|
12
|
+
const projectId = process.env.UIDU_PROJECT_ID;
|
|
13
|
+
|
|
14
|
+
const form = await getForm({
|
|
15
|
+
endpoint,
|
|
16
|
+
projectId,
|
|
17
|
+
id: formId,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!form) {
|
|
21
|
+
return (
|
|
22
|
+
<main className="container mx-auto py-12">
|
|
23
|
+
<h1 className="text-3xl font-bold">Contact</h1>
|
|
24
|
+
<p className="text-muted-foreground mt-4">
|
|
25
|
+
The contact form is currently unavailable. Please try again later.
|
|
26
|
+
</p>
|
|
27
|
+
</main>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<main className="container mx-auto py-12 max-w-2xl">
|
|
33
|
+
<header className="mb-8">
|
|
34
|
+
<h1 className="text-3xl font-bold tracking-tight">
|
|
35
|
+
{form.name ?? 'Contact us'}
|
|
36
|
+
</h1>
|
|
37
|
+
{form.description ? (
|
|
38
|
+
<p className="text-muted-foreground mt-2">{form.description}</p>
|
|
39
|
+
) : null}
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
<ContactForm form={form} />
|
|
43
|
+
</main>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Diagnosis: Root-level `/[id]` is catching static asset requests
|
|
2
|
+
|
|
3
|
+
## What's actually going on
|
|
4
|
+
|
|
5
|
+
Your detail page lives at `src/app/[id]/page.tsx`, which makes `[id]` a **root-level dynamic segment**. In the Next.js App Router, that segment matches *any* single-segment path at the root — including paths the browser asks for that have nothing to do with your stories.
|
|
6
|
+
|
|
7
|
+
When a browser loads any page, it automatically requests static assets at well-known root paths:
|
|
8
|
+
|
|
9
|
+
- `/favicon.ico`
|
|
10
|
+
- `/robots.txt`
|
|
11
|
+
- `/sitemap.xml`
|
|
12
|
+
- `/apple-touch-icon.png`
|
|
13
|
+
- `/manifest.json`
|
|
14
|
+
- assorted Chrome/Apple/Android icon probes
|
|
15
|
+
|
|
16
|
+
If you don't have a real file for one of these (e.g. no `app/favicon.ico`, no `app/robots.txt`), Next.js falls through to your dynamic route. Your page component then runs with `params.id = 'favicon.ico'` and calls `getStory(uidu, { id: 'favicon.ico' })`. The uidu API has no story with that id, so it logs a GraphQL error. Same story for `/robots.txt`, `/sitemap.xml`, etc.
|
|
17
|
+
|
|
18
|
+
This is purely a **routing collision**, not a bug in `@uidu/client` or in your fetch logic. The fetch is behaving correctly — it's being asked for ids that don't exist because the URL was never meant to hit your route in the first place.
|
|
19
|
+
|
|
20
|
+
## The fix: nest detail routes under a scope path
|
|
21
|
+
|
|
22
|
+
Move the dynamic segment one level deeper so it can never collide with root-level asset requests. The convention used by every `create-uidu-app` template is `/<scope>/[id]`:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/app/
|
|
26
|
+
├── layout.tsx
|
|
27
|
+
├── page.tsx # listing at /
|
|
28
|
+
└── story/
|
|
29
|
+
└── [id]/
|
|
30
|
+
└── page.tsx # detail at /story/<id>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After the move:
|
|
34
|
+
|
|
35
|
+
- `/` → your listing page
|
|
36
|
+
- `/story/abc123` → your detail page
|
|
37
|
+
- `/favicon.ico`, `/robots.txt`, `/sitemap.xml` → no longer match any route, Next.js returns a normal 404 for the asset, and **your `getStory` is never called with garbage ids**
|
|
38
|
+
|
|
39
|
+
Update any links accordingly:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
<Link href={`/story/${story.id}`}>{story.name}</Link>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Optional hardening
|
|
46
|
+
|
|
47
|
+
Even after the move, it's worth:
|
|
48
|
+
|
|
49
|
+
1. **Providing real assets** — drop a `favicon.ico` into `src/app/` and a `robots.txt` / `sitemap.ts` so browsers and crawlers get a real response instead of a 404.
|
|
50
|
+
2. **Guarding the detail page** — `getStory` returns `null` for unknown ids, so call `notFound()` from `next/navigation` when the result is null. That way any future routing surprise produces a clean 404 instead of a noisy error.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { notFound } from 'next/navigation';
|
|
54
|
+
import { getStory } from '@uidu/client';
|
|
55
|
+
|
|
56
|
+
export default async function StoryPage({ params }: { params: { id: string } }) {
|
|
57
|
+
const story = await getStory(uidu, { id: params.id });
|
|
58
|
+
if (!story) notFound();
|
|
59
|
+
// ...
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The structural change (nesting under `/story/[id]`) is the actual fix; the guard is defense in depth.
|