@aex.is/zero 0.1.5 → 0.1.7
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 +23 -1
- package/assets/icon.png +0 -0
- package/assets/icon.svg +4 -0
- package/assets/social.png +0 -0
- package/bin/zero-darwin-amd64 +0 -0
- package/bin/zero-darwin-arm64 +0 -0
- package/bin/zero-linux-amd64 +0 -0
- package/bin/zero-linux-arm64 +0 -0
- package/bin/zero-windows-amd64.exe +0 -0
- package/dist/cli/bubbletea.js +57 -0
- package/dist/config/base-env.js +31 -0
- package/dist/config/frameworks.js +1 -0
- package/dist/engine/assets.js +54 -0
- package/dist/engine/env.js +5 -3
- package/dist/engine/scaffold.js +62 -4
- package/dist/engine/templates.js +497 -88
- package/dist/index.js +1 -1
- package/package.json +6 -3
- package/dist/cli/prompts.js +0 -163
package/dist/engine/templates.js
CHANGED
|
@@ -37,6 +37,10 @@ export function buildNextTemplateFiles(data) {
|
|
|
37
37
|
path: `${base}app/guide/page.tsx`,
|
|
38
38
|
content: nextRouteTemplate('Guide', 'Three routes are ready. Customize and ship.')
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
path: `${base}app/api/contact/route.ts`,
|
|
42
|
+
content: nextContactRouteTemplate(data.appName)
|
|
43
|
+
},
|
|
40
44
|
{
|
|
41
45
|
path: `${base}app/globals.css`,
|
|
42
46
|
content: nextGlobalsCss()
|
|
@@ -49,6 +53,10 @@ export function buildNextTemplateFiles(data) {
|
|
|
49
53
|
path: `${base}components/site-footer.tsx`,
|
|
50
54
|
content: nextFooterTemplate(data.domain)
|
|
51
55
|
},
|
|
56
|
+
{
|
|
57
|
+
path: `${base}components/contact-form.tsx`,
|
|
58
|
+
content: nextContactFormTemplate()
|
|
59
|
+
},
|
|
52
60
|
{
|
|
53
61
|
path: `${base}components/env-list.tsx`,
|
|
54
62
|
content: nextEnvListTemplate(envList)
|
|
@@ -94,6 +102,10 @@ export function buildExpoTemplateFiles(data) {
|
|
|
94
102
|
path: 'components/env-list.tsx',
|
|
95
103
|
content: expoEnvListTemplate(envItems)
|
|
96
104
|
},
|
|
105
|
+
{
|
|
106
|
+
path: 'components/contact-form.tsx',
|
|
107
|
+
content: expoContactFormTemplate()
|
|
108
|
+
},
|
|
97
109
|
{
|
|
98
110
|
path: 'components/page-shell.tsx',
|
|
99
111
|
content: expoPageShellTemplate()
|
|
@@ -113,14 +125,31 @@ export function buildExpoTemplateFiles(data) {
|
|
|
113
125
|
];
|
|
114
126
|
}
|
|
115
127
|
function nextLayoutTemplate(appName, domain) {
|
|
128
|
+
const description = `${escapeTemplate(appName)} starter crafted by Aexis Zero.`;
|
|
129
|
+
const domainValue = escapeTemplate(domain);
|
|
130
|
+
const metadataBase = domainValue ? `new URL('https://${domainValue}')` : 'undefined';
|
|
116
131
|
return `import type { ReactNode } from 'react';
|
|
117
132
|
import './globals.css';
|
|
118
133
|
import { SiteHeader } from '@/components/site-header';
|
|
119
134
|
import { SiteFooter } from '@/components/site-footer';
|
|
120
135
|
|
|
121
136
|
export const metadata = {
|
|
122
|
-
title:
|
|
123
|
-
|
|
137
|
+
title: {
|
|
138
|
+
default: '${escapeTemplate(appName)}',
|
|
139
|
+
template: '%s | ${escapeTemplate(appName)}'
|
|
140
|
+
},
|
|
141
|
+
description: '${description}',
|
|
142
|
+
metadataBase: ${metadataBase},
|
|
143
|
+
openGraph: {
|
|
144
|
+
title: '${escapeTemplate(appName)}',
|
|
145
|
+
description: '${description}',
|
|
146
|
+
type: 'website'
|
|
147
|
+
},
|
|
148
|
+
twitter: {
|
|
149
|
+
card: 'summary_large_image',
|
|
150
|
+
title: '${escapeTemplate(appName)}',
|
|
151
|
+
description: '${description}'
|
|
152
|
+
}
|
|
124
153
|
};
|
|
125
154
|
|
|
126
155
|
export default function RootLayout({
|
|
@@ -133,8 +162,8 @@ export default function RootLayout({
|
|
|
133
162
|
<body className="min-h-screen bg-[var(--bg)] text-[var(--fg)]">
|
|
134
163
|
<div className="flex min-h-screen flex-col">
|
|
135
164
|
<SiteHeader appName="${escapeTemplate(appName)}" />
|
|
136
|
-
<main className="flex-1">{children}</main>
|
|
137
|
-
<SiteFooter
|
|
165
|
+
<main className="flex flex-1 items-center justify-center">{children}</main>
|
|
166
|
+
<SiteFooter />
|
|
138
167
|
</div>
|
|
139
168
|
</body>
|
|
140
169
|
</html>
|
|
@@ -144,37 +173,54 @@ export default function RootLayout({
|
|
|
144
173
|
}
|
|
145
174
|
function nextHomeTemplate(appName, domain, envList) {
|
|
146
175
|
return `import { EnvList } from '@/components/env-list';
|
|
176
|
+
import { ContactForm } from '@/components/contact-form';
|
|
177
|
+
|
|
178
|
+
export const metadata = {
|
|
179
|
+
title: 'Home',
|
|
180
|
+
description: 'A minimal starter with routes, metadata, and env var guidance.'
|
|
181
|
+
};
|
|
147
182
|
|
|
148
183
|
export default function Home() {
|
|
149
184
|
return (
|
|
150
185
|
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-6 py-12">
|
|
151
|
-
<div className="flex flex-col gap-
|
|
152
|
-
<p className="text-
|
|
153
|
-
<h1 className="text-
|
|
154
|
-
<p className="text-
|
|
186
|
+
<div className="flex flex-col gap-2">
|
|
187
|
+
<p className="text-base font-bold uppercase tracking-[0.4em]">Hello World</p>
|
|
188
|
+
<h1 className="text-base font-bold">${escapeTemplate(appName)}</h1>
|
|
189
|
+
<p className="text-base">
|
|
155
190
|
${escapeTemplate(domain) ? `Domain: ${escapeTemplate(domain)}` : 'No domain configured yet.'}
|
|
156
191
|
</p>
|
|
157
192
|
</div>
|
|
158
|
-
<div className="
|
|
159
|
-
<h2 className="text-
|
|
160
|
-
<p className="text-
|
|
193
|
+
<div className="flex flex-col gap-3">
|
|
194
|
+
<h2 className="text-base font-bold">Environment variables</h2>
|
|
195
|
+
<p className="text-base">
|
|
196
|
+
Set these in your <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">.env</code>.
|
|
197
|
+
</p>
|
|
161
198
|
<EnvList />
|
|
162
199
|
</div>
|
|
163
|
-
<div className="
|
|
164
|
-
<h2 className="text-
|
|
165
|
-
<p className="text-
|
|
200
|
+
<div className="flex flex-col gap-2">
|
|
201
|
+
<h2 className="text-base font-bold">Routes</h2>
|
|
202
|
+
<p className="text-base">
|
|
203
|
+
Explore <code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/about</code> and{' '}
|
|
204
|
+
<code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/guide</code>.
|
|
205
|
+
</p>
|
|
166
206
|
</div>
|
|
207
|
+
<ContactForm />
|
|
167
208
|
</section>
|
|
168
209
|
);
|
|
169
210
|
}
|
|
170
211
|
`;
|
|
171
212
|
}
|
|
172
213
|
function nextRouteTemplate(title, body) {
|
|
173
|
-
return `export
|
|
214
|
+
return `export const metadata = {
|
|
215
|
+
title: '${title}',
|
|
216
|
+
description: '${body}'
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export default function Page() {
|
|
174
220
|
return (
|
|
175
221
|
<section className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-12">
|
|
176
|
-
<h1 className="text-
|
|
177
|
-
<p className="text-
|
|
222
|
+
<h1 className="text-base font-bold">${title}</h1>
|
|
223
|
+
<p className="text-base">${body}</p>
|
|
178
224
|
</section>
|
|
179
225
|
);
|
|
180
226
|
}
|
|
@@ -191,13 +237,13 @@ const links = [
|
|
|
191
237
|
|
|
192
238
|
export function SiteHeader({ appName }: { appName: string }) {
|
|
193
239
|
return (
|
|
194
|
-
<header
|
|
195
|
-
<div className="mx-auto flex w-full max-w-4xl items-center justify-between px-6 py-
|
|
240
|
+
<header>
|
|
241
|
+
<div className="mx-auto flex w-full max-w-4xl items-center justify-between px-6 py-6">
|
|
196
242
|
<div className="flex items-baseline gap-3">
|
|
197
|
-
<span className="text-
|
|
198
|
-
<span className="text-
|
|
243
|
+
<span className="text-base font-bold tracking-[0.3em]">ZER0</span>
|
|
244
|
+
<span className="text-base font-bold uppercase tracking-[0.2em]">{appName}</span>
|
|
199
245
|
</div>
|
|
200
|
-
<nav className="hidden items-center gap-6 text-
|
|
246
|
+
<nav className="hidden items-center gap-6 text-base sm:flex">
|
|
201
247
|
{links.map((link) => (
|
|
202
248
|
<Link key={link.href} href={link.href} className="underline-offset-4 hover:underline">
|
|
203
249
|
{link.label}
|
|
@@ -205,8 +251,8 @@ export function SiteHeader({ appName }: { appName: string }) {
|
|
|
205
251
|
))}
|
|
206
252
|
</nav>
|
|
207
253
|
<details className="sm:hidden">
|
|
208
|
-
<summary className="cursor-pointer text-
|
|
209
|
-
<div className="mt-3 flex flex-col gap-3 text-
|
|
254
|
+
<summary className="cursor-pointer text-base font-bold">Menu</summary>
|
|
255
|
+
<div className="mt-3 flex flex-col gap-3 text-base">
|
|
210
256
|
{links.map((link) => (
|
|
211
257
|
<Link key={link.href} href={link.href} className="underline-offset-4 hover:underline">
|
|
212
258
|
{link.label}
|
|
@@ -224,10 +270,10 @@ function nextFooterTemplate(domain) {
|
|
|
224
270
|
const domainLabel = escapeTemplate(domain).trim().length > 0
|
|
225
271
|
? `Domain: ${escapeTemplate(domain)}`
|
|
226
272
|
: 'Domain: not set';
|
|
227
|
-
return `export function SiteFooter(
|
|
273
|
+
return `export function SiteFooter() {
|
|
228
274
|
return (
|
|
229
|
-
<footer
|
|
230
|
-
<div className=\"mx-auto flex w-full max-w-4xl flex-col gap-2 px-6 py-
|
|
275
|
+
<footer>
|
|
276
|
+
<div className=\"mx-auto flex w-full max-w-4xl flex-col gap-2 px-6 py-6 text-base\">
|
|
231
277
|
<span>${domainLabel}</span>
|
|
232
278
|
<span>Generated by Aexis Zero.</span>
|
|
233
279
|
</div>
|
|
@@ -255,8 +301,179 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
255
301
|
}
|
|
256
302
|
`;
|
|
257
303
|
}
|
|
304
|
+
function nextContactFormTemplate() {
|
|
305
|
+
return `'use client';
|
|
306
|
+
|
|
307
|
+
import { useState, type ChangeEvent, type FormEvent } from 'react';
|
|
308
|
+
|
|
309
|
+
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
|
310
|
+
|
|
311
|
+
type FormState = {
|
|
312
|
+
name: string;
|
|
313
|
+
email: string;
|
|
314
|
+
message: string;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const initialState: FormState = {
|
|
318
|
+
name: '',
|
|
319
|
+
email: '',
|
|
320
|
+
message: ''
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export function ContactForm() {
|
|
324
|
+
const [form, setForm] = useState<FormState>(initialState);
|
|
325
|
+
const [status, setStatus] = useState<Status>('idle');
|
|
326
|
+
const [error, setError] = useState<string | null>(null);
|
|
327
|
+
|
|
328
|
+
const updateField =
|
|
329
|
+
(key: keyof FormState) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
330
|
+
setForm((prev) => ({ ...prev, [key]: event.target.value }));
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
if (!form.name.trim() || !form.email.trim() || !form.message.trim()) {
|
|
336
|
+
setStatus('error');
|
|
337
|
+
setError('All fields are required.');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setStatus('sending');
|
|
342
|
+
setError(null);
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch('/api/contact', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
name: form.name.trim(),
|
|
350
|
+
email: form.email.trim(),
|
|
351
|
+
message: form.message.trim()
|
|
352
|
+
})
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
const payload = await response.json().catch(() => null);
|
|
357
|
+
throw new Error(payload?.error ?? 'Unable to send message.');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
setStatus('sent');
|
|
361
|
+
setForm(initialState);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
setStatus('error');
|
|
364
|
+
setError(err instanceof Error ? err.message : 'Unable to send message.');
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<form className="flex flex-col gap-4 text-base" onSubmit={handleSubmit}>
|
|
370
|
+
<div className="flex flex-col gap-2">
|
|
371
|
+
<h2 className="text-base font-bold">Contact</h2>
|
|
372
|
+
<p className="text-base">Send a quick note to your inbox.</p>
|
|
373
|
+
</div>
|
|
374
|
+
<label className="flex flex-col gap-1">
|
|
375
|
+
<span className="text-base font-bold">Name</span>
|
|
376
|
+
<input
|
|
377
|
+
className="border-b border-[var(--fg)] bg-transparent py-1 focus:outline-none"
|
|
378
|
+
value={form.name}
|
|
379
|
+
onChange={updateField('name')}
|
|
380
|
+
type="text"
|
|
381
|
+
autoComplete="name"
|
|
382
|
+
/>
|
|
383
|
+
</label>
|
|
384
|
+
<label className="flex flex-col gap-1">
|
|
385
|
+
<span className="text-base font-bold">Email</span>
|
|
386
|
+
<input
|
|
387
|
+
className="border-b border-[var(--fg)] bg-transparent py-1 focus:outline-none"
|
|
388
|
+
value={form.email}
|
|
389
|
+
onChange={updateField('email')}
|
|
390
|
+
type="email"
|
|
391
|
+
autoComplete="email"
|
|
392
|
+
/>
|
|
393
|
+
</label>
|
|
394
|
+
<label className="flex flex-col gap-1">
|
|
395
|
+
<span className="text-base font-bold">Message</span>
|
|
396
|
+
<textarea
|
|
397
|
+
className="min-h-[96px] border-b border-[var(--fg)] bg-transparent py-1 focus:outline-none"
|
|
398
|
+
value={form.message}
|
|
399
|
+
onChange={updateField('message')}
|
|
400
|
+
rows={4}
|
|
401
|
+
/>
|
|
402
|
+
</label>
|
|
403
|
+
<button
|
|
404
|
+
className="self-start text-base font-bold underline underline-offset-4 disabled:opacity-60"
|
|
405
|
+
type="submit"
|
|
406
|
+
disabled={status === 'sending'}
|
|
407
|
+
>
|
|
408
|
+
{status === 'sending' ? 'Sending...' : 'Send message'}
|
|
409
|
+
</button>
|
|
410
|
+
{status === 'sent' ? (
|
|
411
|
+
<p className="text-base" role="status">
|
|
412
|
+
Message sent.
|
|
413
|
+
</p>
|
|
414
|
+
) : null}
|
|
415
|
+
{status === 'error' && error ? (
|
|
416
|
+
<p className="text-base" role="status">
|
|
417
|
+
{error}
|
|
418
|
+
</p>
|
|
419
|
+
) : null}
|
|
420
|
+
</form>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
`;
|
|
424
|
+
}
|
|
425
|
+
function nextContactRouteTemplate(appName) {
|
|
426
|
+
return `import { Resend } from 'resend';
|
|
427
|
+
|
|
428
|
+
type Payload = {
|
|
429
|
+
name: string;
|
|
430
|
+
email: string;
|
|
431
|
+
message: string;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
function isNonEmpty(value: unknown): value is string {
|
|
435
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function POST(request: Request) {
|
|
439
|
+
const apiKey = process.env.RESEND_API_KEY?.trim();
|
|
440
|
+
const from = process.env.EMAIL_FROM?.trim();
|
|
441
|
+
const to = process.env.EMAIL_TO?.trim();
|
|
442
|
+
|
|
443
|
+
if (!apiKey || !from || !to) {
|
|
444
|
+
return Response.json(
|
|
445
|
+
{ error: 'Set RESEND_API_KEY, EMAIL_FROM, and EMAIL_TO.' },
|
|
446
|
+
{ status: 500 }
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const body = (await request.json().catch(() => null)) as Partial<Payload> | null;
|
|
451
|
+
if (!body || !isNonEmpty(body.name) || !isNonEmpty(body.email) || !isNonEmpty(body.message)) {
|
|
452
|
+
return Response.json({ error: 'Invalid payload.' }, { status: 400 });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const resend = new Resend(apiKey);
|
|
456
|
+
const name = body.name.trim();
|
|
457
|
+
const email = body.email.trim();
|
|
458
|
+
const message = body.message.trim();
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
await resend.emails.send({
|
|
462
|
+
from,
|
|
463
|
+
to,
|
|
464
|
+
subject: 'New message from ${escapeTemplate(appName)}',
|
|
465
|
+
text: \`Name: \${name}\\nEmail: \${email}\\n\\n\${message}\`
|
|
466
|
+
});
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return Response.json({ error: 'Failed to send message.' }, { status: 500 });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return Response.json({ ok: true });
|
|
472
|
+
}
|
|
473
|
+
`;
|
|
474
|
+
}
|
|
258
475
|
function nextGlobalsCss() {
|
|
259
|
-
return `@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@
|
|
476
|
+
return `@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;700&display=swap');
|
|
260
477
|
|
|
261
478
|
@tailwind base;
|
|
262
479
|
@tailwind components;
|
|
@@ -274,16 +491,14 @@ function nextGlobalsCss() {
|
|
|
274
491
|
}
|
|
275
492
|
}
|
|
276
493
|
|
|
277
|
-
* {
|
|
278
|
-
border-color: var(--fg);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
494
|
html,
|
|
282
495
|
body {
|
|
283
496
|
min-height: 100%;
|
|
284
497
|
background: var(--bg);
|
|
285
498
|
color: var(--fg);
|
|
286
499
|
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
|
500
|
+
font-size: 16px;
|
|
501
|
+
font-weight: 400;
|
|
287
502
|
}
|
|
288
503
|
|
|
289
504
|
a {
|
|
@@ -295,28 +510,39 @@ a {
|
|
|
295
510
|
code {
|
|
296
511
|
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
|
297
512
|
}
|
|
513
|
+
|
|
514
|
+
button,
|
|
515
|
+
input,
|
|
516
|
+
textarea {
|
|
517
|
+
font: inherit;
|
|
518
|
+
color: inherit;
|
|
519
|
+
}
|
|
298
520
|
`;
|
|
299
521
|
}
|
|
300
522
|
function renderNextEnvList(envVars) {
|
|
301
523
|
if (envVars.length === 0) {
|
|
302
|
-
return '<p className="text-
|
|
524
|
+
return '<p className="text-base">No environment variables required.</p>';
|
|
303
525
|
}
|
|
304
526
|
return envVars
|
|
305
527
|
.map((item) => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
309
|
-
<code className="rounded bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">${escapeTemplate(item.key)}</code>
|
|
310
|
-
<span className="text-sm">${escapeTemplate(item.description)}</span>
|
|
311
|
-
</div>
|
|
528
|
+
const link = item.url
|
|
529
|
+
? `
|
|
312
530
|
<a
|
|
313
|
-
className="
|
|
531
|
+
className="inline-flex text-base underline underline-offset-4"
|
|
314
532
|
href="${escapeAttribute(item.url)}"
|
|
315
533
|
target="_blank"
|
|
316
534
|
rel="noreferrer"
|
|
317
535
|
>
|
|
318
|
-
Get keys
|
|
319
|
-
</a
|
|
536
|
+
Get keys ->
|
|
537
|
+
</a>`
|
|
538
|
+
: '';
|
|
539
|
+
return `
|
|
540
|
+
<div className="flex flex-col gap-2">
|
|
541
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
542
|
+
<code className="bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">${escapeTemplate(item.key)}</code>
|
|
543
|
+
<span className="text-base">${escapeTemplate(item.description)}</span>
|
|
544
|
+
</div>
|
|
545
|
+
${link}
|
|
320
546
|
</div>`;
|
|
321
547
|
})
|
|
322
548
|
.join('\n');
|
|
@@ -325,12 +551,12 @@ function expoLayoutTemplate() {
|
|
|
325
551
|
return `import { Stack } from 'expo-router';
|
|
326
552
|
import { TamaguiProvider, Theme } from 'tamagui';
|
|
327
553
|
import { useColorScheme } from 'react-native';
|
|
328
|
-
import { useFonts,
|
|
554
|
+
import { useFonts, GeistMono_400Regular, GeistMono_700Bold } from '@expo-google-fonts/geist-mono';
|
|
329
555
|
import config from '../tamagui.config';
|
|
330
556
|
|
|
331
557
|
export default function RootLayout() {
|
|
332
558
|
const scheme = useColorScheme();
|
|
333
|
-
const [loaded] = useFonts({
|
|
559
|
+
const [loaded] = useFonts({ GeistMono_400Regular, GeistMono_700Bold });
|
|
334
560
|
|
|
335
561
|
if (!loaded) {
|
|
336
562
|
return null;
|
|
@@ -347,10 +573,12 @@ export default function RootLayout() {
|
|
|
347
573
|
`;
|
|
348
574
|
}
|
|
349
575
|
function expoHomeTemplate(appName, domain, envItems) {
|
|
350
|
-
return `import {
|
|
576
|
+
return `import { Head } from 'expo-router/head';
|
|
577
|
+
import { Text, YStack } from 'tamagui';
|
|
351
578
|
import { PageShell } from '../components/page-shell';
|
|
352
579
|
import { EnvList } from '../components/env-list';
|
|
353
|
-
import {
|
|
580
|
+
import { ContactForm } from '../components/contact-form';
|
|
581
|
+
import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from '../components/theme';
|
|
354
582
|
|
|
355
583
|
export default function Home() {
|
|
356
584
|
const { fg } = useThemeColors();
|
|
@@ -361,31 +589,47 @@ export default function Home() {
|
|
|
361
589
|
subtitle="${escapeTemplate(domain) ? `Domain: ${escapeTemplate(domain)}` : 'No domain configured yet.'}"
|
|
362
590
|
badge="Hello World"
|
|
363
591
|
>
|
|
364
|
-
<
|
|
365
|
-
<
|
|
592
|
+
<Head>
|
|
593
|
+
<title>${escapeTemplate(appName)} | Home</title>
|
|
594
|
+
<meta name="description" content="A minimal starter with routes, metadata, and env var guidance." />
|
|
595
|
+
<meta property="og:title" content="${escapeTemplate(appName)}" />
|
|
596
|
+
<meta property="og:description" content="A minimal starter with routes, metadata, and env var guidance." />
|
|
597
|
+
<meta property="twitter:card" content="summary_large_image" />
|
|
598
|
+
</Head>
|
|
599
|
+
<YStack gap="$3">
|
|
600
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
366
601
|
Environment variables
|
|
367
602
|
</Text>
|
|
368
603
|
<EnvList />
|
|
369
604
|
</YStack>
|
|
370
|
-
<YStack
|
|
371
|
-
<Text fontFamily={
|
|
605
|
+
<YStack gap="$2">
|
|
606
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
372
607
|
Routes
|
|
373
608
|
</Text>
|
|
374
|
-
<Text fontFamily={
|
|
609
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
375
610
|
Visit /about and /guide.
|
|
376
611
|
</Text>
|
|
377
612
|
</YStack>
|
|
613
|
+
<ContactForm />
|
|
378
614
|
</PageShell>
|
|
379
615
|
);
|
|
380
616
|
}
|
|
381
617
|
`;
|
|
382
618
|
}
|
|
383
619
|
function expoRouteTemplate(title, body) {
|
|
384
|
-
return `import {
|
|
620
|
+
return `import { Head } from 'expo-router/head';
|
|
621
|
+
import { PageShell } from '../components/page-shell';
|
|
385
622
|
|
|
386
623
|
export default function Page() {
|
|
387
624
|
return (
|
|
388
625
|
<PageShell title="${title}" subtitle="${body}">
|
|
626
|
+
<Head>
|
|
627
|
+
<title>${title}</title>
|
|
628
|
+
<meta name="description" content="${body}" />
|
|
629
|
+
<meta property="og:title" content="${title}" />
|
|
630
|
+
<meta property="og:description" content="${body}" />
|
|
631
|
+
<meta property="twitter:card" content="summary_large_image" />
|
|
632
|
+
</Head>
|
|
389
633
|
<></>
|
|
390
634
|
</PageShell>
|
|
391
635
|
);
|
|
@@ -406,7 +650,9 @@ export const COLORS = {
|
|
|
406
650
|
}
|
|
407
651
|
};
|
|
408
652
|
|
|
409
|
-
export const
|
|
653
|
+
export const FONT_REGULAR = 'GeistMono_400Regular';
|
|
654
|
+
export const FONT_BOLD = 'GeistMono_700Bold';
|
|
655
|
+
export const FONT_SIZE = 16;
|
|
410
656
|
|
|
411
657
|
export function useThemeColors() {
|
|
412
658
|
const scheme = useColorScheme();
|
|
@@ -423,7 +669,7 @@ function expoHeaderTemplate(appName) {
|
|
|
423
669
|
return `import { useState } from 'react';
|
|
424
670
|
import { Link } from 'expo-router';
|
|
425
671
|
import { Button, Text, XStack, YStack } from 'tamagui';
|
|
426
|
-
import {
|
|
672
|
+
import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
|
|
427
673
|
|
|
428
674
|
const links = [
|
|
429
675
|
{ href: '/', label: 'Home' },
|
|
@@ -436,21 +682,25 @@ export function SiteHeader() {
|
|
|
436
682
|
const { bg, fg } = useThemeColors();
|
|
437
683
|
|
|
438
684
|
return (
|
|
439
|
-
<YStack backgroundColor={bg} paddingHorizontal="$5" paddingVertical="$4"
|
|
685
|
+
<YStack backgroundColor={bg} paddingHorizontal="$5" paddingVertical="$4">
|
|
440
686
|
<XStack alignItems="center" justifyContent="space-between">
|
|
441
687
|
<XStack alignItems="center" gap="$3">
|
|
442
|
-
<Text fontFamily={
|
|
688
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} letterSpacing={4} color={fg}>
|
|
443
689
|
ZER0
|
|
444
690
|
</Text>
|
|
445
|
-
<Text fontFamily={
|
|
691
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} textTransform="uppercase" color={fg}>
|
|
446
692
|
${escapeTemplate(appName)}
|
|
447
693
|
</Text>
|
|
448
694
|
</XStack>
|
|
449
695
|
<Button
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
696
|
+
chromeless
|
|
697
|
+
backgroundColor="transparent"
|
|
698
|
+
borderWidth={0}
|
|
699
|
+
color={fg}
|
|
700
|
+
fontFamily={FONT_BOLD}
|
|
701
|
+
fontSize={FONT_SIZE}
|
|
702
|
+
paddingHorizontal={0}
|
|
703
|
+
paddingVertical={0}
|
|
454
704
|
onPress={() => setOpen((prev) => !prev)}
|
|
455
705
|
>
|
|
456
706
|
Menu
|
|
@@ -460,7 +710,7 @@ export function SiteHeader() {
|
|
|
460
710
|
<YStack marginTop="$3" gap="$2">
|
|
461
711
|
{links.map((link) => (
|
|
462
712
|
<Link key={link.href} href={link.href} asChild>
|
|
463
|
-
<Text fontFamily={
|
|
713
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} textDecorationLine="underline" color={fg}>
|
|
464
714
|
{link.label}
|
|
465
715
|
</Text>
|
|
466
716
|
</Link>
|
|
@@ -474,17 +724,17 @@ export function SiteHeader() {
|
|
|
474
724
|
}
|
|
475
725
|
function expoFooterTemplate(domain) {
|
|
476
726
|
return `import { Text, YStack } from 'tamagui';
|
|
477
|
-
import {
|
|
727
|
+
import { FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
|
|
478
728
|
|
|
479
729
|
export function SiteFooter() {
|
|
480
730
|
const { bg, fg } = useThemeColors();
|
|
481
731
|
|
|
482
732
|
return (
|
|
483
|
-
<YStack backgroundColor={bg} paddingHorizontal="$5" paddingVertical="$4"
|
|
484
|
-
<Text fontFamily={
|
|
733
|
+
<YStack backgroundColor={bg} paddingHorizontal="$5" paddingVertical="$4">
|
|
734
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
485
735
|
${escapeTemplate(domain) ? `Domain: ${escapeTemplate(domain)}` : 'Domain: not set'}
|
|
486
736
|
</Text>
|
|
487
|
-
<Text fontFamily={
|
|
737
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
488
738
|
Generated by Aexis Zero.
|
|
489
739
|
</Text>
|
|
490
740
|
</YStack>
|
|
@@ -495,7 +745,7 @@ export function SiteFooter() {
|
|
|
495
745
|
function expoEnvListTemplate(envItems) {
|
|
496
746
|
return `import { Linking } from 'react-native';
|
|
497
747
|
import { Text, YStack } from 'tamagui';
|
|
498
|
-
import {
|
|
748
|
+
import { FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
|
|
499
749
|
|
|
500
750
|
const envItems = ${envItems};
|
|
501
751
|
|
|
@@ -504,7 +754,7 @@ export function EnvList() {
|
|
|
504
754
|
|
|
505
755
|
if (envItems.length === 0) {
|
|
506
756
|
return (
|
|
507
|
-
<Text fontFamily={
|
|
757
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
508
758
|
No environment variables required.
|
|
509
759
|
</Text>
|
|
510
760
|
);
|
|
@@ -513,28 +763,29 @@ export function EnvList() {
|
|
|
513
763
|
return (
|
|
514
764
|
<YStack gap="$3">
|
|
515
765
|
{envItems.map((item) => (
|
|
516
|
-
<YStack key={item.key}
|
|
517
|
-
<Text fontFamily={
|
|
766
|
+
<YStack key={item.key} gap="$2">
|
|
767
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>{item.description}</Text>
|
|
518
768
|
<Text
|
|
519
|
-
fontFamily={
|
|
769
|
+
fontFamily={FONT_REGULAR}
|
|
770
|
+
fontSize={FONT_SIZE}
|
|
520
771
|
backgroundColor={fg}
|
|
521
772
|
color={bg}
|
|
522
773
|
paddingHorizontal="$2"
|
|
523
774
|
paddingVertical="$1"
|
|
524
|
-
borderRadius="$2"
|
|
525
|
-
marginTop="$2"
|
|
526
775
|
>
|
|
527
776
|
{item.key}
|
|
528
777
|
</Text>
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
778
|
+
{item.url ? (
|
|
779
|
+
<Text
|
|
780
|
+
fontFamily={FONT_REGULAR}
|
|
781
|
+
fontSize={FONT_SIZE}
|
|
782
|
+
textDecorationLine="underline"
|
|
783
|
+
color={fg}
|
|
784
|
+
onPress={() => Linking.openURL(item.url)}
|
|
785
|
+
>
|
|
786
|
+
Get keys ->
|
|
787
|
+
</Text>
|
|
788
|
+
) : null}
|
|
538
789
|
</YStack>
|
|
539
790
|
))}
|
|
540
791
|
</YStack>
|
|
@@ -542,12 +793,169 @@ export function EnvList() {
|
|
|
542
793
|
}
|
|
543
794
|
`;
|
|
544
795
|
}
|
|
796
|
+
function expoContactFormTemplate() {
|
|
797
|
+
return `import { useState } from 'react';
|
|
798
|
+
import { Linking, TextInput } from 'react-native';
|
|
799
|
+
import { Button, Text, YStack } from 'tamagui';
|
|
800
|
+
import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
|
|
801
|
+
|
|
802
|
+
const CONTACT_EMAIL = process.env.EXPO_PUBLIC_CONTACT_EMAIL?.trim() ?? '';
|
|
803
|
+
const CONTACT_ENDPOINT = process.env.EXPO_PUBLIC_CONTACT_ENDPOINT?.trim() ?? '';
|
|
804
|
+
|
|
805
|
+
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
|
806
|
+
|
|
807
|
+
export function ContactForm() {
|
|
808
|
+
const { fg } = useThemeColors();
|
|
809
|
+
const [name, setName] = useState('');
|
|
810
|
+
const [email, setEmail] = useState('');
|
|
811
|
+
const [message, setMessage] = useState('');
|
|
812
|
+
const [status, setStatus] = useState<Status>('idle');
|
|
813
|
+
const [error, setError] = useState('');
|
|
814
|
+
|
|
815
|
+
const inputStyle = {
|
|
816
|
+
borderBottomWidth: 1,
|
|
817
|
+
borderBottomColor: fg,
|
|
818
|
+
paddingVertical: 6,
|
|
819
|
+
fontFamily: FONT_REGULAR,
|
|
820
|
+
fontSize: FONT_SIZE,
|
|
821
|
+
color: fg
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const submit = async () => {
|
|
825
|
+
if (!name.trim() || !email.trim() || !message.trim()) {
|
|
826
|
+
setStatus('error');
|
|
827
|
+
setError('All fields are required.');
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
setStatus('sending');
|
|
832
|
+
setError('');
|
|
833
|
+
|
|
834
|
+
let sent = false;
|
|
835
|
+
if (CONTACT_ENDPOINT) {
|
|
836
|
+
try {
|
|
837
|
+
const response = await fetch(CONTACT_ENDPOINT, {
|
|
838
|
+
method: 'POST',
|
|
839
|
+
headers: { 'Content-Type': 'application/json' },
|
|
840
|
+
body: JSON.stringify({
|
|
841
|
+
name: name.trim(),
|
|
842
|
+
email: email.trim(),
|
|
843
|
+
message: message.trim()
|
|
844
|
+
})
|
|
845
|
+
});
|
|
846
|
+
sent = response.ok;
|
|
847
|
+
} catch {
|
|
848
|
+
sent = false;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!sent && CONTACT_EMAIL) {
|
|
853
|
+
const subject = encodeURIComponent(\`New message from \${name.trim()}\`);
|
|
854
|
+
const body = encodeURIComponent(\`Name: \${name.trim()}\\nEmail: \${email.trim()}\\n\\n\${message.trim()}\`);
|
|
855
|
+
const url = \`mailto:\${CONTACT_EMAIL}?subject=\${subject}&body=\${body}\`;
|
|
856
|
+
try {
|
|
857
|
+
await Linking.openURL(url);
|
|
858
|
+
sent = true;
|
|
859
|
+
} catch {
|
|
860
|
+
sent = false;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (!sent) {
|
|
865
|
+
setStatus('error');
|
|
866
|
+
if (CONTACT_ENDPOINT || CONTACT_EMAIL) {
|
|
867
|
+
setError('Unable to send message.');
|
|
868
|
+
} else {
|
|
869
|
+
setError('Set EXPO_PUBLIC_CONTACT_ENDPOINT or EXPO_PUBLIC_CONTACT_EMAIL.');
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
setStatus('sent');
|
|
875
|
+
setName('');
|
|
876
|
+
setEmail('');
|
|
877
|
+
setMessage('');
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
return (
|
|
881
|
+
<YStack gap=\"$3\">
|
|
882
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
883
|
+
Contact
|
|
884
|
+
</Text>
|
|
885
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
886
|
+
Send a quick note to your inbox.
|
|
887
|
+
</Text>
|
|
888
|
+
<YStack gap=\"$3\">
|
|
889
|
+
<YStack gap=\"$1\">
|
|
890
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
891
|
+
Name
|
|
892
|
+
</Text>
|
|
893
|
+
<TextInput
|
|
894
|
+
style={inputStyle}
|
|
895
|
+
value={name}
|
|
896
|
+
onChangeText={setName}
|
|
897
|
+
autoCapitalize="words"
|
|
898
|
+
autoComplete="name"
|
|
899
|
+
/>
|
|
900
|
+
</YStack>
|
|
901
|
+
<YStack gap=\"$1\">
|
|
902
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
903
|
+
Email
|
|
904
|
+
</Text>
|
|
905
|
+
<TextInput
|
|
906
|
+
style={inputStyle}
|
|
907
|
+
value={email}
|
|
908
|
+
onChangeText={setEmail}
|
|
909
|
+
autoCapitalize="none"
|
|
910
|
+
autoComplete="email"
|
|
911
|
+
keyboardType="email-address"
|
|
912
|
+
/>
|
|
913
|
+
</YStack>
|
|
914
|
+
<YStack gap=\"$1\">
|
|
915
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
916
|
+
Message
|
|
917
|
+
</Text>
|
|
918
|
+
<TextInput
|
|
919
|
+
style={{ ...inputStyle, minHeight: 96, textAlignVertical: 'top' }}
|
|
920
|
+
value={message}
|
|
921
|
+
onChangeText={setMessage}
|
|
922
|
+
multiline
|
|
923
|
+
/>
|
|
924
|
+
</YStack>
|
|
925
|
+
</YStack>
|
|
926
|
+
<Button
|
|
927
|
+
backgroundColor=\"transparent\"
|
|
928
|
+
borderWidth={0}
|
|
929
|
+
paddingHorizontal={0}
|
|
930
|
+
paddingVertical={0}
|
|
931
|
+
alignSelf=\"flex-start\"
|
|
932
|
+
onPress={submit}
|
|
933
|
+
>
|
|
934
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg} textDecorationLine=\"underline\">
|
|
935
|
+
{status === 'sending' ? 'Sending...' : 'Send message'}
|
|
936
|
+
</Text>
|
|
937
|
+
</Button>
|
|
938
|
+
{status === 'sent' ? (
|
|
939
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
940
|
+
Message sent.
|
|
941
|
+
</Text>
|
|
942
|
+
) : null}
|
|
943
|
+
{status === 'error' && error ? (
|
|
944
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
945
|
+
{error}
|
|
946
|
+
</Text>
|
|
947
|
+
) : null}
|
|
948
|
+
</YStack>
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
`;
|
|
952
|
+
}
|
|
545
953
|
function expoPageShellTemplate() {
|
|
546
954
|
return `import type { ReactNode } from 'react';
|
|
547
955
|
import { ScrollView, Text, YStack } from 'tamagui';
|
|
548
956
|
import { SiteHeader } from './site-header';
|
|
549
957
|
import { SiteFooter } from './site-footer';
|
|
550
|
-
import {
|
|
958
|
+
import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
|
|
551
959
|
|
|
552
960
|
interface PageShellProps {
|
|
553
961
|
title: string;
|
|
@@ -562,17 +970,17 @@ export function PageShell({ title, subtitle, badge, children }: PageShellProps)
|
|
|
562
970
|
return (
|
|
563
971
|
<YStack flex={1} backgroundColor={bg}>
|
|
564
972
|
<SiteHeader />
|
|
565
|
-
<ScrollView contentContainerStyle={{ padding: 24 }}>
|
|
973
|
+
<ScrollView contentContainerStyle={{ padding: 24, flexGrow: 1, justifyContent: 'center' }}>
|
|
566
974
|
<YStack gap="$4">
|
|
567
975
|
{badge ? (
|
|
568
|
-
<Text fontFamily={
|
|
976
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} textTransform="uppercase" letterSpacing={2} color={fg}>
|
|
569
977
|
{badge}
|
|
570
978
|
</Text>
|
|
571
979
|
) : null}
|
|
572
|
-
<Text fontFamily={
|
|
980
|
+
<Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
|
|
573
981
|
{title}
|
|
574
982
|
</Text>
|
|
575
|
-
<Text fontFamily={
|
|
983
|
+
<Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
|
|
576
984
|
{subtitle}
|
|
577
985
|
</Text>
|
|
578
986
|
{children}
|
|
@@ -627,10 +1035,11 @@ function renderExpoEnvItems(envVars) {
|
|
|
627
1035
|
return '[]';
|
|
628
1036
|
}
|
|
629
1037
|
const items = envVars.map((item) => {
|
|
1038
|
+
const url = item.url ? `'${escapeTemplate(item.url)}'` : 'undefined';
|
|
630
1039
|
return `{
|
|
631
1040
|
key: '${escapeTemplate(item.key)}',
|
|
632
1041
|
description: '${escapeTemplate(item.description)}',
|
|
633
|
-
url:
|
|
1042
|
+
url: ${url}
|
|
634
1043
|
}`;
|
|
635
1044
|
});
|
|
636
1045
|
return `[
|