@aex.is/zero 0.1.6 → 0.1.8

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.
@@ -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: '${escapeTemplate(appName)}',
123
- description: 'Scaffolded by Aexis Zero.'
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,7 +162,7 @@ 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>
165
+ <main className="flex flex-1 items-center justify-center">{children}</main>
137
166
  <SiteFooter />
138
167
  </div>
139
168
  </body>
@@ -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-3">
152
- <p className="text-xs uppercase tracking-[0.4em]">Hello World</p>
153
- <h1 className="text-3xl font-thin">${escapeTemplate(appName)}</h1>
154
- <p className="text-sm">
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="rounded-xl border border-[var(--fg)] p-6">
159
- <h2 className="text-lg font-medium">Environment variables</h2>
160
- <p className="text-sm">Set these in your <code className="rounded bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">.env</code>.</p>
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="rounded-xl border border-[var(--fg)] p-6">
164
- <h2 className="text-lg font-medium">Routes</h2>
165
- <p className="text-sm">Explore <code className="rounded bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/about</code> and <code className="rounded bg-[var(--fg)] px-2 py-1 text-[var(--bg)]">/guide</code>.</p>
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 default function Page() {
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-3xl font-thin">${title}</h1>
177
- <p className="text-sm">${body}</p>
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 className="border-b border-[var(--fg)]">
195
- <div className="mx-auto flex w-full max-w-4xl items-center justify-between px-6 py-4">
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-xl font-thin tracking-[0.3em]">ZER0</span>
198
- <span className="text-xs uppercase tracking-[0.2em]">{appName}</span>
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-sm sm:flex">
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-sm">Menu</summary>
209
- <div className="mt-3 flex flex-col gap-3 text-sm">
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}
@@ -226,8 +272,8 @@ function nextFooterTemplate(domain) {
226
272
  : 'Domain: not set';
227
273
  return `export function SiteFooter() {
228
274
  return (
229
- <footer className=\"border-t border-[var(--fg)]\">
230
- <div className=\"mx-auto flex w-full max-w-4xl flex-col gap-2 px-6 py-4 text-xs\">
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"
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@100;200;300;400;500;600;700;800;900&display=swap');
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-sm">No environment variables required.</p>';
524
+ return '<p className="text-base">No environment variables required.</p>';
303
525
  }
304
526
  return envVars
305
527
  .map((item) => {
306
- return `
307
- <div className="rounded-lg border border-[var(--fg)] p-4">
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="mt-2 inline-flex text-sm underline underline-offset-4"
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, GeistMono_100Thin } from '@expo-google-fonts/geist-mono';
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({ GeistMono_100Thin });
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 { Text, YStack } from 'tamagui';
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 { FONT_FAMILY, useThemeColors } from '../components/theme';
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
- <YStack borderWidth={1} borderColor={fg} padding="$4" borderRadius="$4" gap="$3">
365
- <Text fontFamily={FONT_FAMILY} fontSize="$4" color={fg}>
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 borderWidth={1} borderColor={fg} padding="$4" borderRadius="$4" gap="$3">
371
- <Text fontFamily={FONT_FAMILY} fontSize="$4" color={fg}>
605
+ <YStack gap="$2">
606
+ <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
372
607
  Routes
373
608
  </Text>
374
- <Text fontFamily={FONT_FAMILY} fontSize="$2" color={fg}>
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 { PageShell } from '../components/page-shell';
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 FONT_FAMILY = 'GeistMono_100Thin';
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 { FONT_FAMILY, useThemeColors } from './theme';
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" borderBottomWidth={1} borderColor={fg}>
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={FONT_FAMILY} fontWeight="100" fontSize="$6" color={fg}>
688
+ <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} letterSpacing={4} color={fg}>
443
689
  ZER0
444
690
  </Text>
445
- <Text fontFamily={FONT_FAMILY} fontSize="$2" textTransform="uppercase" color={fg}>
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
- backgroundColor={fg}
451
- color={bg}
452
- fontFamily={FONT_FAMILY}
453
- size="$2"
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={FONT_FAMILY} textDecorationLine="underline" color={fg}>
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 { FONT_FAMILY, useThemeColors } from './theme';
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" borderTopWidth={1} borderColor={fg}>
484
- <Text fontFamily={FONT_FAMILY} fontSize="$2" color={fg}>
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={FONT_FAMILY} fontSize="$2" color={fg}>
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 { FONT_FAMILY, useThemeColors } from './theme';
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={FONT_FAMILY} fontSize="$2" color={fg}>
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} borderWidth={1} borderColor={fg} padding="$3" borderRadius="$4">
517
- <Text fontFamily={FONT_FAMILY} color={fg}>{item.description}</Text>
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={FONT_FAMILY}
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
- <Text
530
- fontFamily={FONT_FAMILY}
531
- textDecorationLine="underline"
532
- color={fg}
533
- marginTop="$2"
534
- onPress={() => Linking.openURL(item.url)}
535
- >
536
- Get keys →
537
- </Text>
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,170 @@ 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
+ disabled={status === 'sending'}
933
+ onPress={submit}
934
+ >
935
+ <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg} textDecorationLine=\"underline\">
936
+ {status === 'sending' ? 'Sending...' : 'Send message'}
937
+ </Text>
938
+ </Button>
939
+ {status === 'sent' ? (
940
+ <Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
941
+ Message sent.
942
+ </Text>
943
+ ) : null}
944
+ {status === 'error' && error ? (
945
+ <Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
946
+ {error}
947
+ </Text>
948
+ ) : null}
949
+ </YStack>
950
+ );
951
+ }
952
+ `;
953
+ }
545
954
  function expoPageShellTemplate() {
546
955
  return `import type { ReactNode } from 'react';
547
956
  import { ScrollView, Text, YStack } from 'tamagui';
548
957
  import { SiteHeader } from './site-header';
549
958
  import { SiteFooter } from './site-footer';
550
- import { FONT_FAMILY, useThemeColors } from './theme';
959
+ import { FONT_BOLD, FONT_REGULAR, FONT_SIZE, useThemeColors } from './theme';
551
960
 
552
961
  interface PageShellProps {
553
962
  title: string;
@@ -562,17 +971,17 @@ export function PageShell({ title, subtitle, badge, children }: PageShellProps)
562
971
  return (
563
972
  <YStack flex={1} backgroundColor={bg}>
564
973
  <SiteHeader />
565
- <ScrollView contentContainerStyle={{ padding: 24 }}>
974
+ <ScrollView contentContainerStyle={{ padding: 24, flexGrow: 1, justifyContent: 'center' }}>
566
975
  <YStack gap="$4">
567
976
  {badge ? (
568
- <Text fontFamily={FONT_FAMILY} fontSize="$2" textTransform="uppercase" letterSpacing={2} color={fg}>
977
+ <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} textTransform="uppercase" letterSpacing={2} color={fg}>
569
978
  {badge}
570
979
  </Text>
571
980
  ) : null}
572
- <Text fontFamily={FONT_FAMILY} fontSize="$7" color={fg}>
981
+ <Text fontFamily={FONT_BOLD} fontSize={FONT_SIZE} color={fg}>
573
982
  {title}
574
983
  </Text>
575
- <Text fontFamily={FONT_FAMILY} fontSize="$3" color={fg}>
984
+ <Text fontFamily={FONT_REGULAR} fontSize={FONT_SIZE} color={fg}>
576
985
  {subtitle}
577
986
  </Text>
578
987
  {children}
@@ -627,10 +1036,11 @@ function renderExpoEnvItems(envVars) {
627
1036
  return '[]';
628
1037
  }
629
1038
  const items = envVars.map((item) => {
1039
+ const url = item.url ? `'${escapeTemplate(item.url)}'` : 'undefined';
630
1040
  return `{
631
1041
  key: '${escapeTemplate(item.key)}',
632
1042
  description: '${escapeTemplate(item.description)}',
633
- url: '${escapeTemplate(item.url)}'
1043
+ url: ${url}
634
1044
  }`;
635
1045
  });
636
1046
  return `[