Compare commits
61 Commits
bbe5b6c67e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f78f53d7 | |||
| 1705b618c3 | |||
| 0189c56bec | |||
| 901f76349a | |||
| ce6f029093 | |||
| 9ffd0885a2 | |||
| a8af84e864 | |||
| d68dd6ffc3 | |||
| db5bbc9e25 | |||
| 3f203e4c46 | |||
| 890f2184e1 | |||
| 23a84cd31b | |||
| f2efa442bf | |||
| cb6ee4783c | |||
| 8a7267e769 | |||
| 09b91b1292 | |||
| 1b09059467 | |||
| 5710d27663 | |||
| 2e23e26fc1 | |||
| 518f86265e | |||
| 57faddc051 | |||
| 24d8ab73f5 | |||
| 2d5e588b2e | |||
| 029d3d7970 | |||
| 2943d90ab1 | |||
| 40e9a257f7 | |||
| 1f6bb7d066 | |||
| bcc09542b7 | |||
| a174518496 | |||
| 896f0eb5f4 | |||
| 3cdb95e488 | |||
| cdb89553e0 | |||
| 28d063e251 | |||
| 94a5876e7d | |||
| bcc24d0f40 | |||
| a5570a90b2 | |||
| 81675335ad | |||
| 3bf07674ad | |||
| 600f1a5241 | |||
| 9b8f0a7f7f | |||
| 3eae92e1c1 | |||
| 7fb3d85103 | |||
| 1e41c1c07c | |||
| 66949c07d8 | |||
| c48313ad91 | |||
| 015ae49d2d | |||
| 6fcebe74af | |||
| 34a78e7d4a | |||
| edb765e0e1 | |||
| 0a20d1e243 | |||
| bbf534cf4f | |||
| 0e3c92f873 | |||
| 15136080ed | |||
| b4a265077e | |||
| 067164645b | |||
| d1d5b7e124 | |||
| bab45b981e | |||
| 16cd2a74ee | |||
| d971c5905f | |||
| ff3c1594fa | |||
| bdb664633d |
@@ -16,8 +16,14 @@ SMTP_PASS=your-smtp-password
|
||||
SMTP_FROM=hello@letsbe.biz
|
||||
ADMIN_EMAIL=hello@letsbe.biz
|
||||
|
||||
# ── Gemini Live API (voice agent) ──
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
|
||||
# ── Cal.com ──
|
||||
NEXT_PUBLIC_CALCOM_URL=https://cal.letsbe.biz
|
||||
|
||||
# ── Site URL ──
|
||||
NEXT_PUBLIC_SITE_URL=https://staging.letsbe.biz
|
||||
|
||||
# ── Google Analytics ──
|
||||
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
|
||||
@@ -38,5 +38,6 @@ jobs:
|
||||
PAYLOAD_SECRET=${{ secrets.PAYLOAD_SECRET }}
|
||||
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||
NEXT_PUBLIC_CALCOM_URL=${{ secrets.NEXT_PUBLIC_CALCOM_URL }}
|
||||
NEXT_PUBLIC_GA_ID=${{ vars.NEXT_PUBLIC_GA_ID }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:buildcache,mode=max
|
||||
|
||||
3
.gitignore
vendored
@@ -31,3 +31,6 @@ public/media/
|
||||
|
||||
# superpowers
|
||||
.superpowers/
|
||||
|
||||
# private credentials
|
||||
private/
|
||||
|
||||
@@ -18,11 +18,13 @@ ARG DATABASE_URI
|
||||
ARG PAYLOAD_SECRET
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_CALCOM_URL
|
||||
ARG NEXT_PUBLIC_GA_ID
|
||||
|
||||
ENV DATABASE_URI=${DATABASE_URI}
|
||||
ENV PAYLOAD_SECRET=${PAYLOAD_SECRET}
|
||||
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
||||
ENV NEXT_PUBLIC_CALCOM_URL=${NEXT_PUBLIC_CALCOM_URL}
|
||||
ENV NEXT_PUBLIC_GA_ID=${NEXT_PUBLIC_GA_ID}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -42,6 +44,9 @@ COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Ensure .next/cache is writable by the nextjs user
|
||||
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
SMTP_FROM: ${SMTP_FROM:-hello@letsbe.biz}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-hello@letsbe.biz}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
1271
docs/superpowers/plans/2026-04-06-voice-discovery-pivot.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Voice Discovery Mode — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Pivot the voice mode from a "faster way to fill out the configurator" into a standalone consultative discovery experience. Exploratory users — people who don't yet know exactly what they need — get a warm, conversational entry point separate from the typed configurator. The conversation is free-flowing and consultant-like, structured data is captured silently, and the user receives a personalized brief at the end.
|
||||
|
||||
## Entry Point & Framing
|
||||
|
||||
### Placement
|
||||
|
||||
A new standalone section on the landing page, positioned after the services or process section — wherever the natural "I'm interested but not sure" moment occurs. Completely decoupled from the configurator.
|
||||
|
||||
### Copy Direction
|
||||
|
||||
- **Headline:** Warm and inviting — "Not sure where to start?" or "Still figuring out what you need?"
|
||||
- **Subtext:** "Tell us what you're thinking and we'll figure it out together. You'll get a personalized brief at the end."
|
||||
- **CTA button:** "Let's talk" — styled distinctly from the configurator's CTA.
|
||||
- Both EN and FR translations required.
|
||||
|
||||
### Behavior
|
||||
|
||||
Clicking the CTA scrolls to / reveals the voice conversation panel inline on the page. No route change, no modal. The panel expands in place, keeping the user grounded in the site context.
|
||||
|
||||
### Configurator Changes
|
||||
|
||||
- Remove the `ModeToggle` component and `mode` state from `WizardContainer.tsx`.
|
||||
- The configurator becomes typed-form-only — the "I know what I want" path.
|
||||
- No other changes to the configurator itself.
|
||||
|
||||
## Voice Conversation UI
|
||||
|
||||
### Layout
|
||||
|
||||
A dedicated panel, roughly the same width as the configurator card but taller. Three zones stacked vertically:
|
||||
|
||||
1. **Agent header** — LetsBe branding mark, agent name, connection status dot. Similar to current but slightly more prominent.
|
||||
|
||||
2. **Orb + transcript area** — Orb is larger (24-28 units instead of 20). Live transcript below it with significantly more vertical space (`max-h-72` or similar instead of current `max-h-40`). Proper autoscroll using `scrollIntoView` on the bottom ref. **Selection chips are removed** — no visible evidence of structured data capture.
|
||||
|
||||
3. **Controls** — Mic toggle and end call button. Same as current, cleaner without chips.
|
||||
|
||||
### Mobile
|
||||
|
||||
Panel goes nearly full-width on small screens. Transcript takes most of the viewport height. Orb may scale down slightly. Controls stay fixed at the bottom for thumb reach.
|
||||
|
||||
### Contact Confirmation Card
|
||||
|
||||
When the agent captures name and email, a small inline card appears (above controls or below transcript) showing the captured values with inline edit affordance. The agent says "I've got your details on screen — look right?" User can tap to edit, then confirm. **This replaces the verbal spell-back entirely.**
|
||||
|
||||
Requires a new tool (e.g., `request_contact`) that the agent calls to surface the card, rather than collecting contact info verbally.
|
||||
|
||||
### During Brief Generation
|
||||
|
||||
After contact confirmation and `complete_brief` trigger:
|
||||
- Connection is closed (already fixed).
|
||||
- Panel transitions to a generating state — orb morphs to loader or StepGenerating-style progress indicators.
|
||||
- Transcript remains visible so the conversation doesn't vanish.
|
||||
|
||||
### On Completion
|
||||
|
||||
Transitions to the same `StepComplete` view (brief preview + book a call CTA). The brief content will be richer due to deeper conversation, but presentation is the same.
|
||||
|
||||
## System Prompt & Agent Behavior
|
||||
|
||||
### Tone
|
||||
|
||||
The agent is a conversational consultant, not an interviewer with a checklist. No numbered topic list to work through. The prompt gives the agent a goal: "understand what this person needs deeply enough to write a compelling brief."
|
||||
|
||||
### Behavioral Guidelines
|
||||
|
||||
- **Follow the user's thread.** If they talk about a frustration, dig into it. Don't redirect to the next "topic."
|
||||
- **One question at a time.** This stays — it works.
|
||||
- **Offer perspective, not just questions.** "That sounds like it might be more of a systems problem than a website problem." The agent has opinions, not just a clipboard.
|
||||
- **Reference LetsBe naturally.** "We've done something similar for a hospitality client" — not a feature list.
|
||||
- **2-3 sentences per response.** Prevents monologuing.
|
||||
|
||||
### Structured Data Capture
|
||||
|
||||
`update_selections` tool stays. The agent is never instructed to "cover these topics." It maps what it hears to predefined values silently. If the conversation never touches timeline, that field stays empty — that's fine.
|
||||
|
||||
### Brief Generation
|
||||
|
||||
`conversationSummary` is the **primary payload**. The prompt instructs the agent to include everything discussed: pain points, current tools, what they want to keep vs change, business context, decision-makers, what success looks like. Structured fields (`services`, `industry`, `timeline`) are metadata that helps organize the brief, not the substance.
|
||||
|
||||
### Brief Content Philosophy
|
||||
|
||||
The brief should be **diagnostic, not prescriptive:**
|
||||
- **Deep on their world** — pain points, current tools, what's broken, customers, what success looks like.
|
||||
- **Deep on what matters** — priorities and trade-offs surfaced in conversation.
|
||||
- **LetsBe's perspective** — a few sentences of informed opinion on what the real problem is.
|
||||
- **High-level on implementation** — no stack recommendations, no architecture, no specific deliverables.
|
||||
- **No timeline/cost** — "that's what the call is for."
|
||||
|
||||
The brief should make the user feel understood and make the follow-up call feel like a warm continuation, not a cold intro.
|
||||
|
||||
### Contact Collection
|
||||
|
||||
The agent asks for name and email when the conversation reaches a natural conclusion — "I think I've got a great picture of what you need. Let me put a brief together — what's your name and email?" No forced timing. The `request_contact` tool surfaces the on-screen card for verification.
|
||||
|
||||
### Language
|
||||
|
||||
Both EN and FR system prompts, same as now.
|
||||
|
||||
## Reconnection Handling
|
||||
|
||||
Exploratory conversations run longer than form-filling. If the WebSocket drops mid-conversation:
|
||||
|
||||
- Preserve the transcript on disconnect.
|
||||
- Show a "reconnect" option instead of just an error.
|
||||
- On reconnect, seed the new Gemini session with the transcript so far (as context in the system prompt or initial message) so the agent can pick up where it left off.
|
||||
- The structured selections captured so far are preserved in state.
|
||||
|
||||
## Technical Changes
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- **`VoiceAgentProvider.tsx`** — Refactor `handleToolCall` so `conversationSummary` is the primary brief input. Add state for contact confirmation card (name + email captured, pending user confirm). Add reconnection logic (preserve transcript, re-seed on reconnect). Connection teardown on brief completion already fixed.
|
||||
|
||||
- **`VoiceAgent.tsx`** — New layout: larger orb, bigger transcript area, no selection chips. Add contact confirmation card component (inline editable name + email). Fix autoscroll with `scrollIntoView`. Guard controls for brief-complete state (already done). Mobile-responsive layout.
|
||||
|
||||
- **`gemini-live.ts`** — Rewrite `buildSystemPrompt()` for both locales with consultative tone. Adjust `complete_brief` tool description to emphasize `conversationSummary`. Add `request_contact` tool declaration that surfaces the on-screen card.
|
||||
|
||||
- **`WizardContainer.tsx`** — Remove `ModeToggle` component import, `mode` state, and the voice mode rendering branch. Remove `handleVoiceComplete` and `VoiceAgentProvider` wrapper (these move to the new section).
|
||||
|
||||
- **`ModeToggle.tsx`** — Delete entirely.
|
||||
|
||||
- **New: Discovery section component** — New section component for the landing page with warm copy, CTA, and expandable voice panel. This is where `VoiceAgentProvider` and `VoiceAgent` now live.
|
||||
|
||||
- **Landing page** — Add the new discovery section at the appropriate position.
|
||||
|
||||
- **i18n message files** (`en.json`, `fr.json`) — Add translations for discovery section copy. Update voice-related strings as needed.
|
||||
|
||||
- **Email template** — Verify the brief email template handles longer, more narrative content gracefully. Adjust if needed.
|
||||
|
||||
### What Stays the Same
|
||||
|
||||
- WebSocket connection to Gemini Live API
|
||||
- Audio worklet recording + playback pipeline
|
||||
- `update_selections` tool (used silently now)
|
||||
- `/api/configure` route and brief generation logic
|
||||
- `/api/gemini-token` route
|
||||
- `StepComplete` component
|
||||
- `analyze_website` tool (still useful when someone mentions their current site)
|
||||
- The typed configurator (minus the mode toggle)
|
||||
151
docs/superpowers/specs/2026-04-07-seo-optimization-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# SEO Optimization — Design Spec
|
||||
|
||||
**Date:** 2026-04-07
|
||||
**Domain:** letsbe.biz
|
||||
**Stack:** Next.js 16 App Router, next-intl (en/fr, `as-needed` prefix), Payload CMS
|
||||
|
||||
## Current State
|
||||
|
||||
The site has a single `metadata` export in the layout with a generic title/description. No robots.txt, no sitemap, no OG tags, no hreflang, no structured data, no analytics. The services page has a static English-only metadata export. Case study pages and the homepage have no metadata at all.
|
||||
|
||||
## 1. Metadata Foundation
|
||||
|
||||
### metadataBase
|
||||
Add `metadataBase: new URL('https://letsbe.biz')` to `src/app/(frontend)/[locale]/layout.tsx`. Required for OG images and canonical URLs to resolve as absolute URLs.
|
||||
|
||||
### i18n SEO keys
|
||||
Add a `meta` section to both `en.json` and `fr.json`:
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"home": { "title": "...", "description": "..." },
|
||||
"about": { "title": "...", "description": "..." },
|
||||
"services": { "title": "...", "description": "..." },
|
||||
"work": {
|
||||
"monaco-ocean": { "title": "...", "description": "..." },
|
||||
"port-nimara": { "title": "...", "description": "..." },
|
||||
"port-amador": { "title": "...", "description": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-page generateMetadata
|
||||
Convert every page to use `generateMetadata({ params })`:
|
||||
- Read locale from params
|
||||
- Call `getTranslations({ locale, namespace: 'meta' })` for localized title/description
|
||||
- Set `alternates.canonical` (e.g., `https://letsbe.biz/about` for en, `https://letsbe.biz/fr/about` for fr)
|
||||
- Set `alternates.languages` for hreflang:
|
||||
- `en` → unprefixed path
|
||||
- `fr` → `/fr/` prefixed path
|
||||
- `x-default` → unprefixed (English)
|
||||
|
||||
### Layout metadata
|
||||
Convert the layout's static `metadata` to `generateMetadata()` so it can set locale-aware defaults, `metadataBase`, and default OG/Twitter config.
|
||||
|
||||
## 2. Social Sharing (Open Graph / Twitter)
|
||||
|
||||
### Default OG in layout
|
||||
```ts
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'LetsBe.',
|
||||
locale: locale === 'fr' ? 'fr_FR' : 'en_US',
|
||||
images: [{ url: '/images/og-default.png', width: 1200, height: 630 }],
|
||||
}
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
}
|
||||
```
|
||||
|
||||
### OG image
|
||||
Create a 1200x630 OG default image using sharp — the logo centered on a branded background (the site's surface color with the blue accent). Place at `public/images/og-default.png`.
|
||||
|
||||
### Per-page OG
|
||||
Case study pages override with their hero image. Other pages use the default.
|
||||
|
||||
## 3. Crawl Infrastructure
|
||||
|
||||
### robots.ts
|
||||
`src/app/robots.ts`:
|
||||
- Allow all user agents
|
||||
- Disallow `/admin/`, `/api/`
|
||||
- Sitemap: `https://letsbe.biz/sitemap.xml`
|
||||
|
||||
### sitemap.ts
|
||||
`src/app/sitemap.ts`:
|
||||
- Static routes: `/`, `/about`, `/services`
|
||||
- Dynamic routes: `/work/monaco-ocean`, `/work/port-nimara`, `/work/port-amador`
|
||||
- Each entry includes both locale variants in `alternates.languages`
|
||||
- `lastModified` set to build date
|
||||
- `changeFrequency` and `priority` set appropriately
|
||||
|
||||
## 4. Structured Data (JSON-LD)
|
||||
|
||||
Inject via `<script type="application/ld+json">` in layout and page components.
|
||||
|
||||
### Organization (layout)
|
||||
```json
|
||||
{
|
||||
"@type": "Organization",
|
||||
"name": "LetsBe.",
|
||||
"url": "https://letsbe.biz",
|
||||
"logo": "https://letsbe.biz/images/letsbe-logo-short.png",
|
||||
"sameAs": ["linkedin-url", "x-url"]
|
||||
}
|
||||
```
|
||||
|
||||
### WebSite (homepage)
|
||||
```json
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"name": "LetsBe.",
|
||||
"url": "https://letsbe.biz"
|
||||
}
|
||||
```
|
||||
|
||||
### Service (services page)
|
||||
One `Service` entry per pillar (Design, Software, Infrastructure).
|
||||
|
||||
### CreativeWork (case study pages)
|
||||
Per-project with title, description, image, creator → Organization.
|
||||
|
||||
## 5. Google Analytics + Consent Mode
|
||||
|
||||
### GA4 integration
|
||||
- Measurement ID: `G-LD348WF8EM`
|
||||
- Use Next.js `@next/third-parties/google` GoogleAnalytics component (or `next/script` if the package isn't available)
|
||||
- Place in the layout, render only in production (`process.env.NODE_ENV === 'production'`)
|
||||
|
||||
### Consent Mode v2
|
||||
Set default consent state before GA loads:
|
||||
```js
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
wait_for_update: 500,
|
||||
});
|
||||
```
|
||||
This lets GA4 run in cookieless mode (modeled conversions, basic pageviews) without requiring a cookie banner. A full consent banner can be added later to unlock full measurement.
|
||||
|
||||
### Environment variable
|
||||
`NEXT_PUBLIC_GA_ID=G-LD348WF8EM` in `.env` / `.env.production`.
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/app/(frontend)/[locale]/layout.tsx` | Convert to generateMetadata, add OG, add JSON-LD Organization |
|
||||
| `src/app/(frontend)/[locale]/page.tsx` | Add generateMetadata, add JSON-LD WebSite |
|
||||
| `src/app/(frontend)/[locale]/about/page.tsx` | Add generateMetadata |
|
||||
| `src/app/(frontend)/[locale]/services/page.tsx` | Replace static metadata with generateMetadata, add JSON-LD Service |
|
||||
| `src/app/(frontend)/[locale]/work/[slug]/page.tsx` | Add generateMetadata, add JSON-LD CreativeWork |
|
||||
| `src/i18n/messages/en.json` | Add `meta` section |
|
||||
| `src/i18n/messages/fr.json` | Add `meta` section |
|
||||
| `src/app/robots.ts` | Create |
|
||||
| `src/app/sitemap.ts` | Create |
|
||||
| `public/images/og-default.png` | Create (generated via sharp) |
|
||||
| `src/components/analytics/GoogleAnalytics.tsx` | Create (GA4 + consent mode) |
|
||||
| `.env.production` | Add `NEXT_PUBLIC_GA_ID` |
|
||||
220
docs/superpowers/specs/2026-04-07-site-copy-redesign-design.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Site Copy Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-04-07
|
||||
**Goal:** Rewrite all user-facing copy to lead with web design/development, position infrastructure as a supporting differentiator, and remove all Côte d'Azur / French Riviera geographic references.
|
||||
|
||||
## Messaging Strategy
|
||||
|
||||
**Approach:** "The Complete Partner" — design-forward but immediately signals full-service depth. The client comes for the website, then discovers they can also get custom software, hosting, and AI from the same team.
|
||||
|
||||
**Target client:** SMB founders whose current website doesn't represent their business well.
|
||||
|
||||
**Tone:** Conversational yet polished — confident and warm, not stiff or fluffy. Light technical credibility signals, not jargon-heavy.
|
||||
|
||||
**Hierarchy:**
|
||||
1. Web design & development (leads)
|
||||
2. Custom software (extends)
|
||||
3. Hosting & infrastructure (supports, positioned as differentiator not lead)
|
||||
4. AI (prominent, practical, honest about capabilities)
|
||||
|
||||
**Geographic references:** Remove ALL Côte d'Azur / French Riviera references. LetsBe is an American company.
|
||||
|
||||
---
|
||||
|
||||
## Copy Changes By Section
|
||||
|
||||
### 1. SEO Metadata
|
||||
|
||||
**Site title:** "LetsBe. | Web Design, AI & Digital Infrastructure Studio"
|
||||
**Site description:** "Custom web design, purpose-built software, AI integration, and private infrastructure — designed, built, and managed by one dedicated team."
|
||||
**Services page description:** "Custom web design, purpose-built software, AI automation, and private infrastructure — three pillars of digital excellence under one roof."
|
||||
|
||||
### 2. Homepage — Hero
|
||||
|
||||
**Headline:** "Your website. Your software. Your *entire* digital world."
|
||||
*(accent word: "entire" in italic/primary blue)*
|
||||
|
||||
**Subtitle:** "Custom websites, purpose-built software, and the infrastructure to run it all — designed, built, and managed by one dedicated team."
|
||||
|
||||
**Primary CTA:** "Start Your Project"
|
||||
**Secondary CTA:** "See Our Work"
|
||||
**Trust line:** "Trusted by businesses worldwide"
|
||||
|
||||
### 3. Homepage — Trust Bar
|
||||
|
||||
| Card | Title | Description |
|
||||
|------|-------|-------------|
|
||||
| 1 | Designed From Scratch | No templates, no page builders. Every site is custom-designed and hand-built for your brand. |
|
||||
| 2 | One Team, Start to Finish | No freelancer juggling. One team handles design, code, hosting, and support. |
|
||||
| 3 | AI Built In | Intelligent features and automation woven directly into your website and software. |
|
||||
| 4 | You Own Everything | Your code, your data, your servers. We build it, you own it — no lock-in, no surprises. |
|
||||
|
||||
### 4. Homepage — Services Overview
|
||||
|
||||
**Eyebrow:** "What We Do"
|
||||
**Title:** "Design. Build. Grow."
|
||||
|
||||
| Pillar | Title | Features |
|
||||
|--------|-------|----------|
|
||||
| 01 | Web Design & Development | Custom Website Design, Responsive Development, SEO & Performance, Content Management |
|
||||
| 02 | Software & Platforms | Business Management Tools, CRMs & Dashboards, Booking & Scheduling Systems, API Integrations |
|
||||
| 03 | Hosting & Infrastructure | Dedicated Servers, Email & Cloud Storage, Security & Monitoring, Ongoing Support |
|
||||
|
||||
**AI callout (italic):** "And we layer AI into everything — from intelligent features in your website to automation that connects all your tools."
|
||||
|
||||
### 5. Homepage — Philosophy
|
||||
|
||||
**Eyebrow:** "Why Us"
|
||||
**Title:** "We do things differently."
|
||||
**Subtitle:** "Most agencies hand you a template and call it custom. We think your business deserves better — real design, real engineering, and a team that sticks around after launch."
|
||||
|
||||
| Pillar | Title | Description |
|
||||
|--------|-------|-------------|
|
||||
| 01 | Craft Over Convenience | We write clean, hand-built code optimized for speed and search engines. No page builders, no bloated themes, no shortcuts. |
|
||||
| 02 | One Relationship | From first design to ongoing support — one team that knows your business inside and out. No handoffs, no telephone-game briefs. |
|
||||
| 03 | Built to Be Yours | Everything we build, you own. Your code, your data, your infrastructure — no lock-in, no platform dependencies, no surprises. |
|
||||
|
||||
**Pull quote:** "We build technology that works for your business — not the other way around." — Matt Ciaccio, Founder
|
||||
|
||||
### 6. Homepage — CTA Banner
|
||||
|
||||
**Eyebrow:** "Let's Talk"
|
||||
**Title:** "Ready to build something great?"
|
||||
**Subtitle:** "Tell us what you're working on. No pitch decks, no pressure — just an honest conversation about what's possible."
|
||||
**Primary CTA:** "Start Your Project"
|
||||
**Email:** hello@letsbe.biz
|
||||
**Fine print:** "No commitment required."
|
||||
|
||||
### 7. Footer
|
||||
|
||||
**Tagline:** "Custom websites, software, and digital platforms — designed and built for businesses that refuse to settle."
|
||||
**Location:** "American-founded. Serving clients worldwide."
|
||||
|
||||
### 8. About Page — Hero
|
||||
|
||||
**Eyebrow:** "About LetsBe."
|
||||
**Headline:** "Great businesses deserve great digital partners."
|
||||
**Subtitle:** "We design and build custom websites, software, and digital platforms for businesses that care about quality — and want a team that does too."
|
||||
|
||||
### 9. About Page — Our Story
|
||||
|
||||
**Eyebrow:** "Our Story"
|
||||
**Heading:** "Built for businesses like yours."
|
||||
|
||||
**Para 1:** "LetsBe. started with a simple belief: that ambitious businesses deserve digital tools as carefully considered as the work they do. Not templates. Not off-the-shelf platforms. Real design and engineering, built from scratch."
|
||||
|
||||
**Para 2:** "Our early clients were founders and operators who needed more than a website — they needed a technical partner who could design, build, host, and maintain everything under one roof. Those projects shaped how we work today."
|
||||
|
||||
**Para 3:** "We build platforms meant to be owned, not rented. We document everything, we hand over codebases that outlast the engagement, and we never lock clients into systems they can't leave. That's not a feature — it's how we think business should work."
|
||||
|
||||
**Pull quote:** "Build fewer things. Build them better. Build them to last." — LetsBe. founding principle
|
||||
|
||||
### 10. About Page — What We Believe
|
||||
|
||||
**Eyebrow:** "Our Beliefs"
|
||||
**Heading:** "What We Believe"
|
||||
**Intro:** "Three principles behind every project we take on."
|
||||
|
||||
| # | Title | Description |
|
||||
|---|-------|-------------|
|
||||
| 1 | Craftsmanship First | The gap between a website that works and one that lasts is craft. We sweat the typography, the transitions, the performance, the edge cases. Every interface we ship is something we'd be proud to sign. |
|
||||
| 2 | One Team, Everything | Design, development, hosting, infrastructure — one team, one point of contact, one standard of quality. No handoffs between agencies. No juggling freelancers. Just people who care about the whole thing. |
|
||||
| 3 | Built to Be Yours | Everything we build, you own — the code, the data, the infrastructure. No vendor lock-in, no platform dependencies. We hand over work that outlasts the engagement. |
|
||||
|
||||
### 11. About Page — Quote
|
||||
|
||||
**Quote:** "We don't just build websites — we build the foundation your business runs on."
|
||||
**Attribution:** "— LetsBe. founding philosophy"
|
||||
|
||||
### 12. About Page — CTA
|
||||
|
||||
**Eyebrow:** "Work With Us"
|
||||
**Heading:** "Let's build something together."
|
||||
**Subtitle:** "Whether you have a clear brief or just an early idea, we'd love to talk through what's possible."
|
||||
|
||||
### 13. Services Page — Hero
|
||||
|
||||
**Eyebrow:** "Our Services"
|
||||
**Headline:** "Everything your business needs online."
|
||||
**Subtitle:** "We design custom websites, build purpose-built software, and manage the infrastructure behind it all — one team, one standard of quality, nothing outsourced."
|
||||
|
||||
### 14. Services Page — Pillar 01: Web Design & Development
|
||||
|
||||
**Description:** "Your website shouldn't look like everyone else's — and it shouldn't be built like everyone else's either. We design and build custom websites and web applications from a blank canvas, crafting every layout, every interaction, and every page with intention. The result is fast, search-engine-friendly, and built to grow with your business. Whether you need a marketing site that converts, a web application your team relies on, or an e-commerce platform that scales — we build it from scratch, and we build it to last."
|
||||
|
||||
| Feature | Title | Description |
|
||||
|---------|-------|-------------|
|
||||
| Design | Custom Design | Every layout, component, and interaction is designed for your brand. No themes, no templates, no shortcuts. |
|
||||
| Apps | Web Applications | Modern, responsive applications built with the latest technologies — fast, reliable, and ready to scale. |
|
||||
| E-commerce | E-Commerce | Custom storefronts, checkout flows, and multi-currency platforms built for serious online retail. |
|
||||
| Performance | Performance & SEO | Fast load times, clean code, and search engine optimization built into the foundation — not bolted on after. |
|
||||
|
||||
### 15. Services Page — Pillar 02: Software & Platforms
|
||||
|
||||
**Description:** "Off-the-shelf software makes assumptions about how your business works. We don't. When spreadsheets and generic tools stop cutting it, we build the exact system your team needs — designed around your workflow, not someone else's. From CRMs tailored to your sales process, to management platforms that replace three different subscriptions, to integrations that connect your existing tools — everything we build is yours, fully documented, and built to last."
|
||||
|
||||
| Feature | Title | Description |
|
||||
|---------|-------|-------------|
|
||||
| CRM | CRM & Management Tools | Relationship and pipeline management built around how your team actually works — not how a generic platform thinks you should. |
|
||||
| Software | Custom Software | From booking platforms to internal tools to full SaaS products — purpose-built for your business. |
|
||||
| APIs | Integrations & APIs | We connect your existing tools and build the bridges between systems so everything works together. |
|
||||
| Internal | Dashboards & Automation | Admin panels, reporting tools, and workflow automation that give your team an unfair advantage. |
|
||||
|
||||
### 16. Services Page — Pillar 03: Hosting & Infrastructure
|
||||
|
||||
**Description:** "Your website and software need a home — and we think you should own it. We set up and manage dedicated servers, email, cloud storage, and all the infrastructure your business runs on. No shared hosting, no mysterious third-party dependencies. You know where your data lives, who has access, and that someone is watching the dashboard around the clock."
|
||||
|
||||
| Feature | Title | Description |
|
||||
|---------|-------|-------------|
|
||||
| Hosting | Dedicated Hosting | Private servers managed for your business — no shared hosting, no noisy neighbors, no surprises. |
|
||||
| Data | Your Data, Your Control | You own your data and know exactly where it lives. Full access, full transparency, no lock-in. |
|
||||
| Security | Security & Protection | Serious security, proactive monitoring, and protection built into your infrastructure from day one. |
|
||||
| DevOps | Monitoring & Support | Proactive monitoring, regular updates, and ongoing support so you never have to worry about uptime. |
|
||||
|
||||
### 17. Services Page — AI Layer
|
||||
|
||||
**Eyebrow:** "Intelligent Layer"
|
||||
**Title:** "AI Built Into Everything"
|
||||
**Italic subtitle:** "Your platform, made smarter."
|
||||
**Body:** "We integrate AI directly into the websites and software we build for you. Not as a buzzword or an add-on — as practical features that save your team time and give your customers a better experience."
|
||||
|
||||
| Capability | Title | Description |
|
||||
|------------|-------|-------------|
|
||||
| Teammate | AI Teammate | An AI assistant built into your workflow — automates repetitive tasks, surfaces the info your team needs, and connects your tools. |
|
||||
| Customer | Customer-Facing AI | Smart features for your customers — intelligent search, personalized recommendations, and conversational interfaces that work around the clock. |
|
||||
| Data | Data Intelligence | AI that helps you understand your data — automated reports, trend spotting, and insights you can actually act on. |
|
||||
|
||||
**Bottom note:** "Every AI feature is tailored to your business — your data stays on your servers."
|
||||
|
||||
### 18. Services Page — CTA
|
||||
|
||||
**Eyebrow:** "Let's Talk"
|
||||
**Heading:** "Ready to get started?"
|
||||
**Subtitle:** "Walk through a few questions and we'll put together a project brief tailored to you — no commitment required, just clarity."
|
||||
**Primary CTA:** "Start Your Project"
|
||||
**Email:** hello@letsbe.biz
|
||||
**Fine print:** "No commitment required — just a conversation about what's possible."
|
||||
|
||||
### 19. Configurator — Service Options
|
||||
|
||||
**Web Design & Development:** "Custom websites and web applications — designed from scratch, built to perform, and optimized to get found."
|
||||
**Custom Software:** "CRMs, management platforms, and business tools built around the way your team actually works."
|
||||
**Private Infrastructure:** "Dedicated hosting, email, cloud storage, and the infrastructure your business runs on — fully owned by you."
|
||||
**AI Toggle:** "Practical AI features and automation built directly into your website and software."
|
||||
|
||||
### 20. Case Study Pages — CTA
|
||||
|
||||
**Eyebrow:** "Your Turn"
|
||||
**Heading:** "Ready to build something like this?"
|
||||
**Body:** "Every project starts with a conversation. Tell us what you're working on and we'll figure out the best way to bring it to life."
|
||||
**CTA:** "Start your project"
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **i18n files** (`src/i18n/messages/en.json`): Most homepage, nav, footer, configurator, and process section copy lives here. Update the JSON keys.
|
||||
- **Hardcoded pages**: About (`src/app/(frontend)/[locale]/about/page.tsx`), Services (`src/app/(frontend)/[locale]/services/page.tsx`, `src/components/sections/services/ServicesHero.tsx`, `src/components/sections/services/AILayer.tsx`, `src/components/sections/services/ServicesCTA.tsx`), and Case Studies (`src/app/(frontend)/[locale]/work/[slug]/page.tsx`) have inline English copy — edit directly.
|
||||
- **French translations** (`src/i18n/messages/fr.json`): Update French translations to match the new English copy for all i18n'd sections.
|
||||
- **SEO metadata**: Update in `src/app/(frontend)/[locale]/layout.tsx` and `src/app/(frontend)/[locale]/services/page.tsx`.
|
||||
- **No structural/layout changes** — this is a copy-only update.
|
||||
60
nginx/letsbe.biz.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# Redirect www → non-www
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name www.letsbe.biz;
|
||||
|
||||
# Certbot will upgrade this to https after cert is issued
|
||||
return 301 http://letsbe.biz$request_uri;
|
||||
}
|
||||
|
||||
# Main site
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name letsbe.biz;
|
||||
|
||||
# Certbot will add SSL config after:
|
||||
# sudo certbot --nginx -d letsbe.biz -d www.letsbe.biz
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Static assets — long cache
|
||||
location /_next/static/ {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Public assets
|
||||
location /images/ {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_cache_valid 200 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Payload media uploads
|
||||
location /media/ {
|
||||
proxy_pass http://127.0.0.1:6974;
|
||||
proxy_cache_valid 200 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
706
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@calcom/embed-react": "^1.5.3",
|
||||
"@google/genai": "^1.48.0",
|
||||
"@payloadcms/db-postgres": "^3.80.0",
|
||||
"@payloadcms/next": "^3.80.0",
|
||||
"@payloadcms/richtext-lexical": "^3.80.0",
|
||||
@@ -16,6 +17,7 @@
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19.2.14",
|
||||
"cheerio": "^1.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"graphql": "^16.13.2",
|
||||
@@ -26,6 +28,7 @@
|
||||
"payload": "^3.80.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-international-phone": "^4.8.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
@@ -1431,6 +1434,29 @@
|
||||
"@formatjs/fast-memoize": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz",
|
||||
"integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.3.0",
|
||||
"p-retry": "^4.6.2",
|
||||
"protobufjs": "^7.5.4",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@modelcontextprotocol/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -2901,6 +2927,70 @@
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@schummar/icu-type-parser": {
|
||||
"version": "1.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
||||
@@ -3537,6 +3627,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -3576,6 +3672,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -3684,10 +3789,30 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.10",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
|
||||
"integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
|
||||
"version": "2.10.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz",
|
||||
"integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -3696,6 +3821,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -3714,6 +3848,12 @@
|
||||
"integrity": "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -3766,6 +3906,12 @@
|
||||
"integrity": "sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -3871,6 +4017,57 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -3986,6 +4183,34 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssfilter": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
|
||||
@@ -3998,6 +4223,15 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/dataloader": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz",
|
||||
@@ -4103,6 +4337,59 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
@@ -4113,6 +4400,20 @@
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.31.7",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.7.tgz",
|
||||
@@ -4253,6 +4554,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.325",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
||||
@@ -4260,6 +4570,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -4409,6 +4732,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
||||
@@ -4460,6 +4789,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-19.3.0.tgz",
|
||||
@@ -4504,6 +4856,18 @@
|
||||
"tabbable": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -4568,6 +4932,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
|
||||
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
@@ -4592,6 +4984,32 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.6.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -4691,6 +5109,25 @@
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-status": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/http-status/-/http-status-2.1.0.tgz",
|
||||
@@ -4700,6 +5137,31 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/icu-minify": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
||||
@@ -4971,6 +5433,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
@@ -5015,6 +5486,27 @@
|
||||
"jsox": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -5325,6 +5817,12 @@
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -6242,6 +6740,44 @@
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
@@ -6267,6 +6803,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -6306,6 +6854,19 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "0.12.0",
|
||||
"retry": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -6361,6 +6922,55 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -6855,6 +7465,30 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
@@ -6965,6 +7599,15 @@
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-international-phone": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.8.0.tgz",
|
||||
"integrity": "sha512-PoyXx8t0OZNZXLupZN5UtmLb8nO6PQ6f6jQvYCAtg7VzxonuBcDs/4YA4+flqZZj5QOVqN4DLY1p39mEtJAwzw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -7088,6 +7731,35 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
@@ -7097,6 +7769,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-filename": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
|
||||
@@ -8204,6 +8882,28 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/embed-react": "^1.5.3",
|
||||
"@google/genai": "^1.48.0",
|
||||
"@payloadcms/db-postgres": "^3.80.0",
|
||||
"@payloadcms/next": "^3.80.0",
|
||||
"@payloadcms/richtext-lexical": "^3.80.0",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19.2.14",
|
||||
"cheerio": "^1.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"graphql": "^16.13.2",
|
||||
@@ -28,6 +30,7 @@
|
||||
"payload": "^3.80.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-international-phone": "^4.8.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
BIN
public/images/anguilla.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/monaco_high_res.jpg
Normal file
|
After Width: | Height: | Size: 636 KiB |
BIN
public/images/og-default.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/images/our_story.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/panama.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/images/philosophy_image.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
31
public/llms.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
# LetsBe.
|
||||
|
||||
> Bespoke digital studio — custom web design, software, AI integration, and private infrastructure.
|
||||
|
||||
LetsBe. is a digital studio founded by Matt Ciaccio, serving clients worldwide from the Côte d'Azur, France. We design and build custom websites, purpose-built software, and manage private digital infrastructure — all under one roof.
|
||||
|
||||
## Services
|
||||
|
||||
- **Web Design & Development**: Custom websites and web applications designed from scratch, built for performance and SEO.
|
||||
- **Software & Platforms**: CRMs, management tools, dashboards, booking systems, and API integrations built around your workflow.
|
||||
- **Hosting & Infrastructure**: Dedicated servers, email, cloud storage, security, and monitoring — fully owned by the client.
|
||||
- **AI Integration**: Intelligent features, automation, and data intelligence woven into websites and software.
|
||||
|
||||
## Selected Work
|
||||
|
||||
- [Monaco Ocean Protection Challenge](/work/monaco-ocean): AI-powered judging and analytics platform for a Mediterranean conservation event.
|
||||
- [Port Nimara](/work/port-nimara): Custom website and CRM for marina lead management and operations.
|
||||
- [Port Amador](/work/port-amador): Website and private digital infrastructure for a premium marina.
|
||||
|
||||
## Key Pages
|
||||
|
||||
- [Home](https://letsbe.biz)
|
||||
- [Services](https://letsbe.biz/services)
|
||||
- [About](https://letsbe.biz/about)
|
||||
- [Start a Project](https://letsbe.biz/#configure)
|
||||
|
||||
## Contact
|
||||
|
||||
- Email: hello@letsbe.biz
|
||||
- LinkedIn: https://www.linkedin.com/company/letsbe-digital
|
||||
- X: https://x.com/letsbe_digital
|
||||
@@ -1,163 +1,50 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Shield, PenTool, Users } from 'lucide-react';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CornerBracket from '@/components/icons/CornerBracket';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz';
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'meta.about' });
|
||||
|
||||
const path = locale === 'en' ? '/about' : `/${locale}/about`;
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': `${BASE_URL}/about`,
|
||||
'fr': `${BASE_URL}/fr/about`,
|
||||
'it': `${BASE_URL}/it/about`,
|
||||
'es': `${BASE_URL}/es/about`,
|
||||
'x-default': `${BASE_URL}/about`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Static Generation ────────────────────────────────────────────────────────
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
id: 'ownership',
|
||||
Icon: Shield,
|
||||
title: 'Ownership & Privacy',
|
||||
description:
|
||||
'We build on infrastructure you control. No vendor lock-in, no opaque SaaS dependencies quietly holding your data hostage. Your platform, your servers, your rules — backed by engineering that makes it maintainable.',
|
||||
},
|
||||
{
|
||||
id: 'craftsmanship',
|
||||
Icon: PenTool,
|
||||
title: 'Craftsmanship',
|
||||
description:
|
||||
'The gap between a website that works and one that endures is craft. We sweat the typography, the transitions, the query performance, the edge cases. Every interface we ship is something we would be proud to sign.',
|
||||
},
|
||||
{
|
||||
id: 'one-team',
|
||||
Icon: Users,
|
||||
title: 'One Team, Everything',
|
||||
description:
|
||||
'Strategy, design, engineering, infrastructure — under one roof, one point of contact, one shared standard of quality. No handoffs between agencies. No telephone-game briefs. Just people who care about the whole thing.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StoryGeometry() {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Primary gradient circle — top right */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '70%',
|
||||
height: '70%',
|
||||
top: '-15%',
|
||||
right: '-15%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.10) 0%, rgba(0,100,148,0.05) 55%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Secondary circle — bottom left */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '55%',
|
||||
height: '55%',
|
||||
bottom: '-10%',
|
||||
left: '-8%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(46,196,160,0.07) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diagonal grid texture */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, var(--color-primary-dark) 0, var(--color-primary-dark) 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tilted rectangle — left center */}
|
||||
<div
|
||||
className="absolute rounded-lg"
|
||||
style={{
|
||||
width: '24%',
|
||||
height: '34%',
|
||||
top: '18%',
|
||||
left: '6%',
|
||||
border: '1.5px solid rgba(91,164,217,0.12)',
|
||||
transform: 'rotate(-6deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dot field — right center */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '28%',
|
||||
height: '28%',
|
||||
top: '30%',
|
||||
right: '8%',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.22) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '10px 10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dashed arc */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{ top: '15%', left: '28%', opacity: 0.07 }}
|
||||
width="160"
|
||||
height="160"
|
||||
viewBox="0 0 160 160"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="80"
|
||||
r="68"
|
||||
stroke="var(--color-primary-dark)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="7 5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Small accent square */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
width: '5%',
|
||||
height: '5%',
|
||||
bottom: '26%',
|
||||
right: '26%',
|
||||
background: 'rgba(91,164,217,0.18)',
|
||||
transform: 'rotate(14deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Teal accent line — bottom */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '30%',
|
||||
height: '2px',
|
||||
bottom: '14%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background:
|
||||
'linear-gradient(to right, transparent, rgba(46,196,160,0.35), transparent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PillarCard({
|
||||
Icon,
|
||||
title,
|
||||
@@ -205,6 +92,14 @@ export default async function AboutPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'aboutPage' });
|
||||
|
||||
const PILLARS = [
|
||||
{ id: 'craftsmanship', Icon: PenTool, title: t('pillars.craftsmanship.title'), description: t('pillars.craftsmanship.description') },
|
||||
{ id: 'one-team', Icon: Users, title: t('pillars.oneTeam.title'), description: t('pillars.oneTeam.description') },
|
||||
{ id: 'ownership', Icon: Shield, title: t('pillars.ownership.title'), description: t('pillars.ownership.description') },
|
||||
];
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
@@ -214,21 +109,18 @@ export default async function AboutPage({ params }: Props) {
|
||||
<div className="max-w-4xl mx-auto flex flex-col items-center text-center gap-8">
|
||||
|
||||
<ScrollReveal variant="fadeIn">
|
||||
<span className="label-md text-primary">About LetsBe.</span>
|
||||
<span className="label-md text-primary">{t('hero.eyebrow')}</span>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<h1 className="font-serif font-semibold text-on-surface text-5xl md:text-6xl lg:text-[4rem] leading-[1.05] tracking-[-0.03em]">
|
||||
Digital Sovereignty
|
||||
<br />
|
||||
is not a luxury.
|
||||
{t('hero.title')}
|
||||
</h1>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<p className="text-outline text-xl leading-relaxed max-w-2xl">
|
||||
We build digital platforms for businesses that refuse to compromise — on ownership,
|
||||
on quality, or on the partner they trust to build it with them.
|
||||
{t('hero.subtitle')}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -245,33 +137,16 @@ export default async function AboutPage({ params }: Props) {
|
||||
<div className="lg:col-span-5 flex flex-col gap-10">
|
||||
|
||||
<ScrollReveal variant="slideLeft" className="flex flex-col gap-4">
|
||||
<span className="label-md text-primary">Our Story</span>
|
||||
<span className="label-md text-primary">{t('story.eyebrow')}</span>
|
||||
<h2 className="font-serif text-4xl md:text-[2.75rem] font-semibold text-on-surface leading-[1.1] tracking-[-0.02em]">
|
||||
Born on the<br />
|
||||
Côte d’Azur.
|
||||
{t('story.title')}
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fadeUp" delay={0.1} className="flex flex-col gap-5 text-outline leading-relaxed text-[0.9375rem]">
|
||||
<p>
|
||||
LetsBe. was founded on the French Riviera by a small team of engineers and
|
||||
designers who shared a single conviction: that ambitious businesses deserve digital
|
||||
infrastructure as carefully considered as the work they do.
|
||||
</p>
|
||||
<p>
|
||||
We started by building for founders and institutions along the coast — port
|
||||
authorities, conservation organisations, maritime operators — each one operating
|
||||
in a context where reliability, elegance, and discretion were not optional extras.
|
||||
Those early projects shaped everything we believe about what a digital partner
|
||||
should be.
|
||||
</p>
|
||||
<p>
|
||||
Today we work with clients across Europe and the Mediterranean on platforms that
|
||||
are built to be owned, not rented. We do not believe in locking clients into
|
||||
systems they cannot see, services they cannot leave, or vendors whose priorities
|
||||
will drift from theirs. We build on open infrastructure, we document everything,
|
||||
and we hand over codebases that outlast the engagement.
|
||||
</p>
|
||||
<p>{t('story.p1')}</p>
|
||||
<p>{t('story.p2')}</p>
|
||||
<p>{t('story.p3')}</p>
|
||||
</ScrollReveal>
|
||||
|
||||
</div>
|
||||
@@ -283,7 +158,13 @@ export default async function AboutPage({ params }: Props) {
|
||||
className="relative bg-surface rounded-xl overflow-hidden"
|
||||
style={{ minHeight: '460px' }}
|
||||
>
|
||||
<StoryGeometry />
|
||||
<Image
|
||||
src="/images/our_story.png"
|
||||
alt="Our story"
|
||||
fill
|
||||
className="object-cover rounded-xl"
|
||||
sizes="(max-width: 1024px) 100vw, 58vw"
|
||||
/>
|
||||
|
||||
{/* Inset rim */}
|
||||
<div
|
||||
@@ -311,13 +192,13 @@ export default async function AboutPage({ params }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<blockquote className="font-serif italic text-lg text-on-surface leading-relaxed pr-8">
|
||||
“Build fewer things. Build them better. Build them to last.”
|
||||
"{t('story.quote')}"
|
||||
</blockquote>
|
||||
<div
|
||||
className="w-8 h-px bg-primary/40 my-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="label-md text-outline">LetsBe. founding principle</p>
|
||||
<p className="label-md text-outline">{t('story.quoteAttrib')}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -331,13 +212,12 @@ export default async function AboutPage({ params }: Props) {
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
<ScrollReveal variant="fadeUp" className="flex flex-col items-center text-center gap-4 mb-16">
|
||||
<span className="label-md text-primary">Our Beliefs</span>
|
||||
<span className="label-md text-primary">{t('pillars.eyebrow')}</span>
|
||||
<h2 className="font-serif font-semibold text-on-surface text-4xl md:text-5xl leading-[1.1] tracking-[-0.02em] max-w-2xl">
|
||||
What We Believe
|
||||
{t('pillars.title')}
|
||||
</h2>
|
||||
<p className="text-outline text-lg leading-relaxed max-w-xl mt-1">
|
||||
Three principles that inform every decision we make, every line of code we write, and
|
||||
every client relationship we enter.
|
||||
{t('pillars.subtitle')}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -367,12 +247,11 @@ export default async function AboutPage({ params }: Props) {
|
||||
/>
|
||||
|
||||
<blockquote className="font-serif italic text-white text-3xl md:text-[2.25rem] leading-[1.3] tracking-[-0.02em]">
|
||||
“Our mission is to bring the precision of architecture to the fluidity of the
|
||||
web.”
|
||||
"{t('quote.text')}"
|
||||
</blockquote>
|
||||
|
||||
<p className="label-md text-white/40">
|
||||
— LetsBe. founding philosophy
|
||||
— {t('quote.attrib')}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -387,26 +266,25 @@ export default async function AboutPage({ params }: Props) {
|
||||
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-8">
|
||||
|
||||
<div className="flex flex-col gap-3 items-center">
|
||||
<span className="label-md text-primary">Work With Us</span>
|
||||
<span className="label-md text-primary">{t('cta.eyebrow')}</span>
|
||||
<h2 className="font-serif font-semibold text-on-surface text-3xl md:text-4xl leading-[1.1] tracking-[-0.02em]">
|
||||
Let’s build something together.
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-outline text-lg leading-relaxed max-w-lg">
|
||||
Whether you have a clear brief or an early-stage idea, we would be glad to talk
|
||||
through what is possible.
|
||||
{t('cta.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<Button variant="primary" size="lg" arrow href="/#configure">
|
||||
Start your project
|
||||
{t('cta.primary')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
href="mailto:hello@letsbe.biz"
|
||||
>
|
||||
Book a call
|
||||
{t('cta.secondary')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,28 +1,85 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages, setRequestLocale } from 'next-intl/server'
|
||||
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { routing } from '@/i18n/routing'
|
||||
import Nav from '@/components/layout/Nav'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import GoogleAnalytics from '@/components/analytics/GoogleAnalytics'
|
||||
import CookieConsent from '@/components/analytics/CookieConsent'
|
||||
import '@/styles/globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'LetsBe. | Bespoke Digital Studio & AI Infrastructure',
|
||||
description:
|
||||
'Bespoke digital ecosystems, private infrastructure, and AI automation for ambitious businesses on the Côte d\'Azur.',
|
||||
}
|
||||
const BASE_URL = 'https://letsbe.biz'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'meta' })
|
||||
|
||||
const ogLocaleMap: Record<string, string> = { en: 'en_US', fr: 'fr_FR', it: 'it_IT', es: 'es_ES' }
|
||||
const currentOgLocale = ogLocaleMap[locale] ?? 'en_US'
|
||||
const otherOgLocales = Object.values(ogLocaleMap).filter((l) => l !== currentOgLocale)
|
||||
|
||||
return {
|
||||
metadataBase: new URL(BASE_URL),
|
||||
title: {
|
||||
default: t('home.title'),
|
||||
template: '%s',
|
||||
},
|
||||
description: t('home.description'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: t('siteName'),
|
||||
locale: currentOgLocale,
|
||||
images: [{ url: '/images/og-default.png', width: 1200, height: 630 }],
|
||||
},
|
||||
other: {
|
||||
'og:locale:alternate': otherOgLocales,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }))
|
||||
}
|
||||
|
||||
// ─── JSON-LD: Organization ───────────────────────────────────────────────────
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'LetsBe.',
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/images/letsbe-logo-short.png`,
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/letsbe-digital',
|
||||
'https://x.com/letsbe_digital',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
email: 'hello@letsbe.biz',
|
||||
contactType: 'customer service',
|
||||
},
|
||||
availableLanguage: [
|
||||
{ '@type': 'Language', name: 'English', alternateName: 'en' },
|
||||
{ '@type': 'Language', name: 'French', alternateName: 'fr' },
|
||||
{ '@type': 'Language', name: 'Italian', alternateName: 'it' },
|
||||
{ '@type': 'Language', name: 'Spanish', alternateName: 'es' },
|
||||
],
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: Props) {
|
||||
const { locale } = await params
|
||||
|
||||
@@ -38,6 +95,10 @@ export default async function LocaleLayout({ children, params }: Props) {
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
||||
/>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
@@ -47,10 +108,12 @@ export default async function LocaleLayout({ children, params }: Props) {
|
||||
)}
|
||||
</head>
|
||||
<body className="font-sans text-on-surface bg-surface antialiased">
|
||||
<GoogleAnalytics />
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Nav />
|
||||
{children}
|
||||
<Footer />
|
||||
<CookieConsent />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import type { Metadata } from 'next'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import Hero from '@/components/sections/Hero'
|
||||
import TrustBar from '@/components/sections/TrustBar'
|
||||
import ServicesOverview from '@/components/sections/ServicesOverview'
|
||||
@@ -6,23 +7,61 @@ import Process from '@/components/sections/Process'
|
||||
import SelectedWorks from '@/components/sections/SelectedWorks'
|
||||
import Philosophy from '@/components/sections/Philosophy'
|
||||
import Configurator from '@/components/sections/Configurator'
|
||||
import Discovery from '@/components/sections/Discovery'
|
||||
import CTABanner from '@/components/sections/CTABanner'
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'meta.home' })
|
||||
|
||||
const path = locale === 'en' ? '' : `/${locale}`
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': BASE_URL,
|
||||
'fr': `${BASE_URL}/fr`,
|
||||
'it': `${BASE_URL}/it`,
|
||||
'es': `${BASE_URL}/es`,
|
||||
'x-default': BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: Props) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'LetsBe.',
|
||||
url: BASE_URL,
|
||||
inLanguage: locale,
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<Hero />
|
||||
<TrustBar />
|
||||
<ServicesOverview />
|
||||
<Configurator />
|
||||
<Process />
|
||||
<Discovery />
|
||||
<SelectedWorks />
|
||||
<Philosophy />
|
||||
<CTABanner />
|
||||
|
||||
@@ -1,148 +1,40 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import ServicesHero from '@/components/sections/services/ServicesHero';
|
||||
import ServicePillar from '@/components/sections/services/ServicePillar';
|
||||
import AILayer from '@/components/sections/services/AILayer';
|
||||
import ServicesCTA from '@/components/sections/services/ServicesCTA';
|
||||
// Icon names passed as strings — resolved in client component
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz';
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Services | LetsBe. — Bespoke Digital Studio',
|
||||
description:
|
||||
'From bespoke web design to private infrastructure and AI automation — three pillars of digital excellence, engineered for ambitious businesses.',
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
// ─── Service data ──────────────────────────────────────────────────────────────
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'meta.services' });
|
||||
|
||||
export const SERVICE_PILLARS = [
|
||||
{
|
||||
id: 'design-development',
|
||||
numeral: '01',
|
||||
title: 'Web Design & Development',
|
||||
description:
|
||||
'Your digital presence is not a template waiting to be filled — it is an engineered expression of your brand. We design and build bespoke websites and web applications from a blank canvas, crafting every interaction, every transition, and every responsive breakpoint with intention. Our work is fast by architecture, not by accident: semantic markup, optimised asset pipelines, and edge-deployed rendering that scores at the top of the Core Web Vitals chart. Whether you need a high-conversion marketing site, a complex SaaS application, or a multi-region e-commerce platform, we deliver a digital product that is built to last and built to grow.',
|
||||
background: 'bg-surface' as const,
|
||||
features: [
|
||||
{
|
||||
icon: 'Palette',
|
||||
title: 'Bespoke UI/UX Design',
|
||||
description:
|
||||
'Custom Figma-to-code workflows. Every layout, component, and motion decision serves your brand — never a theme, never a shortcut.',
|
||||
},
|
||||
{
|
||||
icon: 'Globe',
|
||||
title: 'Modern Web Applications',
|
||||
description:
|
||||
'React, Next.js, and edge-deployed architecture for SPAs, SSR, and full-stack applications that scale without compromise.',
|
||||
},
|
||||
{
|
||||
icon: 'ShoppingCart',
|
||||
title: 'E-commerce & Platforms',
|
||||
description:
|
||||
'Headless storefronts, custom checkout flows, and multi-currency platforms engineered for high-volume retail.',
|
||||
},
|
||||
{
|
||||
icon: 'Zap',
|
||||
title: 'Performance Optimisation',
|
||||
description:
|
||||
'LCP under one second, zero layout shift. We audit, refactor, and rebuild performance into the foundation of your product.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'custom-systems',
|
||||
numeral: '02',
|
||||
title: 'Custom Systems',
|
||||
description:
|
||||
'Off-the-shelf software makes assumptions about your business. We don\'t. When your operations outgrow spreadsheets and generic SaaS platforms, we build the exact system your team needs — with the data model, the permission structure, and the workflow logic that reflects how you actually work. From CRM platforms tailored to your sales cycle, to internal management tools that replace three different subscriptions, to API architecture that connects your existing stack into a coherent whole — every system we build is owned by you, documented in full, and maintained without vendor dependency.',
|
||||
background: 'bg-surface-low' as const,
|
||||
features: [
|
||||
{
|
||||
icon: 'Database',
|
||||
title: 'CRM & Management',
|
||||
description:
|
||||
'Purpose-built relationship and pipeline management with the fields, views, and automation rules your team will actually use.',
|
||||
},
|
||||
{
|
||||
icon: 'Code2',
|
||||
title: 'Bespoke Software',
|
||||
description:
|
||||
'Full-stack business applications: from quoting and booking platforms to complex multi-tenant SaaS products.',
|
||||
},
|
||||
{
|
||||
icon: 'GitBranch',
|
||||
title: 'API Architecture',
|
||||
description:
|
||||
'RESTful and GraphQL APIs, webhook integrations, and the middleware layer that makes your disparate tools speak the same language.',
|
||||
},
|
||||
{
|
||||
icon: 'Wrench',
|
||||
title: 'Internal Tooling',
|
||||
description:
|
||||
'Admin dashboards, reporting portals, and workflow automation that give your team unfair operational advantages.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'infrastructure',
|
||||
numeral: '03',
|
||||
title: 'Digital Infrastructure',
|
||||
description:
|
||||
'Data sovereignty is not a feature — it is a right. We provision and manage private cloud infrastructure that removes your reliance on opaque hyperscalers and shared-hosting providers. Your data lives where you say it lives, on servers you control, with access policies your legal team can actually review. We handle the full infrastructure lifecycle: dedicated server provisioning, containerised deployments with Docker and Kubernetes, automated backup strategies, TLS certificate management, and 24/7 uptime monitoring. When the European data-residency audit arrives, you will be the only business in the room with a clean answer.',
|
||||
background: 'bg-surface' as const,
|
||||
features: [
|
||||
{
|
||||
icon: 'Server',
|
||||
title: 'Dedicated Hosting',
|
||||
description:
|
||||
'Private VPS and bare-metal environments in EU data centres — no shared neighbours, no noisy-neighbour risk.',
|
||||
},
|
||||
{
|
||||
icon: 'Shield',
|
||||
title: 'Data Sovereignty',
|
||||
description:
|
||||
'GDPR-aligned architecture with data-residency guarantees, audit trails, and full client ownership of storage and credentials.',
|
||||
},
|
||||
{
|
||||
icon: 'Lock',
|
||||
title: 'Security Hardening',
|
||||
description:
|
||||
'WAF configuration, DDoS mitigation, secrets management, and penetration-testing-ready hardened deployment pipelines.',
|
||||
},
|
||||
{
|
||||
icon: 'Settings',
|
||||
title: 'DevOps & Maintenance',
|
||||
description:
|
||||
'CI/CD pipelines, container orchestration, rolling deployments, and proactive monitoring so your team ships without fear.',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
const path = locale === 'en' ? '/services' : `/${locale}/services`;
|
||||
|
||||
// ─── AI Layer data ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const AI_CAPABILITIES = [
|
||||
{
|
||||
id: 'ai-teammate',
|
||||
title: 'AI Teammate',
|
||||
description:
|
||||
'An operational assistant embedded directly into your workflow. Retrieves and summarises emails, drafts responses, executes tasks across connected tools, and surfaces the information your team needs before they know they need it.',
|
||||
},
|
||||
{
|
||||
id: 'customer-facing-ai',
|
||||
title: 'Customer-Facing AI',
|
||||
description:
|
||||
'Intelligent conversational interfaces that handle enquiries, qualify leads, make product recommendations, and personalise the user journey — at scale, around the clock, without extra headcount.',
|
||||
},
|
||||
{
|
||||
id: 'data-intelligence',
|
||||
title: 'Data Intelligence',
|
||||
description:
|
||||
'Automated analytics pipelines, predictive modelling, and scheduled reporting that transform the data your systems collect into decisions your leadership team can act on.',
|
||||
},
|
||||
] as const;
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': `${BASE_URL}/services`,
|
||||
'fr': `${BASE_URL}/fr/services`,
|
||||
'it': `${BASE_URL}/it/services`,
|
||||
'es': `${BASE_URL}/es/services`,
|
||||
'x-default': `${BASE_URL}/services`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -154,19 +46,37 @@ export default async function ServicesPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'servicesPage' })
|
||||
|
||||
const backgrounds = ['bg-surface', 'bg-surface-low', 'bg-surface'] as const
|
||||
const pillars = [0, 1, 2].map((i) => ({
|
||||
id: t(`pillars.${i}.id`),
|
||||
numeral: t(`pillars.${i}.numeral`),
|
||||
title: t(`pillars.${i}.title`),
|
||||
description: t(`pillars.${i}.description`),
|
||||
background: backgrounds[i],
|
||||
features: [0, 1, 2, 3].map((j) => ({
|
||||
icon: t(`pillars.${i}.features.${j}.icon`),
|
||||
title: t(`pillars.${i}.features.${j}.title`),
|
||||
description: t(`pillars.${i}.features.${j}.description`),
|
||||
})),
|
||||
}))
|
||||
|
||||
const aiCapabilities = [0, 1, 2].map((i) => ({
|
||||
id: t(`ai.capabilities.${i}.id`),
|
||||
title: t(`ai.capabilities.${i}.title`),
|
||||
description: t(`ai.capabilities.${i}.description`),
|
||||
}))
|
||||
|
||||
return (
|
||||
<main>
|
||||
<ServicesHero />
|
||||
|
||||
{SERVICE_PILLARS.map((pillar, index) => (
|
||||
<ServicePillar
|
||||
key={pillar.id}
|
||||
pillar={pillar}
|
||||
index={index}
|
||||
/>
|
||||
{pillars.map((pillar, index) => (
|
||||
<ServicePillar key={pillar.id} pillar={pillar} index={index} />
|
||||
))}
|
||||
|
||||
<AILayer capabilities={AI_CAPABILITIES} />
|
||||
<AILayer capabilities={aiCapabilities} />
|
||||
|
||||
<ServicesCTA />
|
||||
</main>
|
||||
|
||||
@@ -1,69 +1,83 @@
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CornerBracket from '@/components/icons/CornerBracket';
|
||||
import Chip from '@/components/ui/Chip';
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Project {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
challenge: string;
|
||||
approach: string;
|
||||
outcome: string;
|
||||
image: string;
|
||||
techStack: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// ─── Slug-to-work-key mapping ─────────────────────────────────────────────────
|
||||
|
||||
const SLUG_TO_KEY: Record<string, string> = {
|
||||
'monaco-ocean': 'monaco',
|
||||
'port-nimara': 'portNimara',
|
||||
'port-amador': 'portAmador',
|
||||
}
|
||||
|
||||
// ─── Data (will come from Payload CMS) ────────────────────────────────────────
|
||||
|
||||
const PROJECTS: Record<string, Project> = {
|
||||
'monaco-ocean': {
|
||||
title: 'Monaco Ocean Protection Challenge',
|
||||
subtitle: 'AI-Powered Judging & Analytics Platform',
|
||||
description:
|
||||
"A comprehensive judging and analytics system with advanced AI jury integration for one of the Mediterranean's most prestigious conservation events.",
|
||||
challenge:
|
||||
'The Monaco Ocean Protection Challenge needed a modern platform to manage submissions, coordinate judges across time zones, and provide AI-assisted evaluation of conservation proposals — all while maintaining the prestige and security expected of a Monaco institution.',
|
||||
approach:
|
||||
'We built a custom platform from the ground up using Next.js and a private PostgreSQL infrastructure. The AI jury module uses natural language processing to pre-screen submissions and generate summary reports, while human judges retain full control over final decisions.',
|
||||
outcome:
|
||||
"The platform processed over 200 submissions in its first season, reducing judge workload by 40% through AI-assisted pre-screening. The client praised the system's reliability and the elegance of its interface.",
|
||||
image: '/images/monaco_high_res.jpg',
|
||||
techStack: ['Next.js', 'PostgreSQL', 'OpenAI API', 'Docker', 'Private Cloud'],
|
||||
tags: ['AI Integration', 'Platform'],
|
||||
},
|
||||
'port-nimara': {
|
||||
title: 'Port Nimara',
|
||||
subtitle: 'Maritime Digital Hub',
|
||||
description: 'Scalable digital hub for maritime logistics.',
|
||||
challenge:
|
||||
'Port Nimara needed a modern digital presence that could serve as both a marketing website and an operational hub for berth inquiries, event management, and partner communications.',
|
||||
approach:
|
||||
'We designed and developed a performant Nuxt.js application with a headless CMS for content management, integrated with their existing maritime scheduling systems via custom API middleware.',
|
||||
outcome:
|
||||
'The new platform increased online berth inquiries by 3x and provided the port authority with real-time content management capabilities they previously lacked.',
|
||||
image: '/images/anguilla.png',
|
||||
techStack: ['Nuxt.js', 'Directus CMS', 'Node.js', 'Docker'],
|
||||
tags: ['Website', 'Infrastructure'],
|
||||
},
|
||||
'port-amador': {
|
||||
title: 'Port Amador',
|
||||
subtitle: 'Premium Nautical Experience',
|
||||
description: 'Premium digital experience for elite nautical services.',
|
||||
challenge:
|
||||
'Port Amador required a luxury-grade digital experience that matched the exclusivity of their nautical services, with multi-language support and seamless booking integration.',
|
||||
approach:
|
||||
'We crafted a bespoke website with cinematic imagery, smooth animations, and an integrated booking flow. The site was built on modern web technologies with a focus on performance and SEO for the competitive luxury maritime market.',
|
||||
outcome:
|
||||
"The redesigned platform elevated Port Amador's digital presence to match their premium positioning, with a 60% improvement in page load times and significantly increased organic traffic.",
|
||||
image: '/images/panama.png',
|
||||
techStack: ['Next.js', 'Tailwind CSS', 'Framer Motion', 'Vercel'],
|
||||
tags: ['Website', 'Infrastructure'],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const project = PROJECTS[slug];
|
||||
if (!project) return {};
|
||||
|
||||
const workKey = SLUG_TO_KEY[slug];
|
||||
if (!workKey) return {};
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'meta.work' });
|
||||
|
||||
const path = locale === 'en' ? `/work/${slug}` : `/${locale}/work/${slug}`;
|
||||
|
||||
return {
|
||||
title: t(`${slug}.title` as any),
|
||||
description: t(`${slug}.description` as any),
|
||||
openGraph: {
|
||||
images: [{ url: project.image }],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}${path}`,
|
||||
languages: {
|
||||
'en': `${BASE_URL}/work/${slug}`,
|
||||
'fr': `${BASE_URL}/fr/work/${slug}`,
|
||||
'it': `${BASE_URL}/it/work/${slug}`,
|
||||
'es': `${BASE_URL}/es/work/${slug}`,
|
||||
'x-default': `${BASE_URL}/work/${slug}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Static Generation ────────────────────────────────────────────────────────
|
||||
|
||||
export function generateStaticParams() {
|
||||
@@ -119,43 +133,91 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
const project = PROJECTS[slug];
|
||||
if (!project) notFound();
|
||||
|
||||
const workKey = SLUG_TO_KEY[slug];
|
||||
if (!workKey) notFound();
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'caseStudy' });
|
||||
const tw = await getTranslations({ locale, namespace: 'work' });
|
||||
|
||||
// Get translated content
|
||||
const title = tw(`projects.${workKey}.title`)
|
||||
const tags = tw.raw(`projects.${workKey}.tags`) as string[]
|
||||
const subtitle = t(`projects.${slug}.subtitle`)
|
||||
const description = t(`projects.${slug}.description`)
|
||||
const challenge = t(`projects.${slug}.challenge`)
|
||||
const approach = t(`projects.${slug}.approach`)
|
||||
const outcome = t(`projects.${slug}.outcome`)
|
||||
|
||||
const caseStudyJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CreativeWork',
|
||||
name: title,
|
||||
description: description,
|
||||
image: `${BASE_URL}${project.image}`,
|
||||
inLanguage: locale,
|
||||
creator: {
|
||||
'@type': 'Organization',
|
||||
name: 'LetsBe.',
|
||||
url: BASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(caseStudyJsonLd) }}
|
||||
/>
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<section className="bg-surface-low py-24">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-6">
|
||||
<section className="relative min-h-[420px] md:min-h-[480px] flex items-end overflow-hidden">
|
||||
{/* Background image */}
|
||||
<Image
|
||||
src={project.image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
{/* Dark overlay for text readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[rgba(25,28,29,0.85)] via-[rgba(25,28,29,0.55)] to-[rgba(25,28,29,0.3)]" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 pb-16 pt-32">
|
||||
<div className="max-w-3xl mx-auto flex flex-col items-center text-center gap-5">
|
||||
|
||||
{/* Tags */}
|
||||
<ScrollReveal variant="fadeIn">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Chip key={tag} size="md">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center bg-white/15 backdrop-blur-sm text-white/90 text-[0.75rem] font-semibold px-3 py-1 rounded-full leading-none tracking-wide"
|
||||
>
|
||||
{tag}
|
||||
</Chip>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Title */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.08}>
|
||||
<h1 className="font-serif font-semibold text-on-surface text-4xl md:text-5xl lg:text-[3.25rem] leading-[1.1] tracking-[-0.02em]">
|
||||
{project.title}
|
||||
<h1 className="font-serif font-semibold text-white text-4xl md:text-5xl lg:text-[3.25rem] leading-[1.1] tracking-[-0.02em]">
|
||||
{title}
|
||||
</h1>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Subtitle */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.16}>
|
||||
<p className="label-md text-primary tracking-widest">
|
||||
{project.subtitle}
|
||||
<p className="label-md text-white/70 tracking-widest">
|
||||
{subtitle}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Description */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.22}>
|
||||
<p className="text-outline text-lg leading-relaxed max-w-2xl">
|
||||
{project.description}
|
||||
<p className="text-white/80 text-lg leading-relaxed max-w-2xl">
|
||||
{description}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -182,10 +244,10 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
<div className="flex flex-col gap-16 md:gap-20">
|
||||
|
||||
<ContentSection
|
||||
label="The Challenge"
|
||||
label={t('labels.challenge')}
|
||||
index="01"
|
||||
heading="The problem we set out to solve"
|
||||
body={project.challenge}
|
||||
heading={t('labels.challengeHeading')}
|
||||
body={challenge}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
@@ -201,10 +263,10 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
</ScrollReveal>
|
||||
|
||||
<ContentSection
|
||||
label="Our Approach"
|
||||
label={t('labels.approach')}
|
||||
index="02"
|
||||
heading="How we thought about it"
|
||||
body={project.approach}
|
||||
heading={t('labels.approachHeading')}
|
||||
body={approach}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
@@ -220,10 +282,10 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
</ScrollReveal>
|
||||
|
||||
<ContentSection
|
||||
label="The Outcome"
|
||||
label={t('labels.outcome')}
|
||||
index="03"
|
||||
heading="What we delivered"
|
||||
body={project.outcome}
|
||||
heading={t('labels.outcomeHeading')}
|
||||
body={outcome}
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -237,7 +299,7 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<ScrollReveal variant="fadeUp" className="flex flex-col gap-5">
|
||||
<p className="label-md text-outline">Built with</p>
|
||||
<p className="label-md text-outline">{t('labels.builtWith')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.techStack.map((tech) => (
|
||||
<span
|
||||
@@ -267,20 +329,19 @@ export default async function CaseStudyPage({ params }: Props) {
|
||||
<div className="absolute -top-3 -right-3" aria-hidden="true">
|
||||
<CornerBracket size={24} position="top-right" color="var(--color-teal)" />
|
||||
</div>
|
||||
<p className="label-md text-primary px-4">Next Step</p>
|
||||
<p className="label-md text-primary px-4">{t('labels.yourTurn')}</p>
|
||||
</div>
|
||||
|
||||
<h2 className="font-serif font-semibold text-on-surface text-3xl md:text-4xl leading-[1.1] tracking-[-0.02em]">
|
||||
Ready to build your own landmark?
|
||||
{t('labels.ctaTitle')}
|
||||
</h2>
|
||||
|
||||
<p className="text-outline text-lg leading-relaxed max-w-xl">
|
||||
Every project we take on is a collaboration built on trust, precision, and a shared
|
||||
belief that great digital work is never accidental.
|
||||
{t('labels.ctaSubtitle')}
|
||||
</p>
|
||||
|
||||
<Button variant="primary" size="lg" arrow href="/#configure">
|
||||
Start your project
|
||||
{t('labels.ctaButton')}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
53
src/app/(frontend)/api/analyze-site/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { analyzeSite, type SiteAnalysis } from '@/lib/site-analysis';
|
||||
|
||||
// ─── Summary Builder ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildAnalysisSummary(a: SiteAnalysis): string {
|
||||
if (a.fetchError) return "I wasn't able to reach that site to analyze it.";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (a.techStack?.cms) parts.push(`it's built on ${a.techStack.cms}`);
|
||||
if (a.techStack?.framework) parts.push(`using ${a.techStack.framework}`);
|
||||
if (a.techStack?.ecommerce) parts.push(`with ${a.techStack.ecommerce} for e-commerce`);
|
||||
|
||||
if (a.performance) {
|
||||
const score = a.performance.score;
|
||||
const quality = score >= 90 ? 'excellent' : score >= 50 ? 'moderate' : 'low';
|
||||
parts.push(`the mobile performance score is ${score} out of 100, which is ${quality}`);
|
||||
}
|
||||
|
||||
if (a.techStack?.hosting) parts.push(`hosted on ${a.techStack.hosting}`);
|
||||
if (a.hasForms) parts.push('it has contact forms');
|
||||
|
||||
if (a.techStack?.analytics && a.techStack.analytics.length > 0) {
|
||||
parts.push(`using ${a.techStack.analytics.join(' and ')} for analytics`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return "I was able to fetch the site but couldn't determine much about its technology stack.";
|
||||
}
|
||||
|
||||
return `Here's what I found: ${parts.join(', ')}.`;
|
||||
}
|
||||
|
||||
// ─── Route Handler ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url } = (await request.json()) as { url?: string };
|
||||
|
||||
if (!url?.trim()) {
|
||||
return NextResponse.json({ success: false, error: 'URL required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const analysis = await analyzeSite(url.trim());
|
||||
const summary = buildAnalysisSummary(analysis);
|
||||
|
||||
return NextResponse.json({ success: true, summary, analysis });
|
||||
} catch (error) {
|
||||
console.error('[analyze-site] Failed:', error);
|
||||
return NextResponse.json({ success: false, error: 'Analysis failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sendBriefToClient, sendLeadNotification } from '@/lib/email';
|
||||
import { analyzeSite, type SiteAnalysis } from '@/lib/site-analysis';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +14,11 @@ interface ConfigureRequestBody {
|
||||
name: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
contactPreference: string;
|
||||
currentSiteUrl?: string;
|
||||
currentSiteThoughts?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// ─── Formatting helpers ──────────────────────────────────────────────────────
|
||||
@@ -47,7 +53,7 @@ const AI_TYPE_NAMES: Record<string, string> = {
|
||||
notsure: 'AI Integration (approach TBD)',
|
||||
};
|
||||
|
||||
function buildContext(body: ConfigureRequestBody): string {
|
||||
function buildContext(body: ConfigureRequestBody, siteAnalysis: SiteAnalysis | null = null): string {
|
||||
const services = body.services.map((s) => SERVICE_NAMES[s] ?? s).join(', ');
|
||||
const industry = body.industry ? INDUSTRY_NAMES[body.industry] ?? body.industry : 'Not specified';
|
||||
const timeline = body.timeline ? TIMELINE_NAMES[body.timeline] ?? body.timeline : 'Not specified';
|
||||
@@ -66,25 +72,67 @@ Timeline: ${timeline}`;
|
||||
context += `\nAI Integration: Yes — ${aiTypeNames ?? 'type to be determined'}`;
|
||||
}
|
||||
|
||||
if (body.phone?.trim()) {
|
||||
context += `\nPhone: ${body.phone.trim()}`;
|
||||
}
|
||||
|
||||
if (body.contactPreference?.trim()) {
|
||||
context += `\nPreferred Contact Method: ${body.contactPreference.trim()}`;
|
||||
}
|
||||
|
||||
if (body.scope.trim()) {
|
||||
context += `\nClient's Goals: "${body.scope.trim()}"`;
|
||||
}
|
||||
|
||||
if (body.currentSiteUrl?.trim()) {
|
||||
context += `\nCurrent Website: ${body.currentSiteUrl.trim()}`;
|
||||
}
|
||||
if (body.currentSiteThoughts?.trim()) {
|
||||
context += `\nClient's Thoughts on Current Site: "${body.currentSiteThoughts.trim()}"`;
|
||||
}
|
||||
|
||||
if (siteAnalysis && !siteAnalysis.fetchError) {
|
||||
context += '\n\n--- Current Website Analysis ---';
|
||||
if (siteAnalysis.techStack) {
|
||||
const { cms, framework, ecommerce, analytics, hosting } = siteAnalysis.techStack;
|
||||
if (cms) context += `\nCMS: ${cms}`;
|
||||
if (framework) context += `\nFront-End Framework: ${framework}`;
|
||||
if (ecommerce) context += `\nE-Commerce: ${ecommerce}`;
|
||||
if (analytics.length > 0) context += `\nAnalytics: ${analytics.join(', ')}`;
|
||||
if (hosting) context += `\nHosting: ${hosting}`;
|
||||
}
|
||||
if (siteAnalysis.performance) {
|
||||
const p = siteAnalysis.performance;
|
||||
context += `\nPerformance Score (mobile): ${p.score}/100`;
|
||||
context += `\nCore Web Vitals — FCP: ${Math.round(p.fcp)}ms, LCP: ${Math.round(p.lcp)}ms, CLS: ${p.cls.toFixed(2)}, TBT: ${Math.round(p.tbt)}ms`;
|
||||
}
|
||||
if (siteAnalysis.title) context += `\nSite Title: ${siteAnalysis.title}`;
|
||||
if (siteAnalysis.description) context += `\nMeta Description: ${siteAnalysis.description}`;
|
||||
if (siteAnalysis.primaryColors.length > 0) context += `\nBrand Colors: ${siteAnalysis.primaryColors.join(', ')}`;
|
||||
if (siteAnalysis.hasForms) context += '\nHas Contact/Lead Forms: Yes';
|
||||
} else if (siteAnalysis?.fetchError) {
|
||||
context += `\nNote: Attempted to analyze ${body.currentSiteUrl} but it was unreachable.`;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// ─── AI Brief Generation ─────────────────────────────────────────────────────
|
||||
|
||||
async function generateBriefWithAI(body: ConfigureRequestBody): Promise<string> {
|
||||
async function generateBriefWithAI(body: ConfigureRequestBody, siteAnalysis: SiteAnalysis | null = null): Promise<string> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log('[configure] OPENROUTER_API_KEY not set, using fallback brief template');
|
||||
return generateFallbackBrief(body);
|
||||
}
|
||||
|
||||
const context = buildContext(body);
|
||||
console.log('[configure] Generating AI brief via OpenRouter (deepseek/deepseek-v3.2)...');
|
||||
|
||||
const context = buildContext(body, siteAnalysis);
|
||||
console.log('[configure] AI context:\n', context);
|
||||
const displayName = body.name.split(' ')[0] || body.name;
|
||||
|
||||
const systemPrompt = `You are writing a project brief on behalf of LetsBe Solutions, a digital studio that builds custom websites, custom software, and private digital infrastructure. The company is American-founded and serves businesses on the Côte d'Azur and internationally.
|
||||
const systemPrompt = `You are writing a project brief on behalf of LetsBe Solutions, a digital studio that builds custom websites, custom software, and private digital infrastructure. The company is American-founded and serves clients internationally.
|
||||
|
||||
Key facts about LetsBe:
|
||||
- Every project is designed and coded from scratch — no templates, no page builders
|
||||
@@ -94,18 +142,38 @@ Key facts about LetsBe:
|
||||
- Small, experienced team with decades of combined experience in design and engineering
|
||||
- They emphasize data ownership, privacy, and digital sovereignty
|
||||
|
||||
Write in a professional but warm tone. Be specific and practical — no empty buzzwords. The brief should feel like it was written by someone who understood the client's needs, not a generic template.`;
|
||||
Write in a professional but warm tone. Be specific and practical — no empty buzzwords. The brief should feel like it was written by someone who understood the client's needs, not a generic template.
|
||||
|
||||
Structure the brief for easy scanning — use short paragraphs, bullet points where appropriate, and clear section headings. Avoid walls of text.
|
||||
|
||||
Always reference a 30-minute introductory call (not 60 minutes or 1 hour) when mentioning next steps.
|
||||
|
||||
When site analysis data is provided in the context, you MUST include a dedicated **Current Website Analysis** section near the top of the brief (after the introduction, before the proposed solution). This section should:
|
||||
- State what technology the site currently runs on (CMS, framework, hosting)
|
||||
- If performance data is available, cite the exact score and what it means practically
|
||||
- Note any strengths or weaknesses observable from the data (e.g., has forms, missing meta description, no analytics)
|
||||
- If the client shared thoughts about their current site, acknowledge those specifically
|
||||
- Explain how the proposed solution addresses each issue found
|
||||
This section demonstrates that LetsBe has already begun analyzing the client's situation before the first call. Never invent data not present in the context — only reference what the analysis actually returned.`;
|
||||
|
||||
const langInstructions: Record<string, string> = {
|
||||
fr: '\n\nIMPORTANT: Write the entire brief in French. All headings, body text, and next steps must be in French.',
|
||||
it: '\n\nIMPORTANT: Write the entire brief in Italian. All headings, body text, and next steps must be in Italian.',
|
||||
es: '\n\nIMPORTANT: Write the entire brief in Spanish. All headings, body text, and next steps must be in Spanish.',
|
||||
};
|
||||
const langInstruction = langInstructions[body.locale ?? ''] ?? '';
|
||||
|
||||
const userPrompt = `Generate a personalized project brief for the following prospect. The brief should:
|
||||
1. Address the client by first name (${displayName})
|
||||
2. Acknowledge their specific industry and goals
|
||||
3. For each service they selected, describe concretely what LetsBe would build and why it matters for their business
|
||||
4. If AI integration is requested, explain practically what that would look like
|
||||
5. Propose a clear engagement approach (discovery → strategy → build → launch)
|
||||
6. Include a timeline note based on their preference
|
||||
7. End with clear next steps
|
||||
3. For each service they selected, describe concretely what LetsBe would build. Include 2-3 specific, practical benefits the client would gain (e.g., reduced costs, time saved, better guest experience, competitive advantage).
|
||||
4. Weave in deep industry context — demonstrate understanding of the client's sector, its challenges, and how the proposed solution addresses real pain points in that industry.
|
||||
5. If AI integration is requested, explain practically what that would look like
|
||||
6. Propose a clear engagement approach (discovery → strategy → build → launch). Keep each phase to 1-2 sentences maximum.
|
||||
7. Include a timeline note based on their preference
|
||||
8. End with a clear next step: book a free 30-minute introductory call to discuss the brief.
|
||||
|
||||
Format the brief using **bold** for section headings and --- for separators. Keep it concise but substantive — around 400-600 words.
|
||||
Format the brief using **bold** for section headings and --- for separators. Keep it concise but substantive — around 350-500 words.
|
||||
|
||||
Client details:
|
||||
${context}`;
|
||||
@@ -122,7 +190,7 @@ ${context}`;
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek/deepseek-v3.2',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'system', content: systemPrompt + langInstruction },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: 1500,
|
||||
@@ -131,10 +199,12 @@ ${context}`;
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('OpenRouter API error:', response.status, response.statusText);
|
||||
console.error(`[configure] OpenRouter API error: ${response.status} ${response.statusText}`);
|
||||
return generateFallbackBrief(body);
|
||||
}
|
||||
|
||||
console.log('[configure] AI brief generated successfully');
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
|
||||
@@ -144,51 +214,298 @@ ${context}`;
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error('AI brief generation failed:', error);
|
||||
console.error('[configure] AI brief generation failed:', error);
|
||||
return generateFallbackBrief(body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ─── Fallback Brief (no API key or API failure) ──────────────────────────────
|
||||
|
||||
function generateFallbackBrief(body: ConfigureRequestBody): string {
|
||||
const { services, aiEnabled, aiTypes, industry, scope, timeline, name, company } = body;
|
||||
const locale = body.locale ?? 'en';
|
||||
|
||||
const SERVICE_NAMES_FR: Record<string, string> = {
|
||||
web: 'Design & Développement Web',
|
||||
systems: 'Logiciels Sur Mesure',
|
||||
infrastructure: 'Infrastructure Privée',
|
||||
};
|
||||
|
||||
const INDUSTRY_NAMES_FR: Record<string, string> = {
|
||||
maritime: 'Maritime & Yachting',
|
||||
hospitality: 'Hôtellerie',
|
||||
technology: 'Technologie',
|
||||
realestate: 'Immobilier',
|
||||
finance: 'Finance',
|
||||
ngo: 'ONG & Associatif',
|
||||
other: 'Autre',
|
||||
};
|
||||
|
||||
const TIMELINE_NAMES_FR: Record<string, string> = {
|
||||
asap: 'dès que possible',
|
||||
'1-3months': '1–3 mois',
|
||||
'3-6months': '3–6 mois',
|
||||
exploring: 'en phase d\'exploration',
|
||||
};
|
||||
|
||||
const SERVICE_NAMES_IT: Record<string, string> = {
|
||||
web: 'Web Design & Sviluppo',
|
||||
systems: 'Software Su Misura',
|
||||
infrastructure: 'Infrastruttura Privata',
|
||||
};
|
||||
|
||||
const INDUSTRY_NAMES_IT: Record<string, string> = {
|
||||
maritime: 'Marittimo & Nautica', hospitality: 'Ospitalità', technology: 'Tecnologia',
|
||||
realestate: 'Immobiliare', finance: 'Finanza', ngo: 'ONG & No-Profit', other: 'Altro',
|
||||
};
|
||||
|
||||
const TIMELINE_NAMES_IT: Record<string, string> = {
|
||||
asap: 'il prima possibile', '1-3months': '1–3 mesi', '3-6months': '3–6 mesi', exploring: 'in fase di esplorazione',
|
||||
};
|
||||
|
||||
const SERVICE_NAMES_ES: Record<string, string> = {
|
||||
web: 'Diseño & Desarrollo Web',
|
||||
systems: 'Software a Medida',
|
||||
infrastructure: 'Infraestructura Privada',
|
||||
};
|
||||
|
||||
const INDUSTRY_NAMES_ES: Record<string, string> = {
|
||||
maritime: 'Marítimo & Náutico', hospitality: 'Hostelería', technology: 'Tecnología',
|
||||
realestate: 'Inmobiliario', finance: 'Finanzas', ngo: 'ONG & Sin Ánimo de Lucro', other: 'Otro',
|
||||
};
|
||||
|
||||
const TIMELINE_NAMES_ES: Record<string, string> = {
|
||||
asap: 'lo antes posible', '1-3months': '1–3 meses', '3-6months': '3–6 meses', exploring: 'en fase de exploración',
|
||||
};
|
||||
|
||||
const svcMap: Record<string, Record<string, string>> = { fr: SERVICE_NAMES_FR, it: SERVICE_NAMES_IT, es: SERVICE_NAMES_ES };
|
||||
const indMap: Record<string, Record<string, string>> = { fr: INDUSTRY_NAMES_FR, it: INDUSTRY_NAMES_IT, es: INDUSTRY_NAMES_ES };
|
||||
const tlMap: Record<string, Record<string, string>> = { fr: TIMELINE_NAMES_FR, it: TIMELINE_NAMES_IT, es: TIMELINE_NAMES_ES };
|
||||
const svcNames = svcMap[locale] ?? SERVICE_NAMES;
|
||||
const indNames = indMap[locale] ?? INDUSTRY_NAMES;
|
||||
const tlNames = tlMap[locale] ?? TIMELINE_NAMES;
|
||||
|
||||
const serviceNames = services.map((s) => svcNames[s] ?? s);
|
||||
|
||||
const joiners: Record<string, { and: string; commaAnd: string }> = {
|
||||
fr: { and: ' et ', commaAnd: ' et ' },
|
||||
it: { and: ' e ', commaAnd: ' e ' },
|
||||
es: { and: ' y ', commaAnd: ' y ' },
|
||||
};
|
||||
const j = joiners[locale] ?? { and: ' and ', commaAnd: ', and ' };
|
||||
|
||||
const serviceNames = services.map((s) => SERVICE_NAMES[s] ?? s);
|
||||
const servicesList = serviceNames.length <= 2
|
||||
? serviceNames.join(' and ')
|
||||
: `${serviceNames.slice(0, -1).join(', ')}, and ${serviceNames[serviceNames.length - 1]}`;
|
||||
const industryLabel = industry ? INDUSTRY_NAMES[industry] ?? industry : 'your industry';
|
||||
const displayCompany = company.trim() || 'your organization';
|
||||
const displayName = name.split(' ')[0] || 'there';
|
||||
? serviceNames.join(j.and)
|
||||
: `${serviceNames.slice(0, -1).join(', ')}${j.commaAnd}${serviceNames[serviceNames.length - 1]}`;
|
||||
|
||||
const industryFallbacks: Record<string, string> = { fr: 'votre secteur', it: 'il tuo settore', es: 'tu sector' };
|
||||
const companyFallbacks: Record<string, string> = { fr: 'votre organisation', it: 'la tua organizzazione', es: 'tu organización' };
|
||||
const nameFallbacks: Record<string, string> = { fr: 'bonjour', it: 'ciao', es: 'hola' };
|
||||
|
||||
const industryLabel = industry ? indNames[industry] ?? industry : (industryFallbacks[locale] ?? 'your industry');
|
||||
const displayCompany = company.trim() || (companyFallbacks[locale] ?? 'your organization');
|
||||
const displayName = name.split(' ')[0] || (nameFallbacks[locale] ?? 'there');
|
||||
|
||||
const timelineFallbacks: Record<string, string> = {
|
||||
fr: 'un calendrier à convenir', it: 'un calendario da definire', es: 'un calendario a convenir',
|
||||
};
|
||||
const timelineStr = timeline
|
||||
? TIMELINE_NAMES[timeline]?.toLowerCase() ?? 'a timeline to be agreed upon'
|
||||
: 'a timeline to be agreed upon';
|
||||
? tlNames[timeline]?.toLowerCase() ?? (timelineFallbacks[locale] ?? 'a timeline to be agreed upon')
|
||||
: (timelineFallbacks[locale] ?? 'a timeline to be agreed upon');
|
||||
|
||||
const hasWeb = services.includes('web');
|
||||
const hasSystems = services.includes('systems');
|
||||
const hasInfra = services.includes('infrastructure');
|
||||
|
||||
let sections = '';
|
||||
// Build sections per locale
|
||||
const sectionTemplates: Record<string, () => string> = {
|
||||
fr: () => {
|
||||
let s = '';
|
||||
if (hasWeb) {
|
||||
s += `\n**Design & Développement Web**\nNous concevrons et développerons un site web sur mesure pour ${displayCompany} — sans templates, sans constructeurs de pages. Moderne, responsive, rapide et optimisé pour le référencement dès le premier jour.\n`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
s += `\n**Logiciels Sur Mesure**\nNous développerons un système conçu pour correspondre exactement au fonctionnement de ${displayCompany} — modèle de données personnalisé, accès par rôles et intégrations avec vos outils existants.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
s += `\n**Infrastructure Privée**\nNous mettrons en place un environnement serveur dédié pour ${displayCompany} avec email, stockage cloud et outils métier que vous possédez et contrôlez entièrement.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
s += `\n**Intégration IA**\nNous intégrerons ${aiLabels.toLowerCase()} dans vos systèmes — en profondeur, pas en surface. L'approche exacte sera définie lors de la phase de découverte.\n`;
|
||||
} else if (aiEnabled) {
|
||||
s += `\n**Intégration IA**\nNous intégrerons l'IA dans vos systèmes — en profondeur, pas en surface. L'approche exacte sera définie lors de la phase de découverte.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
s += `\n**Vos Objectifs**\nVous avez partagé : "${scope.trim()}" — nous orienterons nos sessions de découverte autour de ces priorités.\n`;
|
||||
}
|
||||
return s;
|
||||
},
|
||||
it: () => {
|
||||
let s = '';
|
||||
if (hasWeb) {
|
||||
s += `\n**Web Design & Sviluppo**\nProgetteremo e svilupperemo un sito web su misura per ${displayCompany} da zero — nessun template, nessun page builder. Moderno, responsive, veloce e ottimizzato per i motori di ricerca fin dal primo giorno.\n`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
s += `\n**Software Su Misura**\nRealizzaremo un sistema progettato su misura per come opera ${displayCompany} — modello dati personalizzato, accesso basato sui ruoli e integrazioni con i tuoi strumenti esistenti.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
s += `\n**Infrastruttura Privata**\nConfigureremo un ambiente server dedicato per ${displayCompany} con email, cloud storage e strumenti aziendali che possiedi e controlli interamente.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
s += `\n**Integrazione IA**\nIntegreremo ${aiLabels.toLowerCase()} nei tuoi sistemi — in profondità, non in superficie. L'approccio esatto sarà definito durante la fase di scoperta.\n`;
|
||||
} else if (aiEnabled) {
|
||||
s += `\n**Integrazione IA**\nIntegreremo l'IA nei tuoi sistemi — in profondità, non in superficie. L'approccio esatto sarà definito durante la fase di scoperta.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
s += `\n**I Tuoi Obiettivi**\nHai condiviso: "${scope.trim()}" — struttureremo le nostre sessioni di scoperta attorno a queste priorità.\n`;
|
||||
}
|
||||
return s;
|
||||
},
|
||||
es: () => {
|
||||
let s = '';
|
||||
if (hasWeb) {
|
||||
s += `\n**Diseño & Desarrollo Web**\nDiseñaremos y desarrollaremos un sitio web a medida para ${displayCompany} desde cero — sin plantillas, sin constructores de páginas. Moderno, responsive, rápido y optimizado para motores de búsqueda desde el primer día.\n`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
s += `\n**Software a Medida**\nDesarrollaremos un sistema diseñado específicamente para cómo opera ${displayCompany} — modelo de datos personalizado, acceso basado en roles e integraciones con tus herramientas existentes.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
s += `\n**Infraestructura Privada**\nConfiguraremos un entorno de servidor dedicado para ${displayCompany} con correo electrónico, almacenamiento en la nube y herramientas empresariales que posees y controlas completamente.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
s += `\n**Integración IA**\nIntegraremos ${aiLabels.toLowerCase()} en tus sistemas — de forma profunda, no superficial. El enfoque exacto se definirá durante la fase de descubrimiento.\n`;
|
||||
} else if (aiEnabled) {
|
||||
s += `\n**Integración IA**\nIntegraremos la IA en tus sistemas — de forma profunda, no superficial. El enfoque exacto se definirá durante la fase de descubrimiento.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
s += `\n**Tus Objetivos**\nCompartiste: "${scope.trim()}" — orientaremos nuestras sesiones de descubrimiento en torno a estas prioridades.\n`;
|
||||
}
|
||||
return s;
|
||||
},
|
||||
en: () => {
|
||||
let s = '';
|
||||
if (hasWeb) {
|
||||
s += `\n**Web Design & Development**\nWe'll design and build a custom website for ${displayCompany} from scratch — no templates, no page builders. Modern, responsive, fast, and optimized for search engines from day one.\n`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
s += `\n**Custom Software**\nWe'll build a purpose-made system tailored to how ${displayCompany} actually operates — custom data model, role-based access, and integrations with your existing tools.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
s += `\n**Private Infrastructure**\nWe'll set up a dedicated server environment for ${displayCompany} with email, cloud storage, and business tools that you fully own and control.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
s += `\n**AI Integration**\nWe'll layer ${aiLabels.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
} else if (aiEnabled) {
|
||||
s += `\n**AI Integration**\nWe'll layer AI integration into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
s += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`;
|
||||
}
|
||||
return s;
|
||||
},
|
||||
};
|
||||
|
||||
if (hasWeb) {
|
||||
sections += `\n**Web Design & Development**\nWe'll design and build a custom website for ${displayCompany} from scratch — no templates, no page builders. Modern, responsive, fast, and optimized for search engines from day one.\n`;
|
||||
const sections = (sectionTemplates[locale] ?? sectionTemplates['en'])();
|
||||
|
||||
if (locale === 'fr') {
|
||||
return `**Brief Projet pour ${displayCompany}**
|
||||
Préparé pour : ${name}
|
||||
Date : ${new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
|
||||
---
|
||||
|
||||
**Aperçu**
|
||||
|
||||
Bonjour ${displayName}, suite à votre intérêt pour ${servicesList} dans le secteur ${industryLabel}, voici un brief préliminaire pour guider notre première conversation.
|
||||
|
||||
Nous aborderons ceci comme un projet unifié — chaque composant fonctionnant ensemble, entièrement détenu et contrôlé par vous.
|
||||
${sections}
|
||||
**Notre Approche**
|
||||
|
||||
Nous commençons par une phase de Découverte (2–3 sessions) pour comprendre vos besoins avant d'écrire la moindre ligne de code.
|
||||
|
||||
**Calendrier**
|
||||
|
||||
Livraison cible : ${timelineStr}. Une feuille de route détaillée suivra la phase de Découverte.
|
||||
|
||||
**Prochaines Étapes**
|
||||
|
||||
1. Réservez un appel de présentation de 30 minutes
|
||||
2. Nous vous enverrons un document de cadrage détaillé sous 48 heures
|
||||
3. La Découverte commence — sans engagement
|
||||
|
||||
Au plaisir de construire quelque chose de formidable ensemble.
|
||||
|
||||
— L'équipe LetsBe`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
sections += `\n**Custom Software**\nWe'll build a purpose-made system tailored to how ${displayCompany} actually operates — custom data model, role-based access, and integrations with your existing tools.\n`;
|
||||
|
||||
if (locale === 'it') {
|
||||
return `**Brief Progetto per ${displayCompany}**
|
||||
Preparato per: ${name}
|
||||
Data: ${new Date().toLocaleDateString('it-IT', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
|
||||
---
|
||||
|
||||
**Panoramica**
|
||||
|
||||
Ciao ${displayName}, in base al tuo interesse per ${servicesList} nel settore ${industryLabel}, ecco un brief preliminare per guidare la nostra prima conversazione.
|
||||
|
||||
Affronteremo questo come un progetto unificato — ogni componente che lavora insieme, interamente di tua proprietà e sotto il tuo controllo.
|
||||
${sections}
|
||||
**Il Nostro Approccio**
|
||||
|
||||
Iniziamo con una fase di Scoperta (2–3 sessioni) per comprendere le tue esigenze prima di scrivere una sola riga di codice.
|
||||
|
||||
**Tempistiche**
|
||||
|
||||
Consegna prevista: ${timelineStr}. Una roadmap dettagliata seguirà la fase di Scoperta.
|
||||
|
||||
**Prossimi Passi**
|
||||
|
||||
1. Prenota una chiamata introduttiva gratuita di 30 minuti
|
||||
2. Ti invieremo un documento di scoping dettagliato entro 48 ore
|
||||
3. La Scoperta inizia — senza impegno
|
||||
|
||||
Non vediamo l'ora di costruire qualcosa di straordinario insieme.
|
||||
|
||||
— Il Team LetsBe`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
sections += `\n**Private Infrastructure**\nWe'll set up a dedicated server environment for ${displayCompany} with email, cloud storage, and business tools that you fully own and control.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
sections += `\n**AI Integration**\nWe'll layer ${aiLabels.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
} else if (aiEnabled) {
|
||||
sections += `\n**AI Integration**\nWe'll layer AI integration into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`;
|
||||
|
||||
if (locale === 'es') {
|
||||
return `**Brief de Proyecto para ${displayCompany}**
|
||||
Preparado para: ${name}
|
||||
Fecha: ${new Date().toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
|
||||
---
|
||||
|
||||
**Resumen**
|
||||
|
||||
Hola ${displayName}, basándonos en tu interés en ${servicesList} para el sector ${industryLabel}, aquí tienes un brief preliminar para guiar nuestra primera conversación.
|
||||
|
||||
Abordaremos esto como un proyecto unificado — cada componente trabajando en conjunto, totalmente de tu propiedad y bajo tu control.
|
||||
${sections}
|
||||
**Nuestro Enfoque**
|
||||
|
||||
Comenzamos con una fase de Descubrimiento (2–3 sesiones) para comprender tus requisitos antes de escribir cualquier línea de código.
|
||||
|
||||
**Plazo**
|
||||
|
||||
Entrega objetivo: ${timelineStr}. Una hoja de ruta detallada seguirá a la fase de Descubrimiento.
|
||||
|
||||
**Próximos Pasos**
|
||||
|
||||
1. Reserva una llamada introductoria gratuita de 30 minutos
|
||||
2. Te enviaremos un documento de alcance detallado en 48 horas
|
||||
3. El Descubrimiento comienza — sin compromiso
|
||||
|
||||
Con ganas de construir algo extraordinario juntos.
|
||||
|
||||
— El Equipo LetsBe`;
|
||||
}
|
||||
|
||||
return `**Project Brief for ${displayCompany}**
|
||||
@@ -250,11 +567,28 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Analyze current website if URL provided
|
||||
let siteAnalysis: SiteAnalysis | null = null;
|
||||
if (body.currentSiteUrl?.trim()) {
|
||||
console.log(`[configure] Analyzing site: ${body.currentSiteUrl.trim()}...`);
|
||||
siteAnalysis = await analyzeSite(body.currentSiteUrl.trim());
|
||||
console.log(`[configure] Site analysis complete (fetchError: ${siteAnalysis.fetchError ?? 'none'})`);
|
||||
console.log(`[configure] Tech stack:`, JSON.stringify(siteAnalysis.techStack));
|
||||
console.log(`[configure] Performance:`, JSON.stringify(siteAnalysis.performance));
|
||||
console.log(`[configure] Colors:`, siteAnalysis.primaryColors);
|
||||
console.log(`[configure] Title:`, siteAnalysis.title);
|
||||
}
|
||||
|
||||
// Generate the brief (AI if available, fallback otherwise)
|
||||
const brief = await generateBriefWithAI(body);
|
||||
const brief = await generateBriefWithAI(body, siteAnalysis);
|
||||
|
||||
// Send emails (non-blocking — don't fail the response if email fails)
|
||||
if (process.env.SMTP_HOST && process.env.SMTP_PASS) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
|
||||
if (smtpHost && smtpPass) {
|
||||
console.log(`[configure] SMTP configured (host: ${smtpHost}), sending emails to ${body.email} and ${process.env.ADMIN_EMAIL || 'hello@letsbe.biz'}...`);
|
||||
|
||||
Promise.allSettled([
|
||||
sendBriefToClient({
|
||||
to: body.email,
|
||||
@@ -269,10 +603,21 @@ export async function POST(request: NextRequest) {
|
||||
brief,
|
||||
services: body.services,
|
||||
email: body.email,
|
||||
phone: body.phone || undefined,
|
||||
contactPreference: body.contactPreference || undefined,
|
||||
}),
|
||||
]).catch(() => {
|
||||
console.error('Email sending failed');
|
||||
]).then((results) => {
|
||||
results.forEach((result, i) => {
|
||||
const target = i === 0 ? 'client brief' : 'admin notification';
|
||||
if (result.status === 'fulfilled') {
|
||||
console.log(`[configure] Email sent successfully: ${target}`);
|
||||
} else {
|
||||
console.error(`[configure] Email failed: ${target}`, result.reason);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log(`[configure] SMTP not configured (SMTP_HOST: ${smtpHost ? 'set' : 'missing'}, SMTP_PASS: ${smtpPass ? 'set' : 'missing'}), skipping emails`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, brief });
|
||||
|
||||
51
src/app/(frontend)/api/gemini-token/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateEphemeralToken } from '@/lib/gemini-live';
|
||||
|
||||
// ─── Rate Limiting ────────────────────────────────────────────────────────────
|
||||
|
||||
const rateLimitMap = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 5_000; // 1 token per 5 seconds per IP
|
||||
|
||||
// ─── Health Check (GET — no rate limit) ──────────────────────────────────────
|
||||
|
||||
export async function GET() {
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
return NextResponse.json({ success: false }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// ─── Token Request (POST — rate limited) ─────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
return NextResponse.json({ success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'unknown';
|
||||
|
||||
const lastRequest = rateLimitMap.get(ip) ?? 0;
|
||||
if (Date.now() - lastRequest < RATE_LIMIT_MS) {
|
||||
return NextResponse.json({ success: false, error: 'Rate limited' }, { status: 429 });
|
||||
}
|
||||
rateLimitMap.set(ip, Date.now());
|
||||
|
||||
const { locale } = (await request.json()) as { locale?: string };
|
||||
const supportedLocales = ['en', 'fr', 'it', 'es'];
|
||||
const result = generateEphemeralToken(supportedLocales.includes(locale ?? '') ? locale! : 'en');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
apiKey: process.env.GEMINI_API_KEY,
|
||||
model: result.model,
|
||||
config: result.config,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[gemini-token] Failed:', error);
|
||||
return NextResponse.json({ success: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
BIN
src/app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
14
src/app/robots.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://letsbe.biz/sitemap.xml',
|
||||
}
|
||||
}
|
||||
44
src/app/sitemap.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
const BASE_URL = 'https://letsbe.biz'
|
||||
|
||||
const STATIC_ROUTES = ['', '/about', '/services']
|
||||
const PROJECT_SLUGS = ['monaco-ocean', 'port-nimara', 'port-amador']
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const now = new Date()
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({
|
||||
url: `${BASE_URL}${route}`,
|
||||
lastModified: now,
|
||||
changeFrequency: route === '' ? 'weekly' : 'monthly',
|
||||
priority: route === '' ? 1.0 : 0.8,
|
||||
alternates: {
|
||||
languages: {
|
||||
en: `${BASE_URL}${route}`,
|
||||
fr: `${BASE_URL}/fr${route}`,
|
||||
it: `${BASE_URL}/it${route}`,
|
||||
es: `${BASE_URL}/es${route}`,
|
||||
'x-default': `${BASE_URL}${route}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const projectEntries: MetadataRoute.Sitemap = PROJECT_SLUGS.map((slug) => ({
|
||||
url: `${BASE_URL}/work/${slug}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
en: `${BASE_URL}/work/${slug}`,
|
||||
fr: `${BASE_URL}/fr/work/${slug}`,
|
||||
it: `${BASE_URL}/it/work/${slug}`,
|
||||
es: `${BASE_URL}/es/work/${slug}`,
|
||||
'x-default': `${BASE_URL}/work/${slug}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return [...staticEntries, ...projectEntries]
|
||||
}
|
||||
82
src/components/analytics/CookieConsent.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const CONSENT_KEY = 'cookie_consent'
|
||||
|
||||
type ConsentState = 'granted' | 'denied'
|
||||
|
||||
function updateConsent(state: ConsentState) {
|
||||
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
|
||||
window.gtag('consent', 'update', {
|
||||
analytics_storage: state,
|
||||
ad_storage: state,
|
||||
ad_user_data: state,
|
||||
ad_personalization: state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function CookieConsent() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(CONSENT_KEY)
|
||||
if (stored === 'granted' || stored === 'denied') {
|
||||
// Restore previous choice
|
||||
updateConsent(stored)
|
||||
} else {
|
||||
// No choice yet — show banner
|
||||
setVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem(CONSENT_KEY, 'granted')
|
||||
updateConsent('granted')
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem(CONSENT_KEY, 'denied')
|
||||
updateConsent('denied')
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Cookie consent"
|
||||
style={{ animation: 'cookie-slide-up 0.5s ease-out' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-6"
|
||||
>
|
||||
<div className="max-w-xl mx-auto rounded-2xl bg-surface-high border border-outline-variant/20 shadow-[0_-4px_32px_rgba(25,28,29,0.12)] p-5 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<p className="text-sm text-outline leading-relaxed flex-1">
|
||||
We use cookies to understand how visitors use our site.
|
||||
No personal data is shared with third parties.
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecline}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium text-outline transition-colors hover:bg-on-surface/5 cursor-pointer"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
className="px-5 py-2 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0 cursor-pointer shadow-[0_4px_16px_rgba(0,100,148,0.25)]"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
src/components/analytics/GoogleAnalytics.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Script from 'next/script'
|
||||
|
||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (!GA_ID || process.env.NODE_ENV !== 'production') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Consent Mode v2 — region-specific defaults */}
|
||||
<Script id="gtag-consent" strategy="beforeInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
|
||||
// Default: granted for regions without consent requirements
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'granted',
|
||||
ad_storage: 'granted',
|
||||
ad_user_data: 'granted',
|
||||
ad_personalization: 'granted',
|
||||
});
|
||||
|
||||
// EEA + UK: denied until user consents (GDPR)
|
||||
gtag('consent', 'default', {
|
||||
analytics_storage: 'denied',
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
wait_for_update: 500,
|
||||
region: ['AT','BE','BG','CY','CZ','DE','DK','EE','ES','FI','FR','GR','HR','HU','IE','IT','LT','LU','LV','MT','NL','PL','PT','RO','SE','SI','SK','IS','LI','NO','GB'],
|
||||
});
|
||||
|
||||
// Improve measurement when consent is denied
|
||||
gtag('set', 'url_passthrough', true);
|
||||
gtag('set', 'ads_data_redaction', true);
|
||||
`}
|
||||
</Script>
|
||||
|
||||
{/* Google tag (gtag.js) */}
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="gtag-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_ID}');
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
currentStep: 1 | 2 | 3;
|
||||
@@ -9,31 +10,68 @@ interface ProgressBarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP_LABELS = ['01', '02', '03'];
|
||||
|
||||
export default function ProgressBar({ currentStep, className }: ProgressBarProps) {
|
||||
return (
|
||||
<div className={cn('flex gap-1.5', className)} role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={3}>
|
||||
{([1, 2, 3] as const).map((step) => {
|
||||
const isActive = step <= currentStep;
|
||||
return (
|
||||
<div
|
||||
key={step}
|
||||
className="relative flex-1 h-1 rounded-full bg-outline-variant/40 overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-r from-primary-dark to-primary"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: isActive ? 1 : 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
delay: isActive ? (step - 1) * 0.05 : 0,
|
||||
}}
|
||||
style={{ transformOrigin: 'left' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
role="progressbar"
|
||||
aria-valuenow={currentStep}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={3}
|
||||
>
|
||||
<div className="flex items-center gap-0 w-full max-w-xs">
|
||||
{([1, 2, 3] as const).map((step) => {
|
||||
const isComplete = step < currentStep;
|
||||
const isActive = step === currentStep;
|
||||
const isUpcoming = step > currentStep;
|
||||
const showFilledLine = step < currentStep;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center flex-1 last:flex-none">
|
||||
{/* Step circle */}
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-7 h-7 rounded-full text-[11px] font-semibold flex-shrink-0 transition-colors duration-300',
|
||||
isComplete && 'bg-primary text-white',
|
||||
isActive && 'bg-primary-dark text-white shadow-[0_0_0_3px_rgba(91,164,217,0.15)]',
|
||||
isUpcoming && 'bg-surface-low text-outline border border-outline-variant/40',
|
||||
)}
|
||||
>
|
||||
{isComplete ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
|
||||
>
|
||||
<Check size={13} strokeWidth={2.5} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<span>{STEP_LABELS[step - 1]}</span>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Connector line — only between steps, fills only when step is complete */}
|
||||
{step < 3 && (
|
||||
<div className="flex-1 h-px mx-2 bg-outline-variant/25 relative overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 right-0 bg-primary"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: showFilledLine ? 1 : 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 25,
|
||||
}}
|
||||
style={{ transformOrigin: 'left' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Mail, RotateCcw } from 'lucide-react';
|
||||
import { Calendar, RotateCcw } from 'lucide-react';
|
||||
import AnimatedCheckmark from '@/components/icons/AnimatedCheckmark';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CalButton from '@/components/ui/CalButton';
|
||||
@@ -96,22 +96,19 @@ export default function StepComplete({ formData, brief, onReset }: StepCompleteP
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col gap-6"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Checkmark + heading */}
|
||||
<motion.div variants={itemVariants} className="flex flex-col items-center text-center pt-2 pb-1">
|
||||
<AnimatedCheckmark size={64} color="#006494" />
|
||||
<motion.div variants={itemVariants} className="flex flex-col items-center text-center pt-1 pb-0">
|
||||
<AnimatedCheckmark size={40} color="#006494" />
|
||||
|
||||
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface mt-4">
|
||||
<h3 className="font-serif text-xl font-semibold tracking-headline text-on-surface mt-2.5">
|
||||
{t('complete.title')}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Mail size={14} strokeWidth={1.5} className="text-primary flex-shrink-0" />
|
||||
<p className="text-sm text-outline">
|
||||
{t('complete.subtitle', { email: displayEmail })}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-outline mt-2">
|
||||
{t('complete.subtitle', { email: displayEmail })}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Brief preview */}
|
||||
@@ -123,31 +120,28 @@ export default function StepComplete({ formData, brief, onReset }: StepCompleteP
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
|
||||
{t('complete.briefPreview')}
|
||||
</p>
|
||||
<div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin">
|
||||
<div className="space-y-1 max-h-[28rem] overflow-y-auto pr-1 scrollbar-thin">
|
||||
{renderBrief(brief)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Booking */}
|
||||
{/* Next step: book a call */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="rounded-xl bg-surface-low px-5 py-5 text-center">
|
||||
<div className="flex justify-center mb-3">
|
||||
<span className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Calendar size={18} strokeWidth={1.5} className="text-primary-dark" />
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-on-surface">
|
||||
{t('complete.nextStep')}
|
||||
</p>
|
||||
<p className="text-xs text-outline mt-0.5">
|
||||
{t('complete.bookSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-on-surface mb-1">
|
||||
{t('complete.bookTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-outline mb-4">
|
||||
{t('complete.bookSubtitle')}
|
||||
</p>
|
||||
<CalButton
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white whitespace-nowrap transition-all hover:-translate-y-px active:translate-y-0 flex-shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
<Calendar size={16} />
|
||||
<Calendar size={14} />
|
||||
{t('complete.bookCall')}
|
||||
</CalButton>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ProgressBar from './ProgressBar';
|
||||
@@ -204,6 +207,68 @@ export default function StepContact({
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
{/* Phone field — optional */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-phone"
|
||||
className="text-xs font-semibold uppercase tracking-label text-outline"
|
||||
>
|
||||
{t('fields.phone')}{' '}
|
||||
<span className="normal-case font-normal">{t('fields.phoneOptional')}</span>
|
||||
</label>
|
||||
<PhoneInput
|
||||
inputProps={{ id: 'contact-phone', autoComplete: 'tel' }}
|
||||
defaultCountry="fr"
|
||||
preferredCountries={['fr', 'us', 'gb', 'mc', 'ch']}
|
||||
forceDialCode={true}
|
||||
value={formData.phone}
|
||||
onChange={(phone) => setFormData((prev) => ({ ...prev, phone }))}
|
||||
style={
|
||||
{
|
||||
'--react-international-phone-height': '44px',
|
||||
'--react-international-phone-border-radius': '12px',
|
||||
'--react-international-phone-border-color': 'rgb(var(--color-outline-variant) / 0.6)',
|
||||
'--react-international-phone-background-color': 'rgb(var(--color-surface-high))',
|
||||
'--react-international-phone-text-color': 'rgb(var(--color-on-surface))',
|
||||
'--react-international-phone-font-size': '14px',
|
||||
'--react-international-phone-selected-dropdown-item-background-color': 'rgb(var(--color-surface-low))',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="w-full [&_.react-international-phone-input]:flex-1 [&_.react-international-phone-input]:rounded-r-xl [&_.react-international-phone-input]:border-outline-variant/60 [&_.react-international-phone-input]:bg-surface-high [&_.react-international-phone-input]:text-on-surface [&_.react-international-phone-input]:placeholder:text-outline/50 [&_.react-international-phone-input]:focus:ring-2 [&_.react-international-phone-input]:focus:ring-primary [&_.react-international-phone-input]:focus:border-primary [&_.react-international-phone-country-selector-button]:rounded-l-xl [&_.react-international-phone-country-selector-button]:border-outline-variant/60 [&_.react-international-phone-country-selector-button]:bg-surface-high"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact preference selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-label text-outline">
|
||||
{t('fields.contactPreference')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{(['email', 'phone', 'whatsapp'] as const).map((method) => {
|
||||
const labelKey = `fields.contact${method.charAt(0).toUpperCase() + method.slice(1)}` as
|
||||
| 'fields.contactEmail'
|
||||
| 'fields.contactPhone'
|
||||
| 'fields.contactWhatsapp';
|
||||
const isActive = formData.contactPreference === method;
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, contactPreference: method }))}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg border text-xs transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary-dark border-primary/30 font-medium'
|
||||
: 'bg-white text-outline border-outline-variant/20 hover:border-outline-variant/40',
|
||||
)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Chip from '@/components/ui/Chip';
|
||||
@@ -100,6 +100,73 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current Website URL */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="current-site-url"
|
||||
className="text-xs font-semibold uppercase tracking-label text-outline"
|
||||
>
|
||||
{t('fields.currentSiteUrl')}
|
||||
<span className="ml-1.5 normal-case font-normal text-outline/70">
|
||||
{t('fields.currentSiteUrlOptional')}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="current-site-url"
|
||||
type="url"
|
||||
value={formData.currentSiteUrl}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, currentSiteUrl: e.target.value }))
|
||||
}
|
||||
placeholder={t('fields.currentSiteUrlPlaceholder')}
|
||||
autoComplete="url"
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-outline-variant/60 bg-surface-high',
|
||||
'px-4 py-3 text-sm text-on-surface placeholder:text-outline/50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'transition-colors duration-200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thoughts on current site (conditional) */}
|
||||
<AnimatePresence>
|
||||
{formData.currentSiteUrl.trim().length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<label
|
||||
htmlFor="current-site-thoughts"
|
||||
className="text-xs font-semibold uppercase tracking-label text-outline"
|
||||
>
|
||||
{t('fields.currentSiteThoughts')}
|
||||
</label>
|
||||
<textarea
|
||||
id="current-site-thoughts"
|
||||
value={formData.currentSiteThoughts}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, currentSiteThoughts: e.target.value }))
|
||||
}
|
||||
placeholder={t('fields.currentSiteThoughtsPlaceholder')}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-high',
|
||||
'px-4 py-3 text-sm text-on-surface placeholder:text-outline/50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'transition-colors duration-200',
|
||||
'leading-relaxed',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-label text-outline">
|
||||
|
||||
120
src/components/configurator/StepGenerating.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GeneratingStep {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface StepGeneratingProps {
|
||||
hasUrl: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StepGenerating({ hasUrl }: StepGeneratingProps) {
|
||||
const t = useTranslations('configurator');
|
||||
|
||||
const steps: GeneratingStep[] = [
|
||||
{ id: 'preparing', labelKey: 'generatingSteps.preparingBrief', durationMs: 1000 },
|
||||
...(hasUrl
|
||||
? [
|
||||
{ id: 'analyzing', labelKey: 'generatingSteps.analyzingSite', durationMs: 3000 },
|
||||
{ id: 'performance', labelKey: 'generatingSteps.runningAudit', durationMs: 5000 },
|
||||
]
|
||||
: []),
|
||||
{ id: 'generating', labelKey: 'generatingSteps.generatingBrief', durationMs: Infinity },
|
||||
];
|
||||
|
||||
const [completedCount, setCompletedCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const step = steps[completedCount];
|
||||
if (!step || step.durationMs === Infinity) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCompletedCount((prev) => Math.min(prev + 1, steps.length - 1));
|
||||
}, step.durationMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [completedCount, steps]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 py-12">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary-dark/80 flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<Loader2 size={28} strokeWidth={1.5} className="text-white" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < completedCount;
|
||||
const isActive = index === completedCount;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{isCompleted ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-5 h-5 rounded-full bg-primary flex items-center justify-center"
|
||||
>
|
||||
<Check size={12} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
) : isActive ? (
|
||||
<motion.div
|
||||
key="spinner"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
className="w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key="pending"
|
||||
className="w-3 h-3 rounded-full bg-outline-variant/30"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={
|
||||
isCompleted
|
||||
? 'text-sm font-medium text-on-surface'
|
||||
: isActive
|
||||
? 'text-sm font-semibold text-primary-dark'
|
||||
: 'text-sm text-outline/40'
|
||||
}
|
||||
>
|
||||
{t(step.labelKey)}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const SERVICES: ServiceOption[] = [
|
||||
|
||||
const AI_TYPE_IDS = ['teammate', 'customer-facing', 'data-intelligence', 'notsure'] as const;
|
||||
|
||||
// ─── Service Card ────────────────────────────────────────────────────────────
|
||||
// ─── Service Card (horizontal layout) ────────────────────────────────────────
|
||||
|
||||
function ServiceCard({
|
||||
option,
|
||||
@@ -35,12 +35,14 @@ function ServiceCard({
|
||||
onToggle,
|
||||
title,
|
||||
description,
|
||||
index,
|
||||
}: {
|
||||
option: ServiceOption;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
index: number;
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
|
||||
@@ -48,130 +50,61 @@ function ServiceCard({
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06, duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className={cn(
|
||||
'group relative w-full text-left rounded-2xl p-5 transition-all duration-200',
|
||||
'group relative flex flex-col items-center text-center rounded-xl px-4 py-5 transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
selected
|
||||
? 'bg-primary/6 border-2 border-primary/30 shadow-card'
|
||||
: 'bg-surface-high border-2 border-transparent shadow-subtle hover:shadow-card hover:border-outline-variant/30',
|
||||
? 'bg-primary/[0.06] border-2 border-primary/40 shadow-[0_4px_16px_rgba(0,100,148,0.1)]'
|
||||
: 'bg-white border-2 border-outline-variant/20 hover:border-outline-variant/40 hover:shadow-[0_2px_12px_rgba(25,28,29,0.06)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon + check badge */}
|
||||
<div className="relative mb-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-colors duration-200',
|
||||
selected
|
||||
? 'bg-primary/15 text-primary-dark'
|
||||
: 'bg-primary/8 text-primary group-hover:bg-primary/12',
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors duration-200',
|
||||
selected ? 'bg-primary/12' : 'bg-surface-low group-hover:bg-primary/6',
|
||||
)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
<Icon
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'text-sm font-semibold leading-tight mb-1 transition-colors duration-200',
|
||||
selected ? 'text-primary-dark' : 'text-on-surface',
|
||||
'transition-colors duration-200',
|
||||
selected ? 'text-primary-dark' : 'text-outline group-hover:text-primary',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-outline leading-relaxed">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<motion.div
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-full border-2 flex items-center justify-center',
|
||||
selected ? 'border-primary bg-primary' : 'border-outline-variant bg-transparent',
|
||||
)}
|
||||
animate={
|
||||
selected
|
||||
? { scale: 1, borderColor: '#5BA4D9', backgroundColor: '#5BA4D9' }
|
||||
: { scale: 1, borderColor: '#c2c7ce', backgroundColor: 'transparent' }
|
||||
}
|
||||
transition={springTransition}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={springTransition}
|
||||
>
|
||||
<Check size={11} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AI Toggle ───────────────────────────────────────────────────────────────
|
||||
|
||||
function AIToggle({
|
||||
enabled,
|
||||
onToggle,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full text-left rounded-xl px-4 py-3',
|
||||
'transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
enabled
|
||||
? 'bg-primary/5 shadow-card'
|
||||
: 'bg-surface-high shadow-subtle hover:shadow-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold transition-colors duration-200 flex items-center gap-1.5',
|
||||
enabled ? 'text-primary-dark' : 'text-on-surface',
|
||||
)}
|
||||
>
|
||||
<Sparkles
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-colors duration-200',
|
||||
enabled ? 'text-primary' : 'text-outline',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
<p className="text-xs text-outline mt-0.5">{description}</p>
|
||||
</div>
|
||||
{/* Check badge — appears on selection */}
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={springTransition}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-primary-dark flex items-center justify-center"
|
||||
>
|
||||
<Check size={10} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'flex-shrink-0 w-10 h-6 rounded-full relative transition-colors duration-300',
|
||||
enabled ? 'bg-primary' : 'bg-outline-variant',
|
||||
'text-sm font-semibold leading-tight mb-1 transition-colors duration-200',
|
||||
selected ? 'text-primary-dark' : 'text-on-surface',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm"
|
||||
animate={{ x: enabled ? 18 : 2 }}
|
||||
transition={springTransition}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[11px] text-outline leading-relaxed">{description}</p>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,19 +142,19 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
const canProceed = formData.services.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProgressBar currentStep={1} totalSteps={3} />
|
||||
|
||||
<div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
|
||||
{t('step1.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-outline">{t('step1.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Service cards */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{SERVICES.map((option) => (
|
||||
{/* Service cards — horizontal grid */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{SERVICES.map((option, index) => (
|
||||
<ServiceCard
|
||||
key={option.id}
|
||||
option={option}
|
||||
@@ -229,31 +162,49 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
onToggle={() => toggleService(option.id)}
|
||||
title={t(option.titleKey)}
|
||||
description={t(option.descriptionKey)}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence>
|
||||
{formData.services.length === 0 && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-xs text-outline/60 text-center pt-1 select-none"
|
||||
>
|
||||
{t('selectService')}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* AI Toggle + Type Selection */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<AIToggle
|
||||
enabled={formData.aiEnabled}
|
||||
onToggle={toggleAI}
|
||||
label={t('aiToggle')}
|
||||
description={t('aiDescription')}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs text-center select-none -mt-2 transition-opacity duration-200',
|
||||
formData.services.length === 0 ? 'text-outline/50' : 'opacity-0',
|
||||
)}
|
||||
aria-hidden={formData.services.length > 0}
|
||||
>
|
||||
{t('selectService')}
|
||||
</p>
|
||||
|
||||
{/* AI Enhancement */}
|
||||
<div className="border-t border-dashed border-outline-variant/30 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAI}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full text-left rounded-xl px-4 py-3',
|
||||
'transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
formData.aiEnabled ? 'bg-primary/[0.04]' : 'hover:bg-surface-low',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors duration-200',
|
||||
formData.aiEnabled ? 'bg-primary/12' : 'bg-surface-low',
|
||||
)}>
|
||||
<Sparkles size={16} strokeWidth={1.75} className={cn('transition-colors', formData.aiEnabled ? 'text-primary-dark' : 'text-outline')} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={cn('text-sm font-semibold transition-colors', formData.aiEnabled ? 'text-primary-dark' : 'text-on-surface')}>
|
||||
{t('aiToggle')}
|
||||
</span>
|
||||
<p className="text-xs text-outline mt-0.5">{t('aiDescription')}</p>
|
||||
</div>
|
||||
<div className={cn('flex-shrink-0 w-11 h-6 rounded-full relative transition-colors duration-300', formData.aiEnabled ? 'bg-primary-dark' : 'bg-outline-variant/60')}>
|
||||
<motion.div className="absolute top-[3px] w-[18px] h-[18px] rounded-full bg-white shadow-sm" animate={{ x: formData.aiEnabled ? 20 : 3 }} transition={springTransition} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{formData.aiEnabled && (
|
||||
@@ -264,29 +215,21 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-1 flex flex-col gap-3">
|
||||
<div className="pt-3 pb-1 flex flex-col gap-2.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AI_TYPE_IDS.map((aiId, index) => (
|
||||
<motion.div
|
||||
key={aiId}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: index * 0.06,
|
||||
duration: 0.3,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
transition={{ delay: index * 0.05, duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Chip
|
||||
active={formData.aiTypes.includes(aiId)}
|
||||
onClick={() => toggleAIType(aiId)}
|
||||
>
|
||||
<Chip active={formData.aiTypes.includes(aiId)} onClick={() => toggleAIType(aiId)}>
|
||||
{t(`aiTypes.${aiId}.title`)}
|
||||
</Chip>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{formData.aiTypes.length > 0 && (
|
||||
<motion.div
|
||||
@@ -295,7 +238,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col gap-1.5 px-1"
|
||||
className="flex flex-col gap-1 px-1"
|
||||
>
|
||||
{formData.aiTypes.map((aiId) => (
|
||||
<p key={aiId} className="text-xs text-outline leading-relaxed">
|
||||
@@ -312,13 +255,8 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
arrow
|
||||
disabled={!canProceed}
|
||||
onClick={onNext}
|
||||
className="w-full"
|
||||
>
|
||||
{/* CTA */}
|
||||
<Button variant="primary" arrow disabled={!canProceed} onClick={onNext} className="w-full">
|
||||
{t('nextStep')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
313
src/components/configurator/VoiceAgent.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion, AnimatePresence, useMotionValue, useTransform } from 'framer-motion';
|
||||
import { Mic, MicOff, PhoneOff, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useVoiceAgent, type TranscriptEntry } from './VoiceAgentProvider';
|
||||
import type { WizardFormData } from './WizardContainer';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VoiceAgentProps {
|
||||
locale: string;
|
||||
onComplete: (brief: string, formData: WizardFormData) => void;
|
||||
}
|
||||
|
||||
// ─── Transcript Bubble ───────────────────────────────────────────────────────
|
||||
|
||||
function TranscriptBubble({ entry }: { entry: TranscriptEntry }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
'flex',
|
||||
entry.role === 'agent' ? 'justify-start' : 'justify-end',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed',
|
||||
entry.role === 'agent'
|
||||
? 'bg-surface-low text-on-surface'
|
||||
: 'bg-primary/10 text-primary-dark',
|
||||
)}
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function VoiceAgent({ locale, onComplete }: VoiceAgentProps) {
|
||||
const t = useTranslations('configurator');
|
||||
const {
|
||||
status,
|
||||
errorMessage,
|
||||
isMicActive,
|
||||
toggleMic,
|
||||
transcript,
|
||||
isAnalyzingSite,
|
||||
isGeneratingBrief,
|
||||
agentAmplitude,
|
||||
startConversation,
|
||||
endConversation,
|
||||
completedBrief,
|
||||
completedFormData,
|
||||
pendingContact,
|
||||
confirmContact,
|
||||
updatePendingContact,
|
||||
canReconnect,
|
||||
reconnect,
|
||||
} = useVoiceAgent();
|
||||
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll transcript
|
||||
useEffect(() => {
|
||||
transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [transcript]);
|
||||
|
||||
// Handle completion — end the call, then transition
|
||||
useEffect(() => {
|
||||
if (completedBrief && completedFormData) {
|
||||
console.log('[VoiceAgent] Brief complete, ending conversation and transitioning in 1.5s...');
|
||||
endConversation();
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[VoiceAgent] Calling onComplete');
|
||||
onComplete(completedBrief, completedFormData);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [completedBrief, completedFormData, onComplete, endConversation]);
|
||||
|
||||
// Orb animation driven by agent amplitude
|
||||
const amplitudeValue = useMotionValue(0);
|
||||
useEffect(() => {
|
||||
amplitudeValue.set(agentAmplitude);
|
||||
}, [agentAmplitude, amplitudeValue]);
|
||||
const orbScale = useTransform(amplitudeValue, [0, 0.5], [1, 1.18]);
|
||||
const orbGlow = useTransform(
|
||||
amplitudeValue,
|
||||
[0, 0.5],
|
||||
['0px 0px 0px rgba(0,100,148,0)', '0px 0px 30px rgba(0,100,148,0.3)'],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Agent card header */}
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-dark/80 flex items-center justify-center">
|
||||
<span className="text-white font-serif text-xs font-bold">L</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-on-surface">{t('voice.agentName')}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status === 'active' ? 'bg-green-500' : status === 'connecting' ? 'bg-amber-400 animate-pulse' : 'bg-outline-variant/50',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-outline">
|
||||
{status === 'active' ? 'Connected' : status === 'connecting' ? t('voice.connecting') : 'Ready'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform orb */}
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<motion.div
|
||||
style={{ scale: status === 'active' ? orbScale : 1, boxShadow: status === 'active' ? orbGlow : 'none' }}
|
||||
className={cn(
|
||||
'w-24 h-24 rounded-full flex items-center justify-center transition-colors duration-300',
|
||||
status === 'active'
|
||||
? 'bg-gradient-to-br from-primary to-primary-dark'
|
||||
: status === 'connecting' || (status === 'idle' && isGeneratingBrief)
|
||||
? 'bg-primary/20'
|
||||
: 'bg-surface-low border-2 border-outline-variant/30',
|
||||
)}
|
||||
>
|
||||
{status === 'idle' && !isGeneratingBrief && (
|
||||
<Mic size={32} strokeWidth={1.5} className="text-outline" />
|
||||
)}
|
||||
{(status === 'connecting' || (status === 'idle' && isGeneratingBrief)) && (
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}>
|
||||
<Loader2 size={32} strokeWidth={1.5} className="text-primary" />
|
||||
</motion.div>
|
||||
)}
|
||||
{status === 'active' && (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
<Mic size={32} strokeWidth={1.5} className="text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Status badges */}
|
||||
<AnimatePresence>
|
||||
{isAnalyzingSite && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary-dark text-xs font-medium"
|
||||
>
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}>
|
||||
<Loader2 size={11} />
|
||||
</motion.div>
|
||||
{t('voice.analyzingSite')}
|
||||
</motion.div>
|
||||
)}
|
||||
{isGeneratingBrief && !completedBrief && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary/10 text-primary-dark text-xs font-medium"
|
||||
>
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}>
|
||||
<Loader2 size={11} />
|
||||
</motion.div>
|
||||
{t('voice.generatingBrief')}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error message */}
|
||||
{errorMessage && !canReconnect && (
|
||||
<p className="text-xs text-red-600 text-center max-w-xs">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live transcript */}
|
||||
{transcript.length > 0 && (
|
||||
<div className="rounded-xl border border-outline-variant/30 bg-surface-high p-3 max-h-72 overflow-y-auto scrollbar-thin">
|
||||
<div className="flex flex-col gap-2">
|
||||
{transcript.map((entry, i) => (
|
||||
<TranscriptBubble key={`${entry.timestamp}-${i}`} entry={entry} />
|
||||
))}
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact confirmation card */}
|
||||
<AnimatePresence>
|
||||
{pendingContact && !completedBrief && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="rounded-xl border border-primary/20 bg-primary/5 p-4"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
|
||||
{t('voice.contactConfirm')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-outline w-12 flex-shrink-0">
|
||||
{t('fields.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pendingContact.name}
|
||||
onChange={(e) => updatePendingContact('name', e.target.value)}
|
||||
className="flex-1 text-sm text-on-surface bg-white rounded-lg border border-outline-variant/30 px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-outline w-12 flex-shrink-0">
|
||||
{t('fields.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={pendingContact.email}
|
||||
onChange={(e) => updatePendingContact('email', e.target.value)}
|
||||
className="flex-1 text-sm text-on-surface bg-white rounded-lg border border-outline-variant/30 px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmContact}
|
||||
className="mt-3 w-full py-2 rounded-lg text-xs font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
{t('voice.contactConfirmButton')}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Controls — sticky on mobile for thumb reach */}
|
||||
<div className="flex items-center justify-center gap-3 pt-2 sticky bottom-0 bg-surface-high/95 backdrop-blur-sm pb-2 -mx-6 px-6 sm:static sm:bg-transparent sm:backdrop-blur-none sm:pb-0 sm:mx-0">
|
||||
{status === 'idle' && !completedBrief && !isGeneratingBrief && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startConversation}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
<Mic size={16} />
|
||||
{t('voice.startConversation')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMic}
|
||||
className={cn(
|
||||
'w-11 h-11 rounded-full flex items-center justify-center transition-all',
|
||||
isMicActive
|
||||
? 'bg-surface-low text-on-surface hover:bg-outline-variant/30'
|
||||
: 'bg-red-100 text-red-600',
|
||||
)}
|
||||
>
|
||||
{isMicActive ? <Mic size={18} /> : <MicOff size={18} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={endConversation}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-red-50 text-red-700 text-xs font-medium hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<PhoneOff size={14} />
|
||||
{t('voice.endConversation')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<p className="text-sm text-outline animate-pulse">{t('voice.connecting')}</p>
|
||||
)}
|
||||
|
||||
{(status === 'error' || canReconnect) && !completedBrief && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-xs text-outline text-center">
|
||||
{t('voice.connectionLost')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reconnect}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
{t('voice.reconnect')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
612
src/components/configurator/VoiceAgentProvider.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import type { WizardFormData } from './WizardContainer';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranscriptEntry {
|
||||
role: 'user' | 'agent';
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type ConnectionStatus = 'idle' | 'connecting' | 'active' | 'ending' | 'error';
|
||||
|
||||
export interface PendingContact {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface VoiceAgentContextValue {
|
||||
status: ConnectionStatus;
|
||||
errorMessage: string | null;
|
||||
isMicActive: boolean;
|
||||
toggleMic: () => void;
|
||||
transcript: TranscriptEntry[];
|
||||
selections: Partial<WizardFormData>;
|
||||
isAnalyzingSite: boolean;
|
||||
isGeneratingBrief: boolean;
|
||||
userAmplitude: number;
|
||||
agentAmplitude: number;
|
||||
startConversation: () => Promise<void>;
|
||||
endConversation: () => void;
|
||||
completedBrief: string | null;
|
||||
completedFormData: WizardFormData | null;
|
||||
pendingContact: PendingContact | null;
|
||||
confirmContact: () => void;
|
||||
updatePendingContact: (field: 'name' | 'email', value: string) => void;
|
||||
canReconnect: boolean;
|
||||
reconnect: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Context ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const VoiceAgentContext = createContext<VoiceAgentContextValue | null>(null);
|
||||
|
||||
export function useVoiceAgent() {
|
||||
const ctx = useContext(VoiceAgentContext);
|
||||
if (!ctx) throw new Error('useVoiceAgent must be used within VoiceAgentProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── Audio Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function int16ToFloat32(int16: Int16Array): Float32Array {
|
||||
const float32 = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
float32[i] = int16[i] / 32768;
|
||||
}
|
||||
return float32;
|
||||
}
|
||||
|
||||
function base64ToInt16(base64: string): Int16Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Int16Array(bytes.buffer);
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// ─── Audio Worklet Processor Code ────────────────────────────────────────────
|
||||
|
||||
const WORKLET_CODE = `
|
||||
class AudioRecordingWorklet extends AudioWorkletProcessor {
|
||||
buffer = new Int16Array(2048);
|
||||
bufferWriteIndex = 0;
|
||||
|
||||
process(inputs) {
|
||||
if (inputs[0].length) {
|
||||
const channel0 = inputs[0][0];
|
||||
for (let i = 0; i < channel0.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, channel0[i]));
|
||||
this.buffer[this.bufferWriteIndex++] = sample * 32767;
|
||||
if (this.bufferWriteIndex >= this.buffer.length) {
|
||||
this.port.postMessage({
|
||||
event: 'chunk',
|
||||
data: { int16arrayBuffer: this.buffer.slice(0, this.bufferWriteIndex).buffer },
|
||||
});
|
||||
this.bufferWriteIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('audio-recorder-worklet', AudioRecordingWorklet);
|
||||
`;
|
||||
|
||||
// ─── Default Form Data (mirror WizardContainer) ─────────────────────────────
|
||||
|
||||
const DEFAULT_FORM_DATA: WizardFormData = {
|
||||
services: [],
|
||||
aiEnabled: false,
|
||||
aiTypes: [],
|
||||
industry: null,
|
||||
scope: '',
|
||||
timeline: null,
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
contactPreference: 'email',
|
||||
currentSiteUrl: '',
|
||||
currentSiteThoughts: '',
|
||||
};
|
||||
|
||||
// ─── Provider Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface VoiceAgentProviderProps {
|
||||
locale: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function VoiceAgentProvider({ locale, children }: VoiceAgentProviderProps) {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isMicActive, setIsMicActive] = useState(true);
|
||||
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
||||
const [selections, setSelections] = useState<Partial<WizardFormData>>({});
|
||||
const [isAnalyzingSite, setIsAnalyzingSite] = useState(false);
|
||||
const [isGeneratingBrief, setIsGeneratingBrief] = useState(false);
|
||||
const [userAmplitude, setUserAmplitude] = useState(0);
|
||||
const [agentAmplitude, setAgentAmplitude] = useState(0);
|
||||
const [completedBrief, setCompletedBrief] = useState<string | null>(null);
|
||||
const [completedFormData, setCompletedFormData] = useState<WizardFormData | null>(null);
|
||||
const [pendingContact, setPendingContact] = useState<PendingContact | null>(null);
|
||||
const [canReconnect, setCanReconnect] = useState(false);
|
||||
|
||||
const turnCompleteRef = useRef(true);
|
||||
const briefSubmittedRef = useRef(false);
|
||||
const pendingContactRef = useRef<PendingContact | null>(null);
|
||||
const reconnectTranscriptRef = useRef<TranscriptEntry[]>([]);
|
||||
const statusRef = useRef<ConnectionStatus>('idle');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const playbackContextRef = useRef<AudioContext | null>(null);
|
||||
const nextStartTimeRef = useRef(0);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
// Keep statusRef in sync for use in closures
|
||||
useEffect(() => { statusRef.current = status; }, [status]);
|
||||
|
||||
const addTranscript = useCallback((role: 'user' | 'agent', text: string) => {
|
||||
setTranscript((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
// Append to last entry if same role and turn is still ongoing
|
||||
if (last && last.role === role && !turnCompleteRef.current) {
|
||||
return [...prev.slice(0, -1), { ...last, text: last.text + text }];
|
||||
}
|
||||
turnCompleteRef.current = false;
|
||||
return [...prev, { role, text, timestamp: Date.now() }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const trackAmplitude = useCallback(() => {
|
||||
if (!analyserRef.current) return;
|
||||
const data = new Uint8Array(analyserRef.current.fftSize);
|
||||
analyserRef.current.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = (data[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
setUserAmplitude(Math.sqrt(sum / data.length));
|
||||
animFrameRef.current = requestAnimationFrame(trackAmplitude);
|
||||
}, []);
|
||||
|
||||
const handleToolCall = useCallback(
|
||||
async (name: string, args: Record<string, unknown>, callId: string) => {
|
||||
if (name === 'update_selections') {
|
||||
setSelections((prev) => ({ ...prev, ...(args as Partial<WizardFormData>) }));
|
||||
return JSON.stringify({ success: true });
|
||||
}
|
||||
|
||||
if (name === 'analyze_website') {
|
||||
setIsAnalyzingSite(true);
|
||||
try {
|
||||
const res = await fetch('/api/analyze-site', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: args.url }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setIsAnalyzingSite(false);
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
setIsAnalyzingSite(false);
|
||||
return JSON.stringify({ success: false, summary: "I wasn't able to analyze that site." });
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'request_contact') {
|
||||
const { name: contactName, email: contactEmail } = args as { name: string; email: string };
|
||||
const contact = { name: contactName, email: contactEmail };
|
||||
setPendingContact(contact);
|
||||
pendingContactRef.current = contact;
|
||||
// Respond immediately so Gemini doesn't timeout waiting for a tool response.
|
||||
// The agent is told to wait — user confirmation comes as a text message via confirmContact().
|
||||
return JSON.stringify({ success: true, message: 'Contact card is now shown on screen. Wait for the user to review and confirm before calling complete_brief. Do not proceed until you hear confirmation.' });
|
||||
}
|
||||
|
||||
if (name === 'complete_brief') {
|
||||
// Prevent duplicate submissions
|
||||
if (briefSubmittedRef.current) return JSON.stringify({ success: true, message: 'Brief already submitted' });
|
||||
briefSubmittedRef.current = true;
|
||||
setIsGeneratingBrief(true);
|
||||
console.log('[VoiceAgent] complete_brief called, generating...');
|
||||
try {
|
||||
const toolArgs = args as Partial<WizardFormData> & { conversationSummary?: string };
|
||||
const summary = toolArgs.conversationSummary ?? '';
|
||||
const existingScope = toolArgs.scope ?? '';
|
||||
const combinedScope = [existingScope, summary].filter(Boolean).join('\n\n');
|
||||
// Use confirmed contact details from the on-screen card if available
|
||||
const contactName = pendingContactRef.current?.name ?? toolArgs.name ?? '';
|
||||
const contactEmail = pendingContactRef.current?.email ?? toolArgs.email ?? '';
|
||||
const formData = { ...DEFAULT_FORM_DATA, ...toolArgs, name: contactName, email: contactEmail, scope: combinedScope, locale };
|
||||
delete (formData as Record<string, unknown>).conversationSummary;
|
||||
const res = await fetch('/api/configure', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = (await res.json()) as { success: boolean; brief?: string };
|
||||
console.log('[VoiceAgent] Brief API response:', data.success);
|
||||
if (data.success && data.brief) {
|
||||
setCompletedBrief(data.brief);
|
||||
setCompletedFormData(formData as WizardFormData);
|
||||
console.log('[VoiceAgent] completedBrief and completedFormData set');
|
||||
}
|
||||
return JSON.stringify({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[VoiceAgent] Brief generation failed:', err);
|
||||
briefSubmittedRef.current = false;
|
||||
return JSON.stringify({ success: false, error: 'Brief generation failed' });
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({ error: `Unknown tool: ${name}` });
|
||||
},
|
||||
[locale],
|
||||
);
|
||||
|
||||
const playAudioChunk = useCallback((base64Audio: string) => {
|
||||
if (!playbackContextRef.current) return;
|
||||
const ctx = playbackContextRef.current;
|
||||
const int16 = base64ToInt16(base64Audio);
|
||||
const float32 = int16ToFloat32(int16);
|
||||
const buffer = ctx.createBuffer(1, float32.length, 24000);
|
||||
buffer.copyToChannel(new Float32Array(float32), 0);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
if (nextStartTimeRef.current < ctx.currentTime) {
|
||||
nextStartTimeRef.current = ctx.currentTime;
|
||||
}
|
||||
source.start(nextStartTimeRef.current);
|
||||
nextStartTimeRef.current += buffer.duration;
|
||||
|
||||
const amplitude = Math.sqrt(float32.reduce((sum, v) => sum + v * v, 0) / float32.length);
|
||||
setAgentAmplitude(amplitude);
|
||||
}, []);
|
||||
|
||||
const startConversation = useCallback(async () => {
|
||||
setStatus('connecting');
|
||||
setErrorMessage(null);
|
||||
setCanReconnect(false);
|
||||
// Only reset transcript/selections on fresh start (not reconnect)
|
||||
if (reconnectTranscriptRef.current.length === 0) {
|
||||
setTranscript([]);
|
||||
setSelections({});
|
||||
setPendingContact(null);
|
||||
pendingContactRef.current = null;
|
||||
}
|
||||
setCompletedBrief(null);
|
||||
setCompletedFormData(null);
|
||||
briefSubmittedRef.current = false;
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch('/api/gemini-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale }),
|
||||
});
|
||||
const tokenData = await tokenRes.json();
|
||||
if (!tokenData.success) throw new Error(`Token generation failed: ${tokenData.error ?? tokenRes.status}`);
|
||||
|
||||
const { apiKey, model, config } = tokenData;
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
});
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// Create AudioContexts during user gesture (required on mobile)
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioContextRef.current = audioCtx;
|
||||
|
||||
// Playback context MUST be created here (user gesture) for mobile
|
||||
playbackContextRef.current = new AudioContext({ sampleRate: 24000 });
|
||||
nextStartTimeRef.current = playbackContextRef.current.currentTime;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// Register AudioWorklet
|
||||
const workletBlob = new Blob([WORKLET_CODE], { type: 'application/javascript' });
|
||||
const workletUrl = URL.createObjectURL(workletBlob);
|
||||
await audioCtx.audioWorklet.addModule(workletUrl);
|
||||
URL.revokeObjectURL(workletUrl);
|
||||
|
||||
const workletNode = new AudioWorkletNode(audioCtx, 'audio-recorder-worklet');
|
||||
source.connect(workletNode);
|
||||
workletNode.connect(audioCtx.destination);
|
||||
|
||||
// Open WebSocket to Gemini Live API
|
||||
const wsUrl = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${apiKey}`;
|
||||
console.log('[VoiceAgent] Connecting to WebSocket...');
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
// Timeout if setup doesn't complete within 10 seconds
|
||||
const setupTimeout = setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.CLOSED) {
|
||||
console.error('[VoiceAgent] Setup timed out after 10s');
|
||||
ws.close();
|
||||
setStatus('error');
|
||||
setErrorMessage('Connection timed out. Please try again.');
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[VoiceAgent] WebSocket opened, sending setup...');
|
||||
ws.send(JSON.stringify({
|
||||
setup: {
|
||||
model: `models/${model}`,
|
||||
generationConfig: {
|
||||
responseModalities: config.responseModalities,
|
||||
speechConfig: config.speechConfig,
|
||||
},
|
||||
systemInstruction: {
|
||||
parts: [{ text: config.systemInstruction }],
|
||||
},
|
||||
tools: config.tools,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Send audio chunks from worklet
|
||||
workletNode.port.onmessage = (event) => {
|
||||
if (event.data.event === 'chunk' && ws.readyState === WebSocket.OPEN) {
|
||||
const base64 = arrayBufferToBase64(event.data.data.int16arrayBuffer);
|
||||
ws.send(JSON.stringify({
|
||||
realtimeInput: {
|
||||
audio: {
|
||||
data: base64,
|
||||
mimeType: 'audio/pcm;rate=16000',
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
let raw: string;
|
||||
if (event.data instanceof Blob) {
|
||||
raw = await event.data.text();
|
||||
} else {
|
||||
raw = event.data as string;
|
||||
}
|
||||
const msg = JSON.parse(raw);
|
||||
console.log('[VoiceAgent] Message:', JSON.stringify(msg).slice(0, 200));
|
||||
|
||||
// Setup complete — Gemini sends back a setupComplete message
|
||||
if (msg.setupComplete !== undefined) {
|
||||
console.log('[VoiceAgent] Setup complete, session active');
|
||||
clearTimeout(setupTimeout);
|
||||
setStatus('active');
|
||||
trackAmplitude();
|
||||
|
||||
// If reconnecting, seed with prior conversation context
|
||||
const priorTranscript = reconnectTranscriptRef.current;
|
||||
if (priorTranscript.length > 0) {
|
||||
const summary = priorTranscript
|
||||
.map((e) => `${e.role === 'user' ? 'User' : 'Agent'}: ${e.text}`)
|
||||
.join('\n');
|
||||
ws.send(JSON.stringify({
|
||||
realtimeInput: {
|
||||
text: `We were having a conversation but got disconnected. Here is what was discussed so far:\n\n${summary}\n\nPlease acknowledge the reconnection briefly and continue where we left off.`,
|
||||
},
|
||||
}));
|
||||
reconnectTranscriptRef.current = [];
|
||||
} else {
|
||||
// Prompt the agent to introduce itself
|
||||
ws.send(JSON.stringify({
|
||||
realtimeInput: {
|
||||
text: 'Hello, please introduce yourself.',
|
||||
},
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Server content (audio + text)
|
||||
if (msg.serverContent) {
|
||||
const parts = msg.serverContent.modelTurn?.parts;
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
console.log('[VoiceAgent] Audio chunk received, mime:', part.inlineData.mimeType, 'len:', part.inlineData.data?.length);
|
||||
playAudioChunk(part.inlineData.data);
|
||||
}
|
||||
if (part.text) {
|
||||
console.log('[VoiceAgent] Text:', part.text);
|
||||
addTranscript('agent', part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Input transcription
|
||||
if (msg.serverContent.inputTranscription?.text) {
|
||||
addTranscript('user', msg.serverContent.inputTranscription.text);
|
||||
}
|
||||
// Output transcription
|
||||
if (msg.serverContent.outputTranscription?.text) {
|
||||
addTranscript('agent', msg.serverContent.outputTranscription.text);
|
||||
}
|
||||
// Turn complete — next output starts a new transcript entry
|
||||
if (msg.serverContent.turnComplete || msg.serverContent.generationComplete) {
|
||||
turnCompleteRef.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Tool call
|
||||
if (msg.toolCall) {
|
||||
const calls = msg.toolCall.functionCalls;
|
||||
if (calls) {
|
||||
const responses = [];
|
||||
for (const call of calls) {
|
||||
const result = await handleToolCall(call.name, call.args ?? {}, call.id);
|
||||
responses.push({ id: call.id, name: call.name, response: { result } });
|
||||
}
|
||||
ws.send(JSON.stringify({ toolResponse: { functionResponses: responses } }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[VoiceAgent] WebSocket error:', e);
|
||||
setStatus('error');
|
||||
setErrorMessage('Connection error. Please try again.');
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log('[VoiceAgent] WebSocket closed:', e.code, e.reason);
|
||||
// Clean up audio but preserve transcript and selections
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
if (playbackContextRef.current) {
|
||||
void playbackContextRef.current.close();
|
||||
playbackContextRef.current = null;
|
||||
}
|
||||
wsRef.current = null;
|
||||
setUserAmplitude(0);
|
||||
setAgentAmplitude(0);
|
||||
// If we weren't intentionally ending, allow reconnect
|
||||
if (statusRef.current !== 'ending' && !briefSubmittedRef.current) {
|
||||
setStatus('error');
|
||||
setErrorMessage(null);
|
||||
setCanReconnect(true);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[VoiceAgent] Start failed:', error);
|
||||
setStatus('error');
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
setErrorMessage('Microphone access was denied.');
|
||||
} else {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
setErrorMessage(`Failed to start: ${msg}`);
|
||||
}
|
||||
}
|
||||
}, [locale, trackAmplitude, handleToolCall, playAudioChunk, addTranscript]);
|
||||
|
||||
const endConversation = useCallback(() => {
|
||||
setStatus('ending');
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
if (playbackContextRef.current) {
|
||||
void playbackContextRef.current.close();
|
||||
playbackContextRef.current = null;
|
||||
}
|
||||
|
||||
setUserAmplitude(0);
|
||||
setAgentAmplitude(0);
|
||||
setCanReconnect(false);
|
||||
reconnectTranscriptRef.current = [];
|
||||
setStatus('idle');
|
||||
}, []);
|
||||
|
||||
const updatePendingContact = useCallback((field: 'name' | 'email', value: string) => {
|
||||
setPendingContact((prev) => {
|
||||
if (!prev) return null;
|
||||
const updated = { ...prev, [field]: value };
|
||||
pendingContactRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const confirmContact = useCallback(() => {
|
||||
if (!pendingContactRef.current) return;
|
||||
// Send a text message to let the agent know the user confirmed their details
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const { name, email } = pendingContactRef.current;
|
||||
wsRef.current.send(JSON.stringify({
|
||||
realtimeInput: {
|
||||
text: `The user has confirmed their contact details on screen. Name: ${name}, Email: ${email}. You may now call complete_brief.`,
|
||||
},
|
||||
}));
|
||||
console.log('[VoiceAgent] Contact confirmed, notified agent');
|
||||
} else {
|
||||
console.warn('[VoiceAgent] Cannot confirm contact — WebSocket not open');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
setCanReconnect(false);
|
||||
setErrorMessage(null);
|
||||
// Preserve transcript for the new session to pick up context
|
||||
reconnectTranscriptRef.current = transcript;
|
||||
await startConversation();
|
||||
}, [startConversation, transcript]);
|
||||
|
||||
const toggleMic = useCallback(() => {
|
||||
if (!mediaStreamRef.current) return;
|
||||
const track = mediaStreamRef.current.getAudioTracks()[0];
|
||||
if (track) {
|
||||
track.enabled = !track.enabled;
|
||||
setIsMicActive(track.enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: VoiceAgentContextValue = {
|
||||
status,
|
||||
errorMessage,
|
||||
isMicActive,
|
||||
toggleMic,
|
||||
transcript,
|
||||
selections,
|
||||
isAnalyzingSite,
|
||||
isGeneratingBrief,
|
||||
userAmplitude,
|
||||
agentAmplitude,
|
||||
startConversation,
|
||||
endConversation,
|
||||
completedBrief,
|
||||
completedFormData,
|
||||
pendingContact,
|
||||
confirmContact,
|
||||
updatePendingContact,
|
||||
canReconnect,
|
||||
reconnect,
|
||||
};
|
||||
|
||||
return (
|
||||
<VoiceAgentContext.Provider value={value}>
|
||||
{children}
|
||||
</VoiceAgentContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { trackEvent } from '@/lib/analytics';
|
||||
import StepServices from './StepServices';
|
||||
import StepDetails from './StepDetails';
|
||||
import StepContact from './StepContact';
|
||||
import StepGenerating from './StepGenerating';
|
||||
import StepComplete from './StepComplete';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -20,6 +22,10 @@ export interface WizardFormData {
|
||||
name: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
contactPreference: string;
|
||||
currentSiteUrl: string;
|
||||
currentSiteThoughts: string;
|
||||
}
|
||||
|
||||
export interface StepProps {
|
||||
@@ -66,20 +72,30 @@ const DEFAULT_FORM_DATA: WizardFormData = {
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
contactPreference: 'email',
|
||||
currentSiteUrl: '',
|
||||
currentSiteThoughts: '',
|
||||
};
|
||||
|
||||
export default function WizardContainer() {
|
||||
const t = useTranslations('configurator');
|
||||
const locale = useLocale();
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3 | 4>(1);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const [formData, setFormData] = useState<WizardFormData>(DEFAULT_FORM_DATA);
|
||||
const [brief, setBrief] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const goNext = () => {
|
||||
setDirection(1);
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 4) as 1 | 2 | 3 | 4);
|
||||
setCurrentStep((prev) => {
|
||||
const next = Math.min(prev + 1, 4) as 1 | 2 | 3 | 4;
|
||||
trackEvent('configurator_step_completed', { step: prev });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
@@ -92,32 +108,41 @@ export default function WizardContainer() {
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setBrief('');
|
||||
setSubmitError(null);
|
||||
setIsGenerating(false);
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
setIsGenerating(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const response = await fetch('/api/configure', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
body: JSON.stringify({ ...formData, locale }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { success: boolean; brief?: string; error?: string };
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
setSubmitError(data.error ?? t('errors.general'));
|
||||
setIsGenerating(false);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setBrief(data.brief ?? '');
|
||||
setDirection(1);
|
||||
setIsGenerating(false);
|
||||
setCurrentStep(4);
|
||||
trackEvent('configurator_brief_generated', {
|
||||
services: formData.services.join(','),
|
||||
ai_enabled: formData.aiEnabled,
|
||||
});
|
||||
} catch {
|
||||
setSubmitError(t('errors.network'));
|
||||
setIsGenerating(false);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -135,7 +160,7 @@ export default function WizardContainer() {
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{currentStep === 1 && (
|
||||
{!isGenerating && currentStep === 1 && (
|
||||
<motion.div
|
||||
key="step-1"
|
||||
variants={stepVariants}
|
||||
@@ -147,7 +172,7 @@ export default function WizardContainer() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
{!isGenerating && currentStep === 2 && (
|
||||
<motion.div
|
||||
key="step-2"
|
||||
variants={stepVariants}
|
||||
@@ -159,7 +184,7 @@ export default function WizardContainer() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
{!isGenerating && currentStep === 3 && (
|
||||
<motion.div
|
||||
key="step-3"
|
||||
variants={stepVariants}
|
||||
@@ -176,7 +201,19 @@ export default function WizardContainer() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
{isGenerating && (
|
||||
<motion.div
|
||||
key="step-generating"
|
||||
variants={stepVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
<StepGenerating hasUrl={!!formData.currentSiteUrl.trim()} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!isGenerating && currentStep === 4 && (
|
||||
<motion.div
|
||||
key="step-4"
|
||||
variants={stepVariants}
|
||||
|
||||
@@ -24,15 +24,15 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
}}
|
||||
>
|
||||
{/* ━━━ BACKGROUND: Faint structural grid ━━━ */}
|
||||
<g data-layer="background" opacity="0.5">
|
||||
<g data-layer="background" opacity="0.85">
|
||||
{/* Horizontal datum lines */}
|
||||
<line x1="0" y1="200" x2="1000" y2="200" stroke="#1C2B3A" strokeOpacity="0.04" strokeWidth="0.5" strokeDasharray="8 24" />
|
||||
<line x1="0" y1="420" x2="1000" y2="420" stroke="#1C2B3A" strokeOpacity="0.03" strokeWidth="0.5" strokeDasharray="6 20" />
|
||||
<line x1="0" y1="640" x2="1000" y2="640" stroke="#1C2B3A" strokeOpacity="0.04" strokeWidth="0.5" strokeDasharray="8 24" />
|
||||
<line x1="0" y1="200" x2="1000" y2="200" stroke="#1C2B3A" strokeOpacity="0.09" strokeWidth="0.5" strokeDasharray="8 24" />
|
||||
<line x1="0" y1="420" x2="1000" y2="420" stroke="#1C2B3A" strokeOpacity="0.07" strokeWidth="0.5" strokeDasharray="6 20" />
|
||||
<line x1="0" y1="640" x2="1000" y2="640" stroke="#1C2B3A" strokeOpacity="0.09" strokeWidth="0.5" strokeDasharray="8 24" />
|
||||
|
||||
{/* Vertical datum lines */}
|
||||
<line x1="500" y1="0" x2="500" y2="800" stroke="#1C2B3A" strokeOpacity="0.03" strokeWidth="0.5" strokeDasharray="6 20" />
|
||||
<line x1="680" y1="0" x2="680" y2="800" stroke="#1C2B3A" strokeOpacity="0.03" strokeWidth="0.5" strokeDasharray="4 18" />
|
||||
<line x1="500" y1="0" x2="500" y2="800" stroke="#1C2B3A" strokeOpacity="0.07" strokeWidth="0.5" strokeDasharray="6 20" />
|
||||
<line x1="680" y1="0" x2="680" y2="800" stroke="#1C2B3A" strokeOpacity="0.07" strokeWidth="0.5" strokeDasharray="4 18" />
|
||||
</g>
|
||||
|
||||
{/* ━━━ MIDGROUND: The main arc composition ━━━ */}
|
||||
@@ -42,7 +42,7 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
cx={cx} cy={cy} r={R}
|
||||
fill="none"
|
||||
stroke="#5BA4D9"
|
||||
strokeOpacity="0.09"
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
cx={cx} cy={cy} r={R * 0.65}
|
||||
fill="none"
|
||||
stroke="#5BA4D9"
|
||||
strokeOpacity="0.05"
|
||||
strokeOpacity="0.12"
|
||||
strokeWidth="0.75"
|
||||
strokeDasharray="4 12"
|
||||
/>
|
||||
@@ -61,7 +61,7 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
cx={cx} cy={cy} r={R * 1.35}
|
||||
fill="none"
|
||||
stroke="#1C2B3A"
|
||||
strokeOpacity="0.035"
|
||||
strokeOpacity="0.08"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="3 16"
|
||||
/>
|
||||
@@ -70,44 +70,44 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
{/* Vertical radius */}
|
||||
<line
|
||||
x1={cx} y1={cy} x2={cx} y2={cy - R}
|
||||
stroke="#5BA4D9" strokeOpacity="0.08" strokeWidth="0.75"
|
||||
stroke="#5BA4D9" strokeOpacity="0.18" strokeWidth="0.75"
|
||||
/>
|
||||
{/* Top tick mark */}
|
||||
<line
|
||||
x1={cx - 8} y1={cy - R} x2={cx + 8} y2={cy - R}
|
||||
stroke="#5BA4D9" strokeOpacity="0.12" strokeWidth="0.75"
|
||||
stroke="#5BA4D9" strokeOpacity="0.25" strokeWidth="0.75"
|
||||
/>
|
||||
|
||||
{/* Diagonal radius — 45deg upper-right */}
|
||||
<line
|
||||
x1={cx} y1={cy}
|
||||
x2={cx + R * 0.707} y2={cy - R * 0.707}
|
||||
stroke="#1C2B3A" strokeOpacity="0.06" strokeWidth="0.5"
|
||||
stroke="#1C2B3A" strokeOpacity="0.13" strokeWidth="0.5"
|
||||
strokeDasharray="4 8"
|
||||
/>
|
||||
|
||||
{/* Horizontal radius — right */}
|
||||
<line
|
||||
x1={cx} y1={cy} x2={cx + R} y2={cy}
|
||||
stroke="#5BA4D9" strokeOpacity="0.06" strokeWidth="0.5"
|
||||
stroke="#5BA4D9" strokeOpacity="0.14" strokeWidth="0.5"
|
||||
/>
|
||||
{/* Right tick */}
|
||||
<line
|
||||
x1={cx + R} y1={cy - 8} x2={cx + R} y2={cy + 8}
|
||||
stroke="#5BA4D9" strokeOpacity="0.1" strokeWidth="0.75"
|
||||
stroke="#5BA4D9" strokeOpacity="0.22" strokeWidth="0.75"
|
||||
/>
|
||||
|
||||
{/* ── Center crosshair ── */}
|
||||
<line x1={cx - 12} y1={cy} x2={cx + 12} y2={cy} stroke="#5BA4D9" strokeOpacity="0.15" strokeWidth="0.75" />
|
||||
<line x1={cx} y1={cy - 12} x2={cx} y2={cy + 12} stroke="#5BA4D9" strokeOpacity="0.15" strokeWidth="0.75" />
|
||||
<circle cx={cx} cy={cy} r="2.5" fill="#5BA4D9" fillOpacity="0.12" />
|
||||
<line x1={cx - 12} y1={cy} x2={cx + 12} y2={cy} stroke="#5BA4D9" strokeOpacity="0.3" strokeWidth="0.75" />
|
||||
<line x1={cx} y1={cy - 12} x2={cx} y2={cy + 12} stroke="#5BA4D9" strokeOpacity="0.3" strokeWidth="0.75" />
|
||||
<circle cx={cx} cy={cy} r="2.5" fill="#5BA4D9" fillOpacity="0.25" />
|
||||
|
||||
{/* ── Angle arc at center — 90deg sweep ── */}
|
||||
<path
|
||||
d={`M ${cx} ${cy - 30} A 30 30 0 0 1 ${cx + 30} ${cy}`}
|
||||
fill="none"
|
||||
stroke="#5BA4D9"
|
||||
strokeOpacity="0.1"
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth="0.75"
|
||||
/>
|
||||
|
||||
@@ -115,38 +115,38 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
<line
|
||||
x1={cx - R} y1={cy - R - 30}
|
||||
x2={cx + R} y2={cy - R - 30}
|
||||
stroke="#1C2B3A" strokeOpacity="0.07" strokeWidth="0.5"
|
||||
stroke="#1C2B3A" strokeOpacity="0.15" strokeWidth="0.5"
|
||||
/>
|
||||
{/* End ticks */}
|
||||
<line x1={cx - R} y1={cy - R - 38} x2={cx - R} y2={cy - R - 22} stroke="#1C2B3A" strokeOpacity="0.09" strokeWidth="0.75" />
|
||||
<line x1={cx + R} y1={cy - R - 38} x2={cx + R} y2={cy - R - 22} stroke="#1C2B3A" strokeOpacity="0.09" strokeWidth="0.75" />
|
||||
<line x1={cx - R} y1={cy - R - 38} x2={cx - R} y2={cy - R - 22} stroke="#1C2B3A" strokeOpacity="0.18" strokeWidth="0.75" />
|
||||
<line x1={cx + R} y1={cy - R - 38} x2={cx + R} y2={cy - R - 22} stroke="#1C2B3A" strokeOpacity="0.18" strokeWidth="0.75" />
|
||||
{/* Label stub */}
|
||||
<rect x={cx - 18} y={cy - R - 37} width="36" height="7" rx="1" fill="#5BA4D9" fillOpacity="0.03" />
|
||||
<rect x={cx - 18} y={cy - R - 37} width="36" height="7" rx="1" fill="#5BA4D9" fillOpacity="0.07" />
|
||||
|
||||
{/* ── Vertical dimension — right side ── */}
|
||||
<line
|
||||
x1={cx + R + 30} y1={cy - R}
|
||||
x2={cx + R + 30} y2={cy + R}
|
||||
stroke="#1C2B3A" strokeOpacity="0.06" strokeWidth="0.5"
|
||||
stroke="#1C2B3A" strokeOpacity="0.13" strokeWidth="0.5"
|
||||
strokeDasharray="2 8"
|
||||
/>
|
||||
<line x1={cx + R + 22} y1={cy - R} x2={cx + R + 38} y2={cy - R} stroke="#1C2B3A" strokeOpacity="0.08" strokeWidth="0.75" />
|
||||
<line x1={cx + R + 22} y1={cy + R} x2={cx + R + 38} y2={cy + R} stroke="#1C2B3A" strokeOpacity="0.08" strokeWidth="0.75" />
|
||||
<line x1={cx + R + 22} y1={cy - R} x2={cx + R + 38} y2={cy - R} stroke="#1C2B3A" strokeOpacity="0.16" strokeWidth="0.75" />
|
||||
<line x1={cx + R + 22} y1={cy + R} x2={cx + R + 38} y2={cy + R} stroke="#1C2B3A" strokeOpacity="0.16" strokeWidth="0.75" />
|
||||
|
||||
{/* Subtle fill — atmospheric glow behind main circle */}
|
||||
<circle cx={cx} cy={cy} r={R * 0.8} fill="#5BA4D9" fillOpacity="0.015" />
|
||||
<circle cx={cx} cy={cy} r={R * 0.8} fill="#5BA4D9" fillOpacity="0.035" />
|
||||
</g>
|
||||
|
||||
{/* ━━━ FOREGROUND: Crisp detail elements ━━━ */}
|
||||
<g data-layer="foreground">
|
||||
{/* ── Corner brackets — architectural framing ── */}
|
||||
{/* Top-right */}
|
||||
<path d="M 940 40 L 970 40 L 970 70" fill="none" stroke="#1C2B3A" strokeOpacity="0.12" strokeWidth="1" />
|
||||
<path d="M 940 40 L 970 40 L 970 70" fill="none" stroke="#1C2B3A" strokeOpacity="0.25" strokeWidth="1" />
|
||||
{/* Bottom-right */}
|
||||
<path d="M 940 760 L 970 760 L 970 730" fill="none" stroke="#1C2B3A" strokeOpacity="0.08" strokeWidth="0.75" />
|
||||
<path d="M 940 760 L 970 760 L 970 730" fill="none" stroke="#1C2B3A" strokeOpacity="0.18" strokeWidth="0.75" />
|
||||
{/* Top inner */}
|
||||
<path d="M 560 80 L 590 80" fill="none" stroke="#1C2B3A" strokeOpacity="0.06" strokeWidth="0.75" />
|
||||
<path d="M 560 80 L 560 110" fill="none" stroke="#1C2B3A" strokeOpacity="0.06" strokeWidth="0.75" />
|
||||
<path d="M 560 80 L 590 80" fill="none" stroke="#1C2B3A" strokeOpacity="0.14" strokeWidth="0.75" />
|
||||
<path d="M 560 80 L 560 110" fill="none" stroke="#1C2B3A" strokeOpacity="0.14" strokeWidth="0.75" />
|
||||
|
||||
{/* ── Tick marks around the main circle — spaced at 30deg intervals ── */}
|
||||
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg) => {
|
||||
@@ -161,7 +161,7 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
x1={x - nx * 6} y1={y - ny * 6}
|
||||
x2={x + nx * 6} y2={y + ny * 6}
|
||||
stroke="#5BA4D9"
|
||||
strokeOpacity="0.1"
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth="0.75"
|
||||
/>
|
||||
)
|
||||
@@ -172,7 +172,7 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
d={`M ${cx + 60} ${cy - R + 40} A 50 50 0 0 1 ${cx + 110} ${cy - R + 40}`}
|
||||
fill="none"
|
||||
stroke="#5BA4D9"
|
||||
strokeOpacity="0.12"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.75"
|
||||
/>
|
||||
|
||||
@@ -185,20 +185,20 @@ export default function HeroGeometric({ className }: HeroGeometricProps) {
|
||||
cy={560 + row * 28}
|
||||
r="1"
|
||||
fill="#1C2B3A"
|
||||
fillOpacity="0.06"
|
||||
fillOpacity="0.13"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── Scattered accent dots ── */}
|
||||
<circle cx={cx + 140} cy={cy - 80} r="2" fill="#5BA4D9" fillOpacity="0.1" />
|
||||
<circle cx={cx - 120} cy={cy + 100} r="1.5" fill="#5BA4D9" fillOpacity="0.07" />
|
||||
<circle cx={cx + 200} cy={cy + 150} r="1.5" fill="#1C2B3A" fillOpacity="0.06" />
|
||||
<circle cx={cx + 140} cy={cy - 80} r="2" fill="#5BA4D9" fillOpacity="0.2" />
|
||||
<circle cx={cx - 120} cy={cy + 100} r="1.5" fill="#5BA4D9" fillOpacity="0.15" />
|
||||
<circle cx={cx + 200} cy={cy + 150} r="1.5" fill="#1C2B3A" fillOpacity="0.13" />
|
||||
|
||||
{/* ── Sparse left-side balance elements ── */}
|
||||
<line x1="60" y1="350" x2="180" y2="350" stroke="#1C2B3A" strokeOpacity="0.03" strokeWidth="0.5" strokeDasharray="4 16" />
|
||||
<line x1="100" y1="500" x2="100" y2="530" stroke="#1C2B3A" strokeOpacity="0.04" strokeWidth="0.5" />
|
||||
<line x1="88" y1="515" x2="112" y2="515" stroke="#1C2B3A" strokeOpacity="0.04" strokeWidth="0.5" />
|
||||
<line x1="60" y1="350" x2="180" y2="350" stroke="#1C2B3A" strokeOpacity="0.07" strokeWidth="0.5" strokeDasharray="4 16" />
|
||||
<line x1="100" y1="500" x2="100" y2="530" stroke="#1C2B3A" strokeOpacity="0.09" strokeWidth="0.5" />
|
||||
<line x1="88" y1="515" x2="112" y2="515" stroke="#1C2B3A" strokeOpacity="0.09" strokeWidth="0.5" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -115,11 +115,20 @@ export default function Nav() {
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
const otherLocale = locales.find((l) => l !== currentLocale) ?? 'fr'
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
|
||||
const LOCALE_DISPLAY: Record<string, { flag: string; label: string; short: string }> = {
|
||||
en: { flag: '🇬🇧', label: 'English', short: 'EN' },
|
||||
fr: { flag: '🇫🇷', label: 'Français', short: 'FR' },
|
||||
it: { flag: '🇮🇹', label: 'Italiano', short: 'IT' },
|
||||
es: { flag: '🇪🇸', label: 'Español', short: 'ES' },
|
||||
}
|
||||
|
||||
const isHomePage = pathname === '/'
|
||||
|
||||
function handleLocaleSwitch() {
|
||||
router.replace(pathname as any, { locale: otherLocale as any })
|
||||
function handleLocaleSwitch(targetLocale: string) {
|
||||
router.replace(pathname as any, { locale: targetLocale as any })
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
|
||||
// Prevent body scroll when mobile menu is open
|
||||
@@ -183,14 +192,58 @@ export default function Nav() {
|
||||
|
||||
{/* ── Desktop right controls ── */}
|
||||
<div className="hidden lg:flex items-center gap-4 shrink-0">
|
||||
{/* Language toggle */}
|
||||
<button
|
||||
onClick={handleLocaleSwitch}
|
||||
className="label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 px-2 py-1 rounded focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
aria-label={`Switch to ${otherLocale === 'fr' ? 'French' : 'English'}`}
|
||||
>
|
||||
{otherLocale.toUpperCase()}
|
||||
</button>
|
||||
{/* Language dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setLocaleMenuOpen((prev) => !prev)}
|
||||
className="label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 px-2 py-1 rounded focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
aria-label="Switch language"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{LOCALE_DISPLAY[currentLocale]?.short ?? currentLocale.toUpperCase()}
|
||||
</button>
|
||||
|
||||
{/* Transparent overlay to close on outside click */}
|
||||
{localeMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[40]"
|
||||
aria-hidden="true"
|
||||
onClick={() => setLocaleMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{localeMenuOpen && (
|
||||
<motion.ul
|
||||
role="listbox"
|
||||
aria-label="Select language"
|
||||
className="absolute right-0 top-full mt-2 z-[41] bg-surface-high rounded-xl shadow-lg border border-outline-variant/20 overflow-hidden min-w-[140px]"
|
||||
initial={{ opacity: 0, y: -6, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.97 }}
|
||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{locales
|
||||
.filter((l) => l !== currentLocale)
|
||||
.map((l) => {
|
||||
const display = LOCALE_DISPLAY[l]
|
||||
return (
|
||||
<li key={l} role="option" aria-selected={false}>
|
||||
<button
|
||||
onClick={() => handleLocaleSwitch(l)}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2.5 label-md text-on-surface/70 hover:text-on-surface hover:bg-surface-low transition-colors duration-150 text-left"
|
||||
>
|
||||
<span aria-hidden="true">{display?.flag}</span>
|
||||
<span>{display?.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Start a Project CTA */}
|
||||
<a
|
||||
@@ -207,7 +260,7 @@ export default function Nav() {
|
||||
{/* ── Mobile hamburger ── */}
|
||||
<button
|
||||
className="lg:hidden p-2 -mr-2 text-on-surface focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
onClick={() => { setMobileOpen(true); setLocaleMenuOpen(false) }}
|
||||
aria-label="Open navigation menu"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-menu"
|
||||
@@ -300,16 +353,27 @@ export default function Nav() {
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="px-6 pb-8 pt-4 flex flex-col gap-3">
|
||||
{/* Language toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleLocaleSwitch()
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
className="w-full py-3 label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 text-left focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
|
||||
>
|
||||
{otherLocale === 'fr' ? '🇫🇷 Français' : '🇬🇧 English'}
|
||||
</button>
|
||||
{/* Language options */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{locales
|
||||
.filter((l) => l !== currentLocale)
|
||||
.map((l) => {
|
||||
const display = LOCALE_DISPLAY[l]
|
||||
return (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => {
|
||||
handleLocaleSwitch(l)
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
className="w-full py-2.5 label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 text-left focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded flex items-center gap-2.5"
|
||||
>
|
||||
<span aria-hidden="true">{display?.flag}</span>
|
||||
<span>{display?.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<a
|
||||
|
||||
@@ -36,7 +36,16 @@ export default function Configurator() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="configure" className="relative bg-surface py-24 overflow-hidden">
|
||||
<section
|
||||
id="configure"
|
||||
className="relative bg-surface py-24 overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
].join(', '),
|
||||
}}
|
||||
>
|
||||
{/* Subtle diagonal accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
|
||||
@@ -131,7 +140,7 @@ export default function Configurator() {
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 overflow-hidden border border-outline-variant/20">
|
||||
<div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 border border-outline-variant/20">
|
||||
{/* Top-edge accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-6 right-6 h-[2px] rounded-full pointer-events-none"
|
||||
|
||||
174
src/components/sections/Discovery.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations';
|
||||
import { trackEvent } from '@/lib/analytics';
|
||||
import VoiceAgentProvider from '@/components/configurator/VoiceAgentProvider';
|
||||
import VoiceAgent from '@/components/configurator/VoiceAgent';
|
||||
import StepComplete from '@/components/configurator/StepComplete';
|
||||
import type { WizardFormData } from '@/components/configurator/WizardContainer';
|
||||
|
||||
export default function Discovery() {
|
||||
const t = useTranslations('discovery');
|
||||
const locale = useLocale();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [completed, setCompleted] = useState<{ brief: string; formData: WizardFormData } | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [voiceSupported, setVoiceSupported] = useState(false);
|
||||
|
||||
// Check if voice is available (same logic as old ModeToggle)
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
if (typeof WebSocket === 'undefined') return;
|
||||
if (!navigator.mediaDevices?.getUserMedia) return;
|
||||
try {
|
||||
const res = await fetch('/api/gemini-token');
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
if (data.success) setVoiceSupported(true);
|
||||
} catch {
|
||||
// silent — section stays hidden
|
||||
}
|
||||
}
|
||||
void check();
|
||||
}, []);
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
trackEvent('voice_agent_started');
|
||||
// Scroll to panel after it renders
|
||||
requestAnimationFrame(() => {
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
};
|
||||
|
||||
const handleComplete = (brief: string, formData: WizardFormData) => {
|
||||
setCompleted({ brief, formData });
|
||||
trackEvent('voice_agent_brief_generated');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setCompleted(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!voiceSupported) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
id="discover"
|
||||
className="relative bg-surface-high py-24 overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
].join(', '),
|
||||
}}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 10%, rgba(91,164,217,0.15) 50%, transparent 90%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{completed ? (
|
||||
<motion.div
|
||||
key="completed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="max-w-2xl mx-auto"
|
||||
>
|
||||
<StepComplete
|
||||
formData={completed.formData}
|
||||
brief={completed.brief}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="discovery"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary"
|
||||
>
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
<motion.h2
|
||||
variants={revealVariants}
|
||||
className="font-serif text-4xl font-semibold tracking-headline text-on-surface leading-tight md:text-5xl mt-4 max-w-lg"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={revealVariants}
|
||||
className="text-base text-outline leading-relaxed max-w-md mt-4"
|
||||
>
|
||||
{t('description')}
|
||||
</motion.p>
|
||||
|
||||
{!isOpen && (
|
||||
<motion.div variants={revealVariants} className="mt-8 flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-2.5 px-7 py-3.5 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0 shadow-lg shadow-primary/20"
|
||||
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
{t('cta')}
|
||||
</button>
|
||||
<p className="text-[11px] text-outline/60 mt-3">{t('privacy')}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Voice panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-xl mt-10 overflow-hidden"
|
||||
>
|
||||
<div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 border border-outline-variant/20">
|
||||
{/* Top accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-6 right-6 h-[2px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #006494, #5BA4D9, transparent)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<VoiceAgentProvider locale={locale}>
|
||||
<VoiceAgent locale={locale} onComplete={handleComplete} />
|
||||
</VoiceAgentProvider>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -124,198 +125,30 @@ function PhilosophyPillar({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Abstract Geometric Decoration ───────────────────────────────────────────
|
||||
|
||||
function AbstractGeometry() {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Primary large circle — filled radial glow */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '65%',
|
||||
height: '65%',
|
||||
top: '-8%',
|
||||
right: '-12%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.12) 0%, rgba(0,100,148,0.06) 60%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Large circle ring — border only, ~40% opacity */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '72%',
|
||||
height: '72%',
|
||||
top: '-18%',
|
||||
right: '-20%',
|
||||
border: '1px solid rgba(91,164,217,0.20)',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Secondary circle, bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
bottom: '-10%',
|
||||
left: '-6%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(0,100,148,0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Second smaller ring — bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '38%',
|
||||
height: '38%',
|
||||
bottom: '-4%',
|
||||
left: '4%',
|
||||
border: '1px solid rgba(0,100,148,0.18)',
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diagonal grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, var(--color-primary-dark) 0, var(--color-primary-dark) 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diagonal line crossing the composition */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '130%',
|
||||
height: '1px',
|
||||
top: '42%',
|
||||
left: '-15%',
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, rgba(91,164,217,0.18) 30%, rgba(0,100,148,0.22) 60%, transparent 100%)',
|
||||
transform: 'rotate(-12deg)',
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accent rectangle top-left, rotated */}
|
||||
<div
|
||||
className="absolute rounded-md"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '30%',
|
||||
top: '10%',
|
||||
left: '8%',
|
||||
border: '1.5px solid rgba(91,164,217,0.15)',
|
||||
transform: 'rotate(-8deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Second rotated rectangle outline — center */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
width: '16%',
|
||||
height: '22%',
|
||||
top: '38%',
|
||||
left: '28%',
|
||||
border: '1px solid rgba(91,164,217,0.12)',
|
||||
transform: 'rotate(14deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating dot cluster center-right */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '22%',
|
||||
top: '35%',
|
||||
right: '10%',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.28) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '10px 10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Small dot cluster upper-left */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '14%',
|
||||
height: '16%',
|
||||
top: '12%',
|
||||
right: '32%',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, rgba(0,100,148,0.20) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '8px 8px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thin dashed arc */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{ top: '20%', left: '30%', opacity: 0.09 }}
|
||||
width="140"
|
||||
height="140"
|
||||
viewBox="0 0 140 140"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="60"
|
||||
stroke="var(--color-primary-dark)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="8 6"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Small solid accent square */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
width: '6%',
|
||||
height: '6%',
|
||||
bottom: '28%',
|
||||
right: '28%',
|
||||
background: 'rgba(91,164,217,0.22)',
|
||||
transform: 'rotate(12deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navy bottom overlay that fades upward — gives depth to pull-quote */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 rounded-b-xl"
|
||||
style={{
|
||||
height: '45%',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(28,43,58,0.55) 0%, rgba(28,43,58,0.18) 55%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Philosophy() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="about" className="bg-surface py-20">
|
||||
<section
|
||||
id="about"
|
||||
className="relative bg-surface py-20 overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
].join(', '),
|
||||
}}
|
||||
>
|
||||
{/* Ambient radial glow */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 20% 50%, rgba(91,164,217,0.04) 0%, transparent 70%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-start">
|
||||
|
||||
@@ -374,7 +207,13 @@ export default function Philosophy() {
|
||||
'overflow-visible',
|
||||
)}
|
||||
>
|
||||
<AbstractGeometry />
|
||||
<Image
|
||||
src="/images/philosophy_image.png"
|
||||
alt="Craftsmanship and precision"
|
||||
fill
|
||||
className="object-cover rounded-xl"
|
||||
sizes="(max-width: 1024px) 100vw, 58vw"
|
||||
/>
|
||||
|
||||
{/* Subtle inner shadow rim */}
|
||||
<div
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Search, LayoutDashboard, PenTool, Rocket } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
staggerContainerWide,
|
||||
@@ -17,16 +15,15 @@ import SectionHeader from '@/components/ui/SectionHeader';
|
||||
interface Step {
|
||||
numeral: string;
|
||||
key: 'discovery' | 'strategy' | 'build' | 'launch';
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ numeral: '01', key: 'discovery', Icon: Search },
|
||||
{ numeral: '02', key: 'strategy', Icon: LayoutDashboard },
|
||||
{ numeral: '03', key: 'build', Icon: PenTool },
|
||||
{ numeral: '04', key: 'launch', Icon: Rocket },
|
||||
{ numeral: '01', key: 'discovery' },
|
||||
{ numeral: '02', key: 'strategy' },
|
||||
{ numeral: '03', key: 'build' },
|
||||
{ numeral: '04', key: 'launch' },
|
||||
];
|
||||
|
||||
// ─── Variants ─────────────────────────────────────────────────────────────────
|
||||
@@ -44,9 +41,134 @@ const numeralScaleVariants: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Animated blueprint network (desktop only) ───────────────────────────────
|
||||
|
||||
function FlowNetwork() {
|
||||
// Radial constellation — center hub with curved spokes to outer nodes
|
||||
const cx = 130, cy = 100; // center
|
||||
const nodes = [
|
||||
{ x: 50, y: 30 }, // top-left
|
||||
{ x: 210, y: 25 }, // top-right
|
||||
{ x: 240, y: 110 }, // right
|
||||
{ x: 195, y: 180 }, // bottom-right
|
||||
{ x: 55, y: 170 }, // bottom-left
|
||||
{ x: 20, y: 95 }, // left
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="hidden lg:block relative mt-8 w-full h-52" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes flow-dash { to { stroke-dashoffset: -40; } }
|
||||
@keyframes flow-dash-slow { to { stroke-dashoffset: -40; } }
|
||||
@keyframes node-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes orbit-a {
|
||||
from { transform: rotate(0deg) translateX(28px) rotate(0deg); }
|
||||
to { transform: rotate(360deg) translateX(28px) rotate(-360deg); }
|
||||
}
|
||||
@keyframes orbit-b {
|
||||
from { transform: rotate(120deg) translateX(22px) rotate(-120deg); }
|
||||
to { transform: rotate(480deg) translateX(22px) rotate(-480deg); }
|
||||
}
|
||||
@keyframes orbit-c {
|
||||
from { transform: rotate(240deg) translateX(35px) rotate(-240deg); }
|
||||
to { transform: rotate(-120deg) translateX(35px) rotate(120deg); }
|
||||
}
|
||||
@keyframes ring-breathe {
|
||||
0%, 100% { opacity: 0.06; }
|
||||
50% { opacity: 0.14; }
|
||||
}
|
||||
.process-flow { animation: flow-dash 2.2s linear infinite; }
|
||||
.process-flow-slow { animation: flow-dash-slow 3.5s linear infinite; }
|
||||
.process-pulse { animation: node-pulse 2.8s ease-in-out infinite; }
|
||||
.process-orbit-a { animation: orbit-a 8s linear infinite; }
|
||||
.process-orbit-b { animation: orbit-b 11s linear infinite; }
|
||||
.process-orbit-c { animation: orbit-c 14s linear infinite; }
|
||||
.process-ring { animation: ring-breathe 4s ease-in-out infinite; }
|
||||
.process-ring-lg { animation: ring-breathe 5.5s ease-in-out infinite; }
|
||||
`}</style>
|
||||
<svg className="w-full h-full" viewBox="0 0 260 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Breathing rings */}
|
||||
<circle cx={cx} cy={cy} r="24" stroke="rgba(91,164,217,0.1)" strokeWidth="1" fill="none" className="process-ring" />
|
||||
<circle cx={cx} cy={cy} r="42" stroke="rgba(91,164,217,0.07)" strokeWidth="1" fill="none" className="process-ring-lg" />
|
||||
<circle cx={cx} cy={cy} r="65" stroke="rgba(91,164,217,0.04)" strokeWidth="0.8" fill="none" strokeDasharray="3 6" />
|
||||
|
||||
{/* Curved spokes from center to each outer node */}
|
||||
{nodes.map((n, i) => {
|
||||
const mx = cx + (n.x - cx) * 0.5 + (i % 2 === 0 ? 15 : -15);
|
||||
const my = cy + (n.y - cy) * 0.5 + (i % 2 === 0 ? -12 : 12);
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M ${cx} ${cy} Q ${mx} ${my} ${n.x} ${n.y}`}
|
||||
stroke="rgba(91,164,217,0.18)"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="5 4"
|
||||
strokeLinecap="round"
|
||||
className={i % 2 === 0 ? 'process-flow' : 'process-flow-slow'}
|
||||
style={{ animationDelay: `${i * 0.3}s` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Faint arcs connecting adjacent outer nodes */}
|
||||
{nodes.map((n, i) => {
|
||||
const next = nodes[(i + 1) % nodes.length];
|
||||
const mx = cx + (n.x + next.x - 2 * cx) * 0.15;
|
||||
const my = cy + (n.y + next.y - 2 * cy) * 0.15;
|
||||
return (
|
||||
<path
|
||||
key={`arc-${i}`}
|
||||
d={`M ${n.x} ${n.y} Q ${mx} ${my} ${next.x} ${next.y}`}
|
||||
stroke="rgba(91,164,217,0.08)"
|
||||
strokeWidth="0.8"
|
||||
strokeDasharray="3 5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer nodes — pulsing */}
|
||||
{nodes.map((n, i) => (
|
||||
<circle
|
||||
key={`node-${i}`}
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r="3"
|
||||
fill="#5BA4D9"
|
||||
className="process-pulse"
|
||||
style={{ animationDelay: `${i * 0.45}s` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Center hub */}
|
||||
<circle cx={cx} cy={cy} r="5" fill="#5BA4D9" opacity="0.85" />
|
||||
<circle cx={cx} cy={cy} r="2" fill="#fff" opacity="0.9" />
|
||||
|
||||
{/* Orbiting particles */}
|
||||
<g style={{ transformOrigin: `${cx}px ${cy}px` }}>
|
||||
<circle cx={cx} cy={cy} r="2" fill="#5BA4D9" opacity="0.7" className="process-orbit-a" />
|
||||
</g>
|
||||
<g style={{ transformOrigin: `${cx}px ${cy}px` }}>
|
||||
<circle cx={cx} cy={cy} r="1.5" fill="rgba(91,164,217,0.5)" className="process-orbit-b" />
|
||||
</g>
|
||||
<g style={{ transformOrigin: `${cx}px ${cy}px` }}>
|
||||
<circle cx={cx} cy={cy} r="1.5" fill="rgba(91,164,217,0.35)" className="process-orbit-c" />
|
||||
</g>
|
||||
|
||||
{/* Corner brackets — architectural detail */}
|
||||
<path d="M 14 25 L 14 20 L 19 20" stroke="rgba(28,43,58,0.12)" strokeWidth="0.8" fill="none" />
|
||||
<path d="M 246 175 L 246 180 L 241 180" stroke="rgba(28,43,58,0.12)" strokeWidth="0.8" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepCard({ numeral, stepKey, Icon }: { numeral: string; stepKey: string; Icon: LucideIcon }) {
|
||||
function StepCard({ numeral, stepKey }: { numeral: string; stepKey: string }) {
|
||||
const t = useTranslations();
|
||||
const title = t(`process.steps.${stepKey}.title`);
|
||||
const description = t(`process.steps.${stepKey}.description`);
|
||||
@@ -94,7 +216,16 @@ export default function Process() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<section id="process" className="bg-surface-low py-20">
|
||||
<section
|
||||
id="process"
|
||||
className="relative bg-surface py-20"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
].join(', '),
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
|
||||
{/*
|
||||
@@ -121,59 +252,22 @@ export default function Process() {
|
||||
align="left"
|
||||
/>
|
||||
</div>
|
||||
<FlowNetwork />
|
||||
</div>
|
||||
|
||||
{/* ── Steps column ── */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="relative">
|
||||
{/* Dashed connector line — visible on sm+ grid layouts only */}
|
||||
<div
|
||||
className="hidden sm:block absolute inset-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Horizontal dashes across the middle gap */}
|
||||
<line
|
||||
x1="50%" y1="50%"
|
||||
x2="50%" y2="50%"
|
||||
stroke="rgba(91,164,217,0.18)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 6"
|
||||
/>
|
||||
{/* Vertical dashes down the centre gap */}
|
||||
<line
|
||||
x1="0%" y1="50%"
|
||||
x2="100%" y2="50%"
|
||||
stroke="rgba(91,164,217,0.18)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 6"
|
||||
/>
|
||||
<line
|
||||
x1="50%" y1="0%"
|
||||
x2="50%" y2="100%"
|
||||
stroke="rgba(91,164,217,0.18)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
||||
>
|
||||
{STEPS.map((step) => (
|
||||
<StepCard key={step.key} numeral={step.numeral} stepKey={step.key} Icon={step.Icon} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div
|
||||
variants={staggerContainerWide}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-5"
|
||||
>
|
||||
{STEPS.map((step) => (
|
||||
<StepCard key={step.key} numeral={step.numeral} stepKey={step.key} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fadeVariants,
|
||||
viewportOnce,
|
||||
} from '@/lib/animations';
|
||||
import Image from 'next/image';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Lock, Clock, ArrowRight } from 'lucide-react';
|
||||
|
||||
@@ -21,6 +22,7 @@ interface Project {
|
||||
/** number of tags to resolve from the translation array */
|
||||
tagCount: number;
|
||||
featured?: boolean;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface ComingSoonItem {
|
||||
@@ -37,16 +39,19 @@ const PROJECTS: Project[] = [
|
||||
slug: 'monaco-ocean',
|
||||
tagCount: 2,
|
||||
featured: true,
|
||||
image: '/images/monaco_high_res.jpg',
|
||||
},
|
||||
{
|
||||
i18nKey: 'portNimara',
|
||||
slug: 'port-nimara',
|
||||
tagCount: 2,
|
||||
image: '/images/anguilla.png',
|
||||
},
|
||||
{
|
||||
i18nKey: 'portAmador',
|
||||
slug: 'port-amador',
|
||||
tagCount: 2,
|
||||
image: '/images/panama.png',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -86,274 +91,6 @@ const comingSoonVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Geometric Placeholder ────────────────────────────────────────────────────
|
||||
|
||||
function GeometricPlaceholder({
|
||||
variant = 'featured',
|
||||
cardVariant = 'default',
|
||||
className,
|
||||
}: {
|
||||
variant?: 'featured' | 'small';
|
||||
cardVariant?: 'default' | 'nimara' | 'amador';
|
||||
className?: string;
|
||||
}) {
|
||||
const isFeatured = variant === 'featured';
|
||||
|
||||
// Different gradient bases per card so secondary cards look distinct
|
||||
const gradientMap = {
|
||||
default: 'from-primary-dark/90 to-primary/70',
|
||||
nimara: 'from-navy/95 to-primary-dark/75',
|
||||
amador: 'from-[#1a3a4a]/95 to-primary/60',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-gradient-to-br',
|
||||
gradientMap[cardVariant],
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
|
||||
{isFeatured ? (
|
||||
/* ── Featured (Monaco): blueprint feel ── */
|
||||
<>
|
||||
{/* Large filled circle top-right */}
|
||||
<div
|
||||
className="absolute rounded-full bg-white/[0.06]"
|
||||
style={{ width: '55%', height: '55%', top: '-15%', right: '-10%' }}
|
||||
/>
|
||||
{/* Large circle ring — blueprint layer */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '45%',
|
||||
height: '45%',
|
||||
top: '10%',
|
||||
left: '5%',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
{/* Medium circle bottom-left */}
|
||||
<div
|
||||
className="absolute rounded-full bg-white/[0.08]"
|
||||
style={{ width: '40%', height: '40%', bottom: '-20%', left: '-5%' }}
|
||||
/>
|
||||
{/* Subtle grid overlay (blueprint feel) */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(rgba(255,255,255,1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,1) 1px, transparent 1px)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
{/* Diagonal stripes very subtle */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(-45deg, #fff 0, #fff 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
/>
|
||||
{/* Small dot cluster */}
|
||||
<div
|
||||
className="absolute opacity-[0.12]"
|
||||
style={{
|
||||
width: '20%',
|
||||
height: '20%',
|
||||
top: '55%',
|
||||
right: '18%',
|
||||
backgroundImage: 'radial-gradient(circle, #fff 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '8px 8px',
|
||||
}}
|
||||
/>
|
||||
{/* Blueprint horizontal line — wide, white/20 */}
|
||||
<div
|
||||
className="absolute bg-white/[0.22] rounded-full"
|
||||
style={{ width: '60%', height: '1px', top: '50%', left: '5%' }}
|
||||
/>
|
||||
{/* Second thinner line below */}
|
||||
<div
|
||||
className="absolute bg-white/[0.10] rounded-full"
|
||||
style={{ width: '40%', height: '1px', top: 'calc(50% + 10px)', left: '5%' }}
|
||||
/>
|
||||
{/* Accent rotated rectangle */}
|
||||
<div
|
||||
className="absolute bg-white/[0.10] rounded-sm"
|
||||
style={{
|
||||
width: '18%',
|
||||
height: '28%',
|
||||
bottom: '18%',
|
||||
right: '15%',
|
||||
transform: 'rotate(-6deg)',
|
||||
}}
|
||||
/>
|
||||
{/* Small diagonal accent line */}
|
||||
<div
|
||||
className="absolute bg-white/[0.15]"
|
||||
style={{
|
||||
width: '25%',
|
||||
height: '1px',
|
||||
top: '28%',
|
||||
right: '8%',
|
||||
transform: 'rotate(-30deg)',
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : cardVariant === 'nimara' ? (
|
||||
/* ── Port Nimara: horizontal bands + arc ── */
|
||||
<>
|
||||
{/* Large arc ring top-left */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '80%',
|
||||
height: '80%',
|
||||
top: '-30%',
|
||||
left: '-20%',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
{/* Smaller ring center */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '45%',
|
||||
height: '70%',
|
||||
bottom: '-15%',
|
||||
right: '-10%',
|
||||
border: '1px solid rgba(91,164,217,0.25)',
|
||||
}}
|
||||
/>
|
||||
{/* Horizontal rule band */}
|
||||
<div
|
||||
className="absolute bg-white/[0.06]"
|
||||
style={{ height: '28%', bottom: 0, left: 0, right: 0 }}
|
||||
/>
|
||||
{/* Fine horizontal lines */}
|
||||
<div
|
||||
className="absolute bg-white/20 rounded-full"
|
||||
style={{ width: '50%', height: '1px', top: '35%', right: '8%' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bg-white/10 rounded-full"
|
||||
style={{ width: '30%', height: '1px', top: '45%', right: '8%' }}
|
||||
/>
|
||||
{/* Dot cluster top-right */}
|
||||
<div
|
||||
className="absolute opacity-[0.14]"
|
||||
style={{
|
||||
width: '25%',
|
||||
height: '30%',
|
||||
top: '5%',
|
||||
right: '5%',
|
||||
backgroundImage: 'radial-gradient(circle, #fff 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '8px 8px',
|
||||
}}
|
||||
/>
|
||||
{/* Diagonal line */}
|
||||
<div
|
||||
className="absolute bg-white/[0.12]"
|
||||
style={{
|
||||
width: '40%',
|
||||
height: '1px',
|
||||
top: '60%',
|
||||
left: '5%',
|
||||
transform: 'rotate(-20deg)',
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* ── Port Amador: triangular/radial composition ── */
|
||||
<>
|
||||
{/* Central radial glow */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '70%',
|
||||
height: '70%',
|
||||
top: '-10%',
|
||||
right: '-15%',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(91,164,217,0.18) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Diamond/rotated square accent */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
width: '30%',
|
||||
height: '50%',
|
||||
bottom: '-10%',
|
||||
left: '10%',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
transform: 'rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
{/* Diagonal stripes (different angle) */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(30deg, #fff 0, #fff 1px, transparent 0, transparent 50%)',
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
{/* Top horizontal line */}
|
||||
<div
|
||||
className="absolute bg-white/20 rounded-full"
|
||||
style={{ width: '40%', height: '1px', top: '22%', left: '8%' }}
|
||||
/>
|
||||
{/* Small filled rectangle */}
|
||||
<div
|
||||
className="absolute bg-white/[0.12] rounded-sm"
|
||||
style={{
|
||||
width: '20%',
|
||||
height: '32%',
|
||||
top: '15%',
|
||||
right: '12%',
|
||||
transform: 'rotate(8deg)',
|
||||
}}
|
||||
/>
|
||||
{/* Dot accent bottom-right */}
|
||||
<div
|
||||
className="absolute opacity-[0.16]"
|
||||
style={{
|
||||
width: '22%',
|
||||
height: '22%',
|
||||
bottom: '8%',
|
||||
right: '8%',
|
||||
backgroundImage: 'radial-gradient(circle, #fff 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '7px 7px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient fade — card bg color bleed for text readability */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-2/5"
|
||||
style={{
|
||||
background: isFeatured
|
||||
? 'linear-gradient(to top, rgba(0,100,148,0.55) 0%, transparent 100%)'
|
||||
: cardVariant === 'nimara'
|
||||
? 'linear-gradient(to top, rgba(28,43,58,0.60) 0%, transparent 100%)'
|
||||
: 'linear-gradient(to top, rgba(26,58,74,0.55) 0%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tag Chip ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TagChip({ label, showDot = false }: { label: string; showDot?: boolean }) {
|
||||
@@ -383,12 +120,15 @@ function FeaturedCard({ project, readLabel, t }: { project: Project; readLabel:
|
||||
'transition-shadow duration-300 hover:shadow-[0_24px_48px_rgba(25,28,29,0.10)]',
|
||||
)}
|
||||
>
|
||||
{/* Geometric image placeholder */}
|
||||
<GeometricPlaceholder
|
||||
variant="featured"
|
||||
cardVariant="default"
|
||||
className="w-full aspect-[16/9] md:aspect-[2/1]"
|
||||
/>
|
||||
<div className="relative w-full aspect-[16/9] md:aspect-[2/1] overflow-hidden">
|
||||
<Image
|
||||
src={project.image}
|
||||
alt={t(`work.projects.${project.i18nKey}.title`)}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
sizes="(max-width: 768px) 100vw, 66vw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-7 gap-4">
|
||||
@@ -432,13 +172,7 @@ function FeaturedCard({ project, readLabel, t }: { project: Project; readLabel:
|
||||
|
||||
// ─── Small Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
const SLUG_TO_VARIANT: Record<string, 'nimara' | 'amador'> = {
|
||||
'port-nimara': 'nimara',
|
||||
'port-amador': 'amador',
|
||||
};
|
||||
|
||||
function SmallCard({ project, readLabel, t }: { project: Project; readLabel: string; t: (key: string) => string }) {
|
||||
const cardVariant = SLUG_TO_VARIANT[project.slug] ?? 'nimara';
|
||||
const tags = Array.from({ length: project.tagCount }, (_, i) =>
|
||||
t(`work.projects.${project.i18nKey}.tags.${i}`),
|
||||
);
|
||||
@@ -453,19 +187,14 @@ function SmallCard({ project, readLabel, t }: { project: Project; readLabel: str
|
||||
'hover:shadow-subtle',
|
||||
)}
|
||||
>
|
||||
{/* Geometric placeholder — grayscale to color on hover */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-500',
|
||||
'grayscale group-hover:grayscale-0',
|
||||
'opacity-80 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<GeometricPlaceholder
|
||||
variant="small"
|
||||
cardVariant={cardVariant}
|
||||
className="w-full aspect-[16/7]"
|
||||
<div className="relative w-full aspect-[16/7]">
|
||||
<Image
|
||||
src={project.image}
|
||||
alt={t(`work.projects.${project.i18nKey}.title`)}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +286,16 @@ export default function SelectedWorks() {
|
||||
const secondaryProjects = PROJECTS.filter((p) => !p.featured);
|
||||
|
||||
return (
|
||||
<section id="work" className="bg-surface-low py-20">
|
||||
<section
|
||||
id="work"
|
||||
className="bg-surface-low py-20"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
].join(', '),
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes dashed-drift {
|
||||
0% { background-position: 0 0, 100% 0, 100% 100%, 0 100%; }
|
||||
|
||||
@@ -112,6 +112,12 @@ export default function TrustBar() {
|
||||
<section
|
||||
aria-label="Trust indicators"
|
||||
className="relative bg-surface-low py-12"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
'repeating-linear-gradient(90deg, rgba(25,28,29,0.02) 0px, rgba(25,28,29,0.02) 1px, transparent 1px, transparent 48px)',
|
||||
].join(', '),
|
||||
}}
|
||||
>
|
||||
{/* Gradient bridge — blends hero into this section */}
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Users, MessageCircle, BarChart3 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -115,6 +116,7 @@ function AICapabilityCard({ capability }: { capability: AiCapability }) {
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AILayer({ capabilities }: AILayerProps) {
|
||||
const t = useTranslations('servicesPage.ai')
|
||||
return (
|
||||
<section
|
||||
id="ai-automation"
|
||||
@@ -137,7 +139,7 @@ export default function AILayer({ capabilities }: AILayerProps) {
|
||||
variants={revealVariants}
|
||||
className="label-md text-primary mb-4"
|
||||
>
|
||||
Intelligent Layer
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
{/* Heading */}
|
||||
@@ -146,7 +148,7 @@ export default function AILayer({ capabilities }: AILayerProps) {
|
||||
variants={revealVariants}
|
||||
className="font-serif font-semibold tracking-headline text-white text-4xl md:text-5xl max-w-2xl leading-[1.1]"
|
||||
>
|
||||
The AI Layer
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Vertical line */}
|
||||
@@ -163,7 +165,7 @@ export default function AILayer({ capabilities }: AILayerProps) {
|
||||
className="font-serif italic text-xl leading-relaxed max-w-xl"
|
||||
style={{ color: 'rgba(255,255,255,0.75)' }}
|
||||
>
|
||||
We build your ecosystem — then make it intelligent.
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* Context paragraph */}
|
||||
@@ -172,11 +174,7 @@ export default function AILayer({ capabilities }: AILayerProps) {
|
||||
className="mt-5 text-[0.9375rem] leading-relaxed max-w-2xl"
|
||||
style={{ color: 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
AI is not a product we bolt on — it is the connective tissue of
|
||||
every system we build. Once your digital infrastructure is live, we
|
||||
layer language models, automation pipelines, and predictive analytics
|
||||
directly into your workflows, so your team operates with capabilities
|
||||
that were previously reserved for organisations ten times your size.
|
||||
{t('description')}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
@@ -202,7 +200,7 @@ export default function AILayer({ capabilities }: AILayerProps) {
|
||||
className="mt-10 text-center text-xs uppercase tracking-widest"
|
||||
style={{ color: 'rgba(255,255,255,0.25)' }}
|
||||
>
|
||||
Compatible with your existing stack — no data ever leaves your infrastructure
|
||||
{t('bottomNote')}
|
||||
</motion.p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const decorLineVariants = {
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesCTA() {
|
||||
const t = useTranslations('servicesPage.cta')
|
||||
return (
|
||||
<section
|
||||
className="bg-surface-low py-24"
|
||||
@@ -90,23 +92,21 @@ export default function ServicesCTA() {
|
||||
{/* Eyebrow */}
|
||||
<ScrollReveal variant="fadeUp">
|
||||
<span className="label-md text-primary">
|
||||
Let's Talk
|
||||
{t('eyebrow')}
|
||||
</span>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Heading */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.08}>
|
||||
<h2 className="font-serif font-semibold tracking-headline text-on-surface text-4xl md:text-5xl max-w-2xl leading-[1.1]">
|
||||
Ready to scope your project?
|
||||
{t('title')}
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Subtitle */}
|
||||
<ScrollReveal variant="fadeUp" delay={0.16}>
|
||||
<p className="text-lg text-outline leading-relaxed max-w-xl">
|
||||
Use our interactive configurator to define your requirements, select
|
||||
your services, and generate a personalised project brief — no
|
||||
commitment required, just clarity.
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -119,14 +119,14 @@ export default function ServicesCTA() {
|
||||
size="lg"
|
||||
arrow
|
||||
>
|
||||
Configure Your Project
|
||||
{t('primary')}
|
||||
</Button>
|
||||
<Button
|
||||
href="mailto:hello@letsbe.biz"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
hello@letsbe.biz
|
||||
{t('email')}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
@@ -134,7 +134,7 @@ export default function ServicesCTA() {
|
||||
{/* Reassurance */}
|
||||
<ScrollReveal variant="fadeIn" delay={0.3}>
|
||||
<p className="text-sm text-outline/60 mt-1">
|
||||
No commitment required — just a conversation about what's possible.
|
||||
{t('reassurance')}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { staggerContainer, revealVariants, viewportOnce } from '@/lib/animations';
|
||||
|
||||
// ─── Animation variants ────────────────────────────────────────────────────────
|
||||
@@ -45,6 +46,7 @@ const ruleVariants = {
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ServicesHero() {
|
||||
const t = useTranslations('servicesPage.hero')
|
||||
return (
|
||||
<section
|
||||
className="bg-surface pt-32 pb-20"
|
||||
@@ -62,7 +64,7 @@ export default function ServicesHero() {
|
||||
variants={eyebrowVariants}
|
||||
className="label-md text-primary mb-5"
|
||||
>
|
||||
Our Capabilities
|
||||
{t('eyebrow')}
|
||||
</motion.span>
|
||||
|
||||
{/* Headline */}
|
||||
@@ -70,8 +72,8 @@ export default function ServicesHero() {
|
||||
variants={headlineVariants}
|
||||
className="font-serif font-semibold tracking-headline text-on-surface text-5xl md:text-6xl lg:text-7xl max-w-4xl leading-[1.05]"
|
||||
>
|
||||
Three Pillars of{' '}
|
||||
<span className="text-gradient">Digital Excellence</span>
|
||||
{t('title')}{' '}
|
||||
<span className="text-gradient">{t('titleAccent')}</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
@@ -79,9 +81,7 @@ export default function ServicesHero() {
|
||||
variants={subtitleVariants}
|
||||
className="mt-6 text-lg text-outline leading-relaxed max-w-2xl"
|
||||
>
|
||||
We design, build, and operate complete digital ecosystems — from the
|
||||
first pixel to the server rack. Every discipline under one roof,
|
||||
every deliverable built to a standard most agencies never attempt.
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* Decorative rule */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const locales = ['en', 'fr'] as const
|
||||
export const locales = ['en', 'fr', 'it', 'es'] as const
|
||||
export const defaultLocale = 'en' as const
|
||||
|
||||
export type Locale = (typeof locales)[number]
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
{
|
||||
"meta": {
|
||||
"siteName": "LetsBe.",
|
||||
"home": {
|
||||
"title": "LetsBe. | Custom Web Design, Software & Digital Infrastructure",
|
||||
"description": "Bespoke websites, purpose-built software, AI integration, and private infrastructure — designed, built, and managed by one dedicated team."
|
||||
},
|
||||
"about": {
|
||||
"title": "About LetsBe. | Our Story & Approach",
|
||||
"description": "An American-founded digital studio building custom websites, software, and platforms for businesses that care about quality."
|
||||
},
|
||||
"services": {
|
||||
"title": "Services | LetsBe. — Web Design, Software & Infrastructure",
|
||||
"description": "Custom web design, purpose-built software, AI automation, and private infrastructure — three pillars of digital excellence under one roof."
|
||||
},
|
||||
"work": {
|
||||
"monaco-ocean": {
|
||||
"title": "Monaco Ocean Protection Challenge | LetsBe.",
|
||||
"description": "AI-powered judging and analytics platform for one of the Mediterranean's leading conservation events."
|
||||
},
|
||||
"port-nimara": {
|
||||
"title": "Port Nimara — Maritime Digital Hub | LetsBe.",
|
||||
"description": "Custom website and full CRM for lead management, berth assignment, and marina operations."
|
||||
},
|
||||
"port-amador": {
|
||||
"title": "Port Amador — Premium Nautical Experience | LetsBe.",
|
||||
"description": "Website and private digital infrastructure for a premium marina — cloud storage, email, and file management."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"services": "Services",
|
||||
"configure": "Get Started",
|
||||
@@ -9,47 +38,47 @@
|
||||
"bookCall": "Book a Call"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Websites, software, and infrastructure — designed and built {accentWord} around you.",
|
||||
"accentWord": "entirely",
|
||||
"subtitle": "We design custom websites, build purpose-built software, and run private infrastructure that you own and control. One team, from first pixel to final deployment.",
|
||||
"title": "Your website. Your software. Your {accentWord} digital world.",
|
||||
"accentWord": "entire",
|
||||
"subtitle": "Custom websites, purpose-built software, and the infrastructure to run it all - designed, built, and managed by one dedicated team.",
|
||||
"cta": "Start Your Project",
|
||||
"ctaSecondary": "See Our Work",
|
||||
"trust": "Trusted by businesses across the Riviera"
|
||||
"trust": "Trusted by businesses worldwide"
|
||||
},
|
||||
"trustBar": {
|
||||
"customBuilt": {
|
||||
"title": "Built From Scratch",
|
||||
"description": "Every project designed and coded to your exact requirements. No templates, no shortcuts."
|
||||
"title": "Designed From Scratch",
|
||||
"description": "No templates, no page builders. Every site is custom-designed and hand-built for your brand."
|
||||
},
|
||||
"privateInfra": {
|
||||
"title": "You Own Everything",
|
||||
"description": "Private servers, your data, your tools — fully controlled and owned by you."
|
||||
"description": "Your code, your data, your servers. We build it, you own it - no lock-in, no surprises."
|
||||
},
|
||||
"aiPowered": {
|
||||
"title": "One Team, End to End",
|
||||
"description": "Design, development, hosting, and support from a single dedicated team."
|
||||
"title": "One Team, Start to Finish",
|
||||
"description": "No freelancer juggling. One team handles design, code, hosting, and support."
|
||||
},
|
||||
"rivieraBased": {
|
||||
"title": "AI Where It Matters",
|
||||
"description": "Intelligent features and automation built directly into your systems."
|
||||
"title": "AI Built In",
|
||||
"description": "Intelligent features and automation woven directly into your website and software."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"eyebrow": "What We Do",
|
||||
"title": "Design. Build. Run.",
|
||||
"title": "Design. Build. Grow.",
|
||||
"web": {
|
||||
"title": "Websites & Web Apps",
|
||||
"features": ["Custom Web Design", "Responsive Development", "SEO & Digital Marketing", "Content Management"]
|
||||
"title": "Web Design & Development",
|
||||
"features": ["Custom Website Design", "Responsive Development", "SEO & Performance", "Content Management"]
|
||||
},
|
||||
"systems": {
|
||||
"title": "Custom Software",
|
||||
"features": ["Management Platforms", "CRMs & Business Tools", "Booking & Scheduling", "API Integrations"]
|
||||
"title": "Software & Platforms",
|
||||
"features": ["Business Management Tools", "CRMs & Dashboards", "Booking & Scheduling Systems", "API Integrations"]
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Private Infrastructure",
|
||||
"features": ["Dedicated Servers", "Email & Cloud Storage", "Security & Encryption", "Monitoring & Support"]
|
||||
"title": "Hosting & Infrastructure",
|
||||
"features": ["Dedicated Servers", "Email & Cloud Storage", "Security & Monitoring", "Ongoing Support"]
|
||||
},
|
||||
"aiNarrative": "And when you're ready, we layer AI into everything — from intelligent features in your software to automation that connects all your tools."
|
||||
"aiNarrative": "And we layer AI into everything - from intelligent features in your website to automation that connects all your tools."
|
||||
},
|
||||
"configurator": {
|
||||
"eyebrow": "Get Started",
|
||||
@@ -69,9 +98,9 @@
|
||||
},
|
||||
"complete": {
|
||||
"title": "Your project brief is ready",
|
||||
"subtitle": "Check your inbox — we've sent a detailed brief to {email}",
|
||||
"bookTitle": "Book a Consultation",
|
||||
"bookSubtitle": "30 minutes to discuss your brief with our team",
|
||||
"subtitle": "Check your inbox - we've sent a detailed brief to {email}",
|
||||
"nextStep": "Next step: let's talk through your brief",
|
||||
"bookSubtitle": "Book a free 30-minute call to discuss your project and next steps.",
|
||||
"bookCall": "Book a Call",
|
||||
"briefPreview": "Your project brief",
|
||||
"reachDirectly": "Or reach us directly at"
|
||||
@@ -82,27 +111,27 @@
|
||||
"services": {
|
||||
"web": {
|
||||
"title": "Web Design & Development",
|
||||
"description": "Custom websites and web applications — designed from scratch, built for performance, and optimized for search engines."
|
||||
"description": "Custom websites and web applications - designed from scratch, built to perform, and optimized to get found."
|
||||
},
|
||||
"systems": {
|
||||
"title": "Custom Software",
|
||||
"description": "Management platforms, CRMs, and business tools built to match exactly how your team works."
|
||||
"description": "CRMs, management platforms, and business tools built around the way your team actually works."
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Private Infrastructure",
|
||||
"description": "Dedicated servers with email, cloud storage, and business tools — fully owned and controlled by you."
|
||||
"description": "Dedicated hosting, email, cloud storage, and the infrastructure your business runs on - fully owned by you."
|
||||
}
|
||||
},
|
||||
"aiToggle": "Add AI Integration",
|
||||
"aiDescription": "Intelligent features and automation built directly into your systems.",
|
||||
"aiDescription": "Practical AI features and automation built directly into your website and software.",
|
||||
"aiTypes": {
|
||||
"teammate": {
|
||||
"title": "AI Teammate",
|
||||
"description": "An internal AI assistant that helps your team work faster — automates tasks, answers questions, connects your tools."
|
||||
"description": "An internal AI assistant that helps your team work faster - automates tasks, answers questions, connects your tools."
|
||||
},
|
||||
"customer-facing": {
|
||||
"title": "Customer-Facing AI",
|
||||
"description": "AI features your clients interact with — smart search, personalized recommendations, conversational interfaces."
|
||||
"description": "AI features your clients interact with - smart search, personalized recommendations, conversational interfaces."
|
||||
},
|
||||
"data-intelligence": {
|
||||
"title": "Data Intelligence",
|
||||
@@ -110,7 +139,7 @@
|
||||
},
|
||||
"notsure": {
|
||||
"title": "Not Sure Yet",
|
||||
"description": "No problem — we'll explore the best AI approach together during discovery."
|
||||
"description": "No problem - we'll explore the best AI approach together during discovery."
|
||||
}
|
||||
},
|
||||
"industries": {
|
||||
@@ -136,13 +165,46 @@
|
||||
"timeline": "Timeline",
|
||||
"name": "Your name",
|
||||
"company": "Company",
|
||||
"email": "Email address"
|
||||
"email": "Email address",
|
||||
"phone": "Phone",
|
||||
"phoneOptional": "(optional)",
|
||||
"contactPreference": "Preferred contact method",
|
||||
"contactEmail": "Email",
|
||||
"contactPhone": "Phone",
|
||||
"contactWhatsapp": "WhatsApp",
|
||||
"currentSiteUrl": "Current website",
|
||||
"currentSiteUrlOptional": "(optional)",
|
||||
"currentSiteUrlPlaceholder": "https://yourwebsite.com",
|
||||
"currentSiteThoughts": "Thoughts on your current site",
|
||||
"currentSiteThoughtsPlaceholder": "What's working, what isn't, what you'd like to change..."
|
||||
},
|
||||
"summary": {
|
||||
"heading": "Your selections",
|
||||
"aiEnhancement": "AI Enhancement"
|
||||
},
|
||||
"generating": "Generating",
|
||||
"generatingSteps": {
|
||||
"preparingBrief": "Preparing your brief",
|
||||
"analyzingSite": "Analyzing your current website",
|
||||
"runningAudit": "Running performance audit",
|
||||
"generatingBrief": "Generating your personalized brief"
|
||||
},
|
||||
"voice": {
|
||||
"agentName": "LetsBe project assistant",
|
||||
"endConversation": "End Conversation",
|
||||
"analyzingSite": "Analyzing your site...",
|
||||
"connecting": "Connecting...",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"generatingBrief": "Generating your brief...",
|
||||
"contactConfirm": "Does this look right?",
|
||||
"contactEdit": "Edit",
|
||||
"contactConfirmButton": "That's correct",
|
||||
"reconnect": "Reconnect",
|
||||
"connectionLost": "Connection lost. Your conversation is saved.",
|
||||
"briefComplete": "Brief complete",
|
||||
"startConversation": "Start Conversation"
|
||||
},
|
||||
"privacy": "Your information is private and will never be shared.",
|
||||
"generateBrief": "Generate My Brief",
|
||||
"nextStep": "Next Step",
|
||||
@@ -153,6 +215,13 @@
|
||||
"network": "Network error. Please check your connection and try again."
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"eyebrow": "Let's Figure It Out",
|
||||
"title": "Not sure where to start?",
|
||||
"description": "Tell us what you're thinking and we'll figure it out together. You'll get a personalized brief at the end.",
|
||||
"cta": "Let's Talk",
|
||||
"privacy": "Voice conversations are not recorded or stored."
|
||||
},
|
||||
"process": {
|
||||
"eyebrow": "How We Work",
|
||||
"title": "From Idea to Launch",
|
||||
@@ -167,7 +236,7 @@
|
||||
},
|
||||
"build": {
|
||||
"title": "Design & Build",
|
||||
"description": "Your project takes shape — pixel by pixel, feature by feature."
|
||||
"description": "Your project takes shape - pixel by pixel, feature by feature."
|
||||
},
|
||||
"launch": {
|
||||
"title": "Launch & Support",
|
||||
@@ -193,62 +262,210 @@
|
||||
},
|
||||
"portAmador": {
|
||||
"title": "Port Amador",
|
||||
"description": "Website and private digital infrastructure — cloud storage, email, and file management for a premium marina.",
|
||||
"description": "Website and private digital infrastructure - cloud storage, email, and file management for a premium marina.",
|
||||
"tags": ["Website", "Infrastructure"]
|
||||
}
|
||||
},
|
||||
"comingSoonProjects": {
|
||||
"riviera": {
|
||||
"title": "Confidential Riviera Project",
|
||||
"title": "Real Estate Management Platform",
|
||||
"subtitle": "Coming Soon"
|
||||
},
|
||||
"sophia": {
|
||||
"title": "Sophia Antipolis AI Startup",
|
||||
"title": "Enterprise SaaS — Austin, TX",
|
||||
"subtitle": "Launching Q4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"philosophy": {
|
||||
"eyebrow": "Why It Matters",
|
||||
"title": "Your tools should belong to you.",
|
||||
"subtitle": "Most businesses rent their digital life from a dozen different platforms. We think there's a better way — one where you own your data, control your infrastructure, and aren't locked into anyone's pricing page.",
|
||||
"eyebrow": "Why Us",
|
||||
"title": "We do things differently.",
|
||||
"subtitle": "Most agencies hand you a template and call it custom. We think your business deserves better - real design, real engineering, and a team that sticks around after launch.",
|
||||
"ownership": {
|
||||
"title": "Own Your Stack",
|
||||
"description": "We move you off scattered SaaS subscriptions and onto private infrastructure — servers, email, cloud storage, and tools that you control."
|
||||
"title": "Built to Be Yours",
|
||||
"description": "Everything we build, you own. Your code, your data, your infrastructure - no lock-in, no platform dependencies, no surprises."
|
||||
},
|
||||
"craftsmanship": {
|
||||
"title": "No Shortcuts",
|
||||
"description": "We write clean, hand-crafted code optimized for speed and search engines. No page builders, no bloated themes, no technical debt."
|
||||
"title": "Craft Over Convenience",
|
||||
"description": "We write clean, hand-built code optimized for speed and search engines. No page builders, no bloated themes, no shortcuts."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "One Relationship",
|
||||
"description": "From the initial design through development to ongoing support — one team that knows your business inside and out."
|
||||
"description": "From first design to ongoing support - one team that knows your business inside and out. No handoffs, no telephone-game briefs."
|
||||
},
|
||||
"quote": "We build technology that works for your business — not the other way around.",
|
||||
"foundedLocation": "Based on the Côte d'Azur"
|
||||
"quote": "We build technology that works for your business - not the other way around.",
|
||||
"foundedLocation": "Matt Ciaccio, Founder"
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Let's Talk",
|
||||
"title": "Ready to build something?",
|
||||
"subtitle": "Tell us what you need. No pitch decks, no pressure — just a conversation about what's possible.",
|
||||
"title": "Ready to build something great?",
|
||||
"subtitle": "Tell us what you're working on. No pitch decks, no pressure - just an honest conversation about what's possible.",
|
||||
"cta": "Start Your Project",
|
||||
"configure": "Start Your Project",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "No commitment required."
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Custom websites, software, and infrastructure for businesses that want to own their digital future.",
|
||||
"location": "American-founded. Serving the Côte d'Azur and beyond.",
|
||||
"tagline": "Custom websites, software, and digital platforms - designed and built for businesses that refuse to settle.",
|
||||
"location": "American-founded. Serving clients worldwide.",
|
||||
"services": "Services",
|
||||
"studio": "Studio",
|
||||
"connect": "Connect",
|
||||
"serviceLinks": {
|
||||
"designDev": "Websites & Web Apps",
|
||||
"designDev": "Web Design & Development",
|
||||
"customSystems": "Custom Software",
|
||||
"infrastructure": "Private Infrastructure",
|
||||
"aiAutomation": "AI Integration"
|
||||
},
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service"
|
||||
},
|
||||
"aboutPage": {
|
||||
"hero": {
|
||||
"eyebrow": "About LetsBe.",
|
||||
"title": "Great businesses deserve great digital partners.",
|
||||
"subtitle": "We design and build custom websites, software, and digital platforms for businesses that care about quality — and want a team that does too."
|
||||
},
|
||||
"story": {
|
||||
"eyebrow": "Our Story",
|
||||
"title": "Built for businesses like yours.",
|
||||
"p1": "LetsBe. started with a simple belief: that ambitious businesses deserve digital tools as carefully considered as the work they do. Not templates. Not off-the-shelf platforms. Real design and engineering, built from scratch.",
|
||||
"p2": "Our early clients were founders and operators who needed more than a website — they needed a technical partner who could design, build, host, and maintain everything under one roof. Those projects shaped how we work today.",
|
||||
"p3": "We build platforms meant to be owned, not rented. We document everything, we hand over codebases that outlast the engagement, and we never lock clients into systems they can't leave. That's not a feature — it's how we think business should work.",
|
||||
"quote": "Build fewer things. Build them better. Build them to last.",
|
||||
"quoteAttrib": "LetsBe. founding principle"
|
||||
},
|
||||
"pillars": {
|
||||
"eyebrow": "Our Beliefs",
|
||||
"title": "What We Believe",
|
||||
"subtitle": "Three principles behind every project we take on.",
|
||||
"craftsmanship": {
|
||||
"title": "Craftsmanship First",
|
||||
"description": "The gap between a website that works and one that lasts is craft. We sweat the typography, the transitions, the performance, the edge cases. Every interface we ship is something we'd be proud to sign."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "One Team, Everything",
|
||||
"description": "Design, development, hosting, infrastructure — one team, one point of contact, one standard of quality. No handoffs between agencies. No juggling freelancers. Just people who care about the whole thing."
|
||||
},
|
||||
"ownership": {
|
||||
"title": "Built to Be Yours",
|
||||
"description": "Everything we build, you own — the code, the data, the infrastructure. No vendor lock-in, no platform dependencies. We hand over work that outlasts the engagement."
|
||||
}
|
||||
},
|
||||
"quote": {
|
||||
"text": "We don't just build websites — we build the foundation your business runs on.",
|
||||
"attrib": "LetsBe. founding philosophy"
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Work With Us",
|
||||
"title": "Let's build something together.",
|
||||
"subtitle": "Whether you have a clear brief or just an early idea, we'd love to talk through what's possible.",
|
||||
"primary": "Start your project",
|
||||
"secondary": "Book a call"
|
||||
}
|
||||
},
|
||||
"servicesPage": {
|
||||
"hero": {
|
||||
"eyebrow": "Our Services",
|
||||
"title": "Everything your business",
|
||||
"titleAccent": "needs online.",
|
||||
"subtitle": "We design custom websites, build purpose-built software, and manage the infrastructure behind it all — one team, one standard of quality, nothing outsourced."
|
||||
},
|
||||
"pillars": [
|
||||
{
|
||||
"id": "design-development",
|
||||
"numeral": "01",
|
||||
"title": "Web Design & Development",
|
||||
"description": "Your website shouldn't look like everyone else's — and it shouldn't be built like everyone else's either. We design and build custom websites and web applications from a blank canvas, crafting every layout, every interaction, and every page with intention. The result is fast, search-engine-friendly, and built to grow with your business. Whether you need a marketing site that converts, a web application your team relies on, or an e-commerce platform that scales — we build it from scratch, and we build it to last.",
|
||||
"features": [
|
||||
{ "icon": "Palette", "title": "Custom Design", "description": "Every layout, component, and interaction is designed for your brand. No themes, no templates, no shortcuts." },
|
||||
{ "icon": "Globe", "title": "Web Applications", "description": "Modern, responsive applications built with the latest technologies — fast, reliable, and ready to scale." },
|
||||
{ "icon": "ShoppingCart", "title": "E-Commerce", "description": "Custom storefronts, checkout flows, and multi-currency platforms built for serious online retail." },
|
||||
{ "icon": "Zap", "title": "Performance & SEO", "description": "Fast load times, clean code, and search engine optimization built into the foundation — not bolted on after." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "custom-systems",
|
||||
"numeral": "02",
|
||||
"title": "Software & Platforms",
|
||||
"description": "Off-the-shelf software makes assumptions about how your business works. We don't. When spreadsheets and generic tools stop cutting it, we build the exact system your team needs — designed around your workflow, not someone else's. From CRMs tailored to your sales process, to management platforms that replace three different subscriptions, to integrations that connect your existing tools — everything we build is yours, fully documented, and built to last.",
|
||||
"features": [
|
||||
{ "icon": "Database", "title": "CRM & Management Tools", "description": "Relationship and pipeline management built around how your team actually works — not how a generic platform thinks you should." },
|
||||
{ "icon": "Code2", "title": "Custom Software", "description": "From booking platforms to internal tools to full SaaS products — purpose-built for your business." },
|
||||
{ "icon": "GitBranch", "title": "Integrations & APIs", "description": "We connect your existing tools and build the bridges between systems so everything works together." },
|
||||
{ "icon": "Wrench", "title": "Dashboards & Automation", "description": "Admin panels, reporting tools, and workflow automation that give your team an unfair advantage." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "infrastructure",
|
||||
"numeral": "03",
|
||||
"title": "Hosting & Infrastructure",
|
||||
"description": "Your website and software need a home — and we think you should own it. We set up and manage dedicated servers, email, cloud storage, and all the infrastructure your business runs on. No shared hosting, no mysterious third-party dependencies. You know where your data lives, who has access, and that someone is watching the dashboard around the clock.",
|
||||
"features": [
|
||||
{ "icon": "Server", "title": "Dedicated Hosting", "description": "Private servers managed for your business — no shared hosting, no noisy neighbors, no surprises." },
|
||||
{ "icon": "Shield", "title": "Your Data, Your Control", "description": "You own your data and know exactly where it lives. Full access, full transparency, no lock-in." },
|
||||
{ "icon": "Lock", "title": "Security & Protection", "description": "Serious security, proactive monitoring, and protection built into your infrastructure from day one." },
|
||||
{ "icon": "Settings", "title": "Monitoring & Support", "description": "Proactive monitoring, regular updates, and ongoing support so you never have to worry about uptime." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"eyebrow": "Intelligent Layer",
|
||||
"title": "AI Built Into Everything",
|
||||
"subtitle": "Your platform, made smarter.",
|
||||
"description": "We integrate AI directly into the websites and software we build for you. Not as a buzzword or an add-on — as practical features that save your team time and give your customers a better experience.",
|
||||
"bottomNote": "Every AI feature is tailored to your business — your data stays on your servers",
|
||||
"capabilities": [
|
||||
{ "id": "ai-teammate", "title": "AI Teammate", "description": "An AI assistant built into your workflow — automates repetitive tasks, surfaces the info your team needs, and connects your tools." },
|
||||
{ "id": "customer-facing-ai", "title": "Customer-Facing AI", "description": "Smart features for your customers — intelligent search, personalized recommendations, and conversational interfaces that work around the clock." },
|
||||
{ "id": "data-intelligence", "title": "Data Intelligence", "description": "AI that helps you understand your data — automated reports, trend spotting, and insights you can actually act on." }
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Let's Talk",
|
||||
"title": "Ready to get started?",
|
||||
"subtitle": "Walk through a few questions and we'll put together a project brief tailored to you — no commitment required, just clarity.",
|
||||
"primary": "Start Your Project",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "No commitment required — just a conversation about what's possible."
|
||||
}
|
||||
},
|
||||
"caseStudy": {
|
||||
"labels": {
|
||||
"challenge": "The Challenge",
|
||||
"challengeHeading": "The problem we set out to solve",
|
||||
"approach": "Our Approach",
|
||||
"approachHeading": "How we thought about it",
|
||||
"outcome": "The Outcome",
|
||||
"outcomeHeading": "What we delivered",
|
||||
"builtWith": "Built with",
|
||||
"yourTurn": "Your Turn",
|
||||
"ctaTitle": "Ready to build something like this?",
|
||||
"ctaSubtitle": "Every project starts with a conversation. Tell us what you're working on and we'll figure out the best way to bring it to life.",
|
||||
"ctaButton": "Start your project"
|
||||
},
|
||||
"projects": {
|
||||
"monaco-ocean": {
|
||||
"subtitle": "AI-Powered Judging & Analytics Platform",
|
||||
"description": "A comprehensive judging and analytics system with advanced AI jury integration for one of the Mediterranean's most prestigious conservation events.",
|
||||
"challenge": "The Monaco Ocean Protection Challenge needed a modern platform to manage submissions, coordinate judges across time zones, and provide AI-assisted evaluation of conservation proposals — all while maintaining the prestige and security expected of a Monaco institution.",
|
||||
"approach": "We built a custom platform from the ground up using Next.js and a private PostgreSQL infrastructure. The AI jury module uses natural language processing to pre-screen submissions and generate summary reports, while human judges retain full control over final decisions.",
|
||||
"outcome": "The platform processed over 200 submissions in its first season, reducing judge workload by 40% through AI-assisted pre-screening. The client praised the system's reliability and the elegance of its interface."
|
||||
},
|
||||
"port-nimara": {
|
||||
"subtitle": "Maritime Digital Hub",
|
||||
"description": "Scalable digital hub for maritime logistics.",
|
||||
"challenge": "Port Nimara needed a modern digital presence that could serve as both a marketing website and an operational hub for berth inquiries, event management, and partner communications.",
|
||||
"approach": "We designed and developed a performant Nuxt.js application with a headless CMS for content management, integrated with their existing maritime scheduling systems via custom API middleware.",
|
||||
"outcome": "The new platform increased online berth inquiries by 3x and provided the port authority with real-time content management capabilities they previously lacked."
|
||||
},
|
||||
"port-amador": {
|
||||
"subtitle": "Premium Nautical Experience",
|
||||
"description": "Premium digital experience for elite nautical services.",
|
||||
"challenge": "Port Amador required a luxury-grade digital experience that matched the exclusivity of their nautical services, with multi-language support and seamless booking integration.",
|
||||
"approach": "We crafted a bespoke website with cinematic imagery, smooth animations, and an integrated booking flow. The site was built on modern web technologies with a focus on performance and SEO for the competitive luxury maritime market.",
|
||||
"outcome": "The redesigned platform elevated Port Amador's digital presence to match their premium positioning, with a 60% improvement in page load times and significantly increased organic traffic."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
471
src/i18n/messages/es.json
Normal file
@@ -0,0 +1,471 @@
|
||||
{
|
||||
"meta": {
|
||||
"siteName": "LetsBe.",
|
||||
"home": {
|
||||
"title": "LetsBe. | Diseño Web a Medida, Software e Infraestructura Digital",
|
||||
"description": "Sitios web a medida, software diseñado para ti, integración de IA e infraestructura privada — diseñados, desarrollados y gestionados por un único equipo dedicado."
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre LetsBe. | Nuestra Historia y Enfoque",
|
||||
"description": "Un estudio digital fundado en Estados Unidos que crea sitios web, software y plataformas a medida para empresas que apuestan por la calidad."
|
||||
},
|
||||
"services": {
|
||||
"title": "Servicios | LetsBe. — Diseño Web, Software e Infraestructura",
|
||||
"description": "Diseño web a medida, software dedicado, automatización con IA e infraestructura privada — tres pilares de excelencia digital bajo un mismo techo."
|
||||
},
|
||||
"work": {
|
||||
"monaco-ocean": {
|
||||
"title": "Monaco Ocean Protection Challenge | LetsBe.",
|
||||
"description": "Plataforma de evaluación y análisis impulsada por IA para uno de los principales eventos de conservación del Mediterráneo."
|
||||
},
|
||||
"port-nimara": {
|
||||
"title": "Port Nimara — Hub Digital Marítimo | LetsBe.",
|
||||
"description": "Sitio web a medida y CRM completo para la gestión de clientes potenciales, asignación de amarres y operaciones de la marina."
|
||||
},
|
||||
"port-amador": {
|
||||
"title": "Port Amador — Experiencia Náutica Premium | LetsBe.",
|
||||
"description": "Sitio web e infraestructura digital privada para una marina premium — almacenamiento en la nube, correo electrónico y gestión de archivos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"services": "Servicios",
|
||||
"configure": "Empezar",
|
||||
"process": "Proceso",
|
||||
"work": "Proyectos",
|
||||
"about": "Nosotros",
|
||||
"startProject": "Iniciar un Proyecto",
|
||||
"bookCall": "Reservar una Llamada"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Tu sitio web. Tu software. Tu mundo digital {accentWord}.",
|
||||
"accentWord": "completo",
|
||||
"subtitle": "Sitios web a medida, software diseñado para ti y la infraestructura para gestionarlo todo - diseñados, desarrollados y mantenidos por un único equipo dedicado.",
|
||||
"cta": "Inicia tu Proyecto",
|
||||
"ctaSecondary": "Ver Nuestros Proyectos",
|
||||
"trust": "La confianza de empresas en todo el mundo"
|
||||
},
|
||||
"trustBar": {
|
||||
"customBuilt": {
|
||||
"title": "Diseñado Desde Cero",
|
||||
"description": "Sin plantillas, sin constructores de páginas. Cada sitio se diseña a medida y se desarrolla a mano para tu marca."
|
||||
},
|
||||
"privateInfra": {
|
||||
"title": "Todo es Tuyo",
|
||||
"description": "Tu código, tus datos, tus servidores. Nosotros lo construimos, tú lo posees - sin ataduras, sin sorpresas."
|
||||
},
|
||||
"aiPowered": {
|
||||
"title": "Un Equipo, De Principio a Fin",
|
||||
"description": "Sin malabares con freelancers. Un solo equipo gestiona diseño, código, alojamiento y soporte."
|
||||
},
|
||||
"rivieraBased": {
|
||||
"title": "IA Integrada",
|
||||
"description": "Funcionalidades inteligentes y automatización integradas directamente en tu sitio web y software."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"eyebrow": "Lo Que Hacemos",
|
||||
"title": "Diseñar. Desarrollar. Crecer.",
|
||||
"web": {
|
||||
"title": "Diseño y Desarrollo Web",
|
||||
"features": ["Diseño Web a Medida", "Desarrollo Responsive", "SEO y Rendimiento", "Gestión de Contenidos"]
|
||||
},
|
||||
"systems": {
|
||||
"title": "Software y Plataformas",
|
||||
"features": ["Herramientas de Gestión Empresarial", "CRM y Paneles de Control", "Sistemas de Reservas", "Integraciones API"]
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Alojamiento e Infraestructura",
|
||||
"features": ["Servidores Dedicados", "Email y Almacenamiento en la Nube", "Seguridad y Monitorización", "Soporte Continuo"]
|
||||
},
|
||||
"aiNarrative": "Y aplicamos IA en todo lo que hacemos - desde funcionalidades inteligentes en tu sitio web hasta la automatización que conecta todas tus herramientas."
|
||||
},
|
||||
"configurator": {
|
||||
"eyebrow": "Empezar",
|
||||
"title": "Cuéntanos qué necesitas.",
|
||||
"description": "Responde a unas pocas preguntas y prepararemos un briefing de proyecto personalizado para ti.",
|
||||
"step1": {
|
||||
"title": "¿Qué necesitas?",
|
||||
"subtitle": "Selecciona los servicios que se adaptan a tu proyecto."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Cuéntanos sobre tu proyecto",
|
||||
"subtitle": "Algunos detalles nos ayudan a preparar el enfoque adecuado."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Casi listo",
|
||||
"subtitle": "Revisa tus selecciones e indícanos cómo contactarte."
|
||||
},
|
||||
"complete": {
|
||||
"title": "Tu briefing de proyecto está listo",
|
||||
"subtitle": "Revisa tu bandeja de entrada - hemos enviado un briefing detallado a {email}",
|
||||
"nextStep": "Siguiente paso: hablemos de tu briefing",
|
||||
"bookSubtitle": "Reserva una llamada gratuita de 30 minutos para hablar de tu proyecto y los próximos pasos.",
|
||||
"bookCall": "Reservar una Llamada",
|
||||
"briefPreview": "Tu briefing de proyecto",
|
||||
"reachDirectly": "O contáctanos directamente en"
|
||||
},
|
||||
"howItWorks": "Cómo funciona",
|
||||
"noCommitment": "Sin compromiso",
|
||||
"selectService": "Selecciona al menos un servicio para continuar",
|
||||
"services": {
|
||||
"web": {
|
||||
"title": "Diseño y Desarrollo Web",
|
||||
"description": "Sitios web y aplicaciones a medida - diseñados desde cero, construidos para rendir y optimizados para que te encuentren."
|
||||
},
|
||||
"systems": {
|
||||
"title": "Software a Medida",
|
||||
"description": "CRM, plataformas de gestión y herramientas empresariales creadas en torno a cómo trabaja realmente tu equipo."
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Infraestructura Privada",
|
||||
"description": "Alojamiento dedicado, email, almacenamiento en la nube y la infraestructura sobre la que funciona tu negocio - totalmente tuya."
|
||||
}
|
||||
},
|
||||
"aiToggle": "Añadir Integración de IA",
|
||||
"aiDescription": "Funcionalidades de IA prácticas y automatización integradas directamente en tu sitio web y software.",
|
||||
"aiTypes": {
|
||||
"teammate": {
|
||||
"title": "IA como Compañero de Equipo",
|
||||
"description": "Un asistente de IA interno que ayuda a tu equipo a trabajar más rápido - automatiza tareas, responde preguntas, conecta tus herramientas."
|
||||
},
|
||||
"customer-facing": {
|
||||
"title": "IA de Cara al Cliente",
|
||||
"description": "Funcionalidades de IA con las que interactúan tus clientes - búsqueda inteligente, recomendaciones personalizadas, interfaces conversacionales."
|
||||
},
|
||||
"data-intelligence": {
|
||||
"title": "Inteligencia de Datos",
|
||||
"description": "IA que analiza los datos de tu negocio para extraer insights, tendencias y recomendaciones accionables."
|
||||
},
|
||||
"notsure": {
|
||||
"title": "Aún No Estoy Seguro",
|
||||
"description": "Sin problema - exploraremos juntos el mejor enfoque de IA durante la fase de descubrimiento."
|
||||
}
|
||||
},
|
||||
"industries": {
|
||||
"maritime": "Náutica / Yachting",
|
||||
"hospitality": "Hostelería",
|
||||
"technology": "Tecnología",
|
||||
"realestate": "Inmobiliaria",
|
||||
"finance": "Finanzas",
|
||||
"ngo": "ONG / Sin Ánimo de Lucro",
|
||||
"other": "Otro"
|
||||
},
|
||||
"timelines": {
|
||||
"asap": "Lo antes posible",
|
||||
"1-3months": "1–3 meses",
|
||||
"3-6months": "3–6 meses",
|
||||
"exploring": "Solo explorando"
|
||||
},
|
||||
"fields": {
|
||||
"industry": "Tu sector",
|
||||
"scope": "¿Qué quieres conseguir?",
|
||||
"scopeOptional": "(opcional)",
|
||||
"scopePlaceholder": "p. ej. Necesitamos reemplazar nuestro sistema de reservas actual y mejorar la experiencia del cliente…",
|
||||
"timeline": "Plazo",
|
||||
"name": "Tu nombre",
|
||||
"company": "Empresa",
|
||||
"email": "Correo electrónico",
|
||||
"phone": "Teléfono",
|
||||
"phoneOptional": "(opcional)",
|
||||
"contactPreference": "Método de contacto preferido",
|
||||
"contactEmail": "Email",
|
||||
"contactPhone": "Teléfono",
|
||||
"contactWhatsapp": "WhatsApp",
|
||||
"currentSiteUrl": "Sitio web actual",
|
||||
"currentSiteUrlOptional": "(opcional)",
|
||||
"currentSiteUrlPlaceholder": "https://tusitio.com",
|
||||
"currentSiteThoughts": "Opinión sobre tu sitio actual",
|
||||
"currentSiteThoughtsPlaceholder": "Qué funciona, qué no, qué te gustaría cambiar..."
|
||||
},
|
||||
"summary": {
|
||||
"heading": "Tus selecciones",
|
||||
"aiEnhancement": "Mejora con IA"
|
||||
},
|
||||
"generating": "Generando",
|
||||
"generatingSteps": {
|
||||
"preparingBrief": "Preparando tu briefing",
|
||||
"analyzingSite": "Analizando tu sitio web actual",
|
||||
"runningAudit": "Ejecutando auditoría de rendimiento",
|
||||
"generatingBrief": "Generando tu briefing personalizado"
|
||||
},
|
||||
"voice": {
|
||||
"agentName": "Asistente de proyectos de LetsBe",
|
||||
"endConversation": "Finalizar Conversación",
|
||||
"analyzingSite": "Analizando tu sitio...",
|
||||
"connecting": "Conectando...",
|
||||
"mute": "Silenciar",
|
||||
"unmute": "Activar micrófono",
|
||||
"generatingBrief": "Generando tu briefing...",
|
||||
"contactConfirm": "¿Es esto correcto?",
|
||||
"contactEdit": "Editar",
|
||||
"contactConfirmButton": "Sí, es correcto",
|
||||
"reconnect": "Reconectar",
|
||||
"connectionLost": "Conexión perdida. Tu conversación está guardada.",
|
||||
"briefComplete": "Briefing completado",
|
||||
"startConversation": "Iniciar conversación"
|
||||
},
|
||||
"privacy": "Tu información es privada y nunca será compartida.",
|
||||
"generateBrief": "Generar Mi Briefing",
|
||||
"nextStep": "Siguiente Paso",
|
||||
"back": "Volver",
|
||||
"startOver": "Empezar de Nuevo",
|
||||
"errors": {
|
||||
"general": "Algo salió mal. Por favor, inténtalo de nuevo.",
|
||||
"network": "Error de red. Comprueba tu conexión e inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"eyebrow": "Vamos a Descubrirlo",
|
||||
"title": "¿No sabes por dónde empezar?",
|
||||
"description": "Cuéntanos en qué estás pensando y lo resolveremos juntos. Recibirás un briefing personalizado al final.",
|
||||
"cta": "Hablemos",
|
||||
"privacy": "Las conversaciones de voz no se graban ni se almacenan."
|
||||
},
|
||||
"process": {
|
||||
"eyebrow": "Cómo Trabajamos",
|
||||
"title": "De la Idea al Lanzamiento",
|
||||
"steps": {
|
||||
"discovery": {
|
||||
"title": "Descubrimiento",
|
||||
"description": "Conocemos tu negocio, tus usuarios y lo que realmente necesitas construir."
|
||||
},
|
||||
"strategy": {
|
||||
"title": "Estrategia y Planificación",
|
||||
"description": "Definimos la arquitectura, elegimos las herramientas adecuadas y trazamos el plan de desarrollo."
|
||||
},
|
||||
"build": {
|
||||
"title": "Diseño y Desarrollo",
|
||||
"description": "Tu proyecto toma forma - píxel a píxel, función a función."
|
||||
},
|
||||
"launch": {
|
||||
"title": "Lanzamiento y Soporte",
|
||||
"description": "Desplegamos, monitorizamos y mantenemos todo funcionando sin problemas."
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"eyebrow": "Nuestros Proyectos",
|
||||
"title": "Proyectos Que Hemos Construido",
|
||||
"readCaseStudy": "Ver Proyecto",
|
||||
"comingSoon": "Próximamente",
|
||||
"projects": {
|
||||
"monaco": {
|
||||
"title": "Monaco Ocean Protection Challenge",
|
||||
"description": "Plataforma de evaluación y análisis con filtrado por IA, asignación automática de jueces y revisión inteligente de proyectos para uno de los principales eventos de conservación del Mediterráneo.",
|
||||
"tags": ["Software a Medida", "Integración de IA"]
|
||||
},
|
||||
"portNimara": {
|
||||
"title": "Port Nimara",
|
||||
"description": "Sitio web a medida y CRM completo para la gestión de clientes potenciales, asignación de amarres y operaciones de la marina.",
|
||||
"tags": ["Sitio Web", "Software a Medida"]
|
||||
},
|
||||
"portAmador": {
|
||||
"title": "Port Amador",
|
||||
"description": "Sitio web e infraestructura digital privada - almacenamiento en la nube, email y gestión de archivos para una marina premium.",
|
||||
"tags": ["Sitio Web", "Infraestructura"]
|
||||
}
|
||||
},
|
||||
"comingSoonProjects": {
|
||||
"riviera": {
|
||||
"title": "Plataforma de Gestión Inmobiliaria",
|
||||
"subtitle": "Próximamente"
|
||||
},
|
||||
"sophia": {
|
||||
"title": "SaaS Empresarial — Austin, TX",
|
||||
"subtitle": "Lanzamiento en Q4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"philosophy": {
|
||||
"eyebrow": "Por Qué Nosotros",
|
||||
"title": "Hacemos las cosas de otra manera.",
|
||||
"subtitle": "La mayoría de las agencias te dan una plantilla y lo llaman personalizado. Creemos que tu negocio merece algo mejor - diseño real, ingeniería real y un equipo que sigue ahí después del lanzamiento.",
|
||||
"ownership": {
|
||||
"title": "Construido para que sea Tuyo",
|
||||
"description": "Todo lo que construimos es tuyo. Tu código, tus datos, tu infraestructura - sin ataduras, sin dependencias de plataformas, sin sorpresas."
|
||||
},
|
||||
"craftsmanship": {
|
||||
"title": "Artesanía por Encima de la Comodidad",
|
||||
"description": "Escribimos código limpio, hecho a mano y optimizado para velocidad y motores de búsqueda. Sin constructores de páginas, sin temas sobrecargados, sin atajos."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "Una Sola Relación",
|
||||
"description": "Desde el primer diseño hasta el soporte continuo - un equipo que conoce tu negocio de arriba abajo. Sin traspasos, sin briefs perdidos por el camino."
|
||||
},
|
||||
"quote": "Construimos tecnología que trabaja para tu negocio - no al revés.",
|
||||
"foundedLocation": "Matt Ciaccio, Fundador"
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Hablemos",
|
||||
"title": "¿Listo para construir algo grande?",
|
||||
"subtitle": "Cuéntanos en qué estás trabajando. Sin presentaciones, sin presión - solo una conversación honesta sobre lo que es posible.",
|
||||
"cta": "Inicia tu Proyecto",
|
||||
"configure": "Inicia tu Proyecto",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "Sin compromiso."
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Sitios web, software y plataformas digitales a medida - diseñados y desarrollados para empresas que se niegan a conformarse con menos.",
|
||||
"location": "Fundado en Estados Unidos. Al servicio de clientes en todo el mundo.",
|
||||
"services": "Servicios",
|
||||
"studio": "Studio",
|
||||
"connect": "Contacto",
|
||||
"serviceLinks": {
|
||||
"designDev": "Diseño y Desarrollo Web",
|
||||
"customSystems": "Software a Medida",
|
||||
"infrastructure": "Infraestructura Privada",
|
||||
"aiAutomation": "Integración de IA"
|
||||
},
|
||||
"privacy": "Política de Privacidad",
|
||||
"terms": "Términos de Servicio"
|
||||
},
|
||||
"aboutPage": {
|
||||
"hero": {
|
||||
"eyebrow": "Sobre LetsBe.",
|
||||
"title": "Las grandes empresas merecen grandes socios digitales.",
|
||||
"subtitle": "Diseñamos y desarrollamos sitios web, software y plataformas digitales a medida para empresas que apuestan por la calidad — y que buscan un equipo que haga lo mismo."
|
||||
},
|
||||
"story": {
|
||||
"eyebrow": "Nuestra Historia",
|
||||
"title": "Construido para empresas como la tuya.",
|
||||
"p1": "LetsBe. nació de una convicción sencilla: las empresas ambiciosas merecen herramientas digitales tan cuidadas como el trabajo que realizan. No plantillas. No plataformas genéricas. Diseño e ingeniería reales, construidos desde cero.",
|
||||
"p2": "Nuestros primeros clientes eran fundadores y operadores que necesitaban algo más que un sitio web — un socio técnico capaz de diseñar, desarrollar, alojar y mantener todo bajo un mismo techo. Esos proyectos dieron forma a cómo trabajamos hoy.",
|
||||
"p3": "Construimos plataformas pensadas para ser poseídas, no alquiladas. Documentamos todo, entregamos bases de código que perduran más allá del proyecto y nunca atamos a los clientes a sistemas que no pueden abandonar. Eso no es una característica — es cómo creemos que debería funcionar el negocio.",
|
||||
"quote": "Construir menos cosas. Construirlas mejor. Construirlas para que duren.",
|
||||
"quoteAttrib": "Principio fundacional de LetsBe."
|
||||
},
|
||||
"pillars": {
|
||||
"eyebrow": "Nuestras Convicciones",
|
||||
"title": "En Qué Creemos",
|
||||
"subtitle": "Tres principios detrás de cada proyecto que emprendemos.",
|
||||
"craftsmanship": {
|
||||
"title": "La Artesanía Primero",
|
||||
"description": "La diferencia entre un sitio web que funciona y uno que perdura es la artesanía. Nos preocupamos por la tipografía, las transiciones, el rendimiento, los casos límite. Cada interfaz que entregamos es algo de lo que nos enorgullecemos firmar."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "Un Equipo, Todo",
|
||||
"description": "Diseño, desarrollo, alojamiento, infraestructura — un solo equipo, un solo punto de contacto, un solo estándar de calidad. Sin traspasos entre agencias. Sin malabarismos con freelancers. Solo personas que se preocupan por el conjunto."
|
||||
},
|
||||
"ownership": {
|
||||
"title": "Construido para que sea Tuyo",
|
||||
"description": "Todo lo que construimos es tuyo — el código, los datos, la infraestructura. Sin vendor lock-in, sin dependencias de plataformas. Entregamos trabajo que perdura más allá del proyecto."
|
||||
}
|
||||
},
|
||||
"quote": {
|
||||
"text": "No solo construimos sitios web — construimos los cimientos sobre los que funciona tu negocio.",
|
||||
"attrib": "Filosofía fundacional de LetsBe."
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Trabaja Con Nosotros",
|
||||
"title": "Construyamos algo juntos.",
|
||||
"subtitle": "Tanto si tienes un briefing claro como si solo tienes una idea inicial, nos encantaría hablar sobre lo que es posible.",
|
||||
"primary": "Inicia tu proyecto",
|
||||
"secondary": "Reservar una llamada"
|
||||
}
|
||||
},
|
||||
"servicesPage": {
|
||||
"hero": {
|
||||
"eyebrow": "Nuestros Servicios",
|
||||
"title": "Todo lo que tu negocio",
|
||||
"titleAccent": "necesita en línea.",
|
||||
"subtitle": "Diseñamos sitios web a medida, desarrollamos software dedicado y gestionamos la infraestructura que lo sustenta todo — un solo equipo, un solo estándar de calidad, nada externalizado."
|
||||
},
|
||||
"pillars": [
|
||||
{
|
||||
"id": "design-development",
|
||||
"numeral": "01",
|
||||
"title": "Diseño y Desarrollo Web",
|
||||
"description": "Tu sitio web no debería parecerse al de todos los demás — ni estar construido como el de todos los demás. Diseñamos y desarrollamos sitios web y aplicaciones web desde un lienzo en blanco, creando cada layout, cada interacción y cada página con intención. El resultado es rápido, amigable para los motores de búsqueda y construido para crecer con tu negocio. Tanto si necesitas un sitio de marketing que convierte, una aplicación web en la que tu equipo confía, o una plataforma de e-commerce que escala — lo construimos desde cero, y lo construimos para que dure.",
|
||||
"features": [
|
||||
{ "icon": "Palette", "title": "Diseño a Medida", "description": "Cada layout, componente e interacción se diseña para tu marca. Sin temas, sin plantillas, sin atajos." },
|
||||
{ "icon": "Globe", "title": "Aplicaciones Web", "description": "Aplicaciones modernas y responsive desarrolladas con las últimas tecnologías — rápidas, fiables y listas para escalar." },
|
||||
{ "icon": "ShoppingCart", "title": "E-Commerce", "description": "Tiendas personalizadas, flujos de pago y plataformas multi-divisa construidas para el comercio online serio." },
|
||||
{ "icon": "Zap", "title": "Rendimiento y SEO", "description": "Tiempos de carga rápidos, código limpio y optimización para motores de búsqueda integrados en los cimientos — no añadidos después." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "custom-systems",
|
||||
"numeral": "02",
|
||||
"title": "Software y Plataformas",
|
||||
"description": "El software genérico hace suposiciones sobre cómo funciona tu negocio. Nosotros no. Cuando las hojas de cálculo y las herramientas genéricas dejan de ser suficientes, construimos exactamente el sistema que tu equipo necesita — diseñado en torno a tu flujo de trabajo, no al de otra persona. Desde CRM adaptados a tu proceso de ventas, hasta plataformas de gestión que reemplazan tres suscripciones distintas, pasando por integraciones que conectan tus herramientas existentes — todo lo que construimos es tuyo, completamente documentado y construido para durar.",
|
||||
"features": [
|
||||
{ "icon": "Database", "title": "CRM y Herramientas de Gestión", "description": "Gestión de relaciones y pipeline construida en torno a cómo trabaja realmente tu equipo — no a cómo cree una plataforma genérica que deberías trabajar." },
|
||||
{ "icon": "Code2", "title": "Software a Medida", "description": "Desde plataformas de reservas hasta herramientas internas o productos SaaS completos — diseñados específicamente para tu negocio." },
|
||||
{ "icon": "GitBranch", "title": "Integraciones y APIs", "description": "Conectamos tus herramientas existentes y construimos los puentes entre sistemas para que todo funcione conjuntamente." },
|
||||
{ "icon": "Wrench", "title": "Paneles y Automatización", "description": "Paneles de administración, herramientas de informes y automatización de flujos de trabajo que le dan a tu equipo una ventaja competitiva." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "infrastructure",
|
||||
"numeral": "03",
|
||||
"title": "Alojamiento e Infraestructura",
|
||||
"description": "Tu sitio web y tu software necesitan un hogar — y creemos que deberías ser tú quien lo posea. Configuramos y gestionamos servidores dedicados, correo electrónico, almacenamiento en la nube y toda la infraestructura sobre la que funciona tu negocio. Sin alojamiento compartido, sin misteriosas dependencias de terceros. Sabes dónde viven tus datos, quién tiene acceso y que alguien está vigilando el panel de control las 24 horas del día.",
|
||||
"features": [
|
||||
{ "icon": "Server", "title": "Alojamiento Dedicado", "description": "Servidores privados gestionados para tu negocio — sin alojamiento compartido, sin vecinos molestos, sin sorpresas." },
|
||||
{ "icon": "Shield", "title": "Tus Datos, Tu Control", "description": "Posees tus datos y sabes exactamente dónde están. Acceso completo, total transparencia, sin lock-in." },
|
||||
{ "icon": "Lock", "title": "Seguridad y Protección", "description": "Seguridad seria, monitorización proactiva y protección integrada en tu infraestructura desde el primer día." },
|
||||
{ "icon": "Settings", "title": "Monitorización y Soporte", "description": "Monitorización proactiva, actualizaciones regulares y soporte continuo para que nunca tengas que preocuparte por el tiempo de actividad." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"eyebrow": "Capa Inteligente",
|
||||
"title": "IA Integrada en Todo",
|
||||
"subtitle": "Tu plataforma, más inteligente.",
|
||||
"description": "Integramos IA directamente en los sitios web y el software que construimos para ti. No como palabra de moda ni como complemento — como funcionalidades prácticas que ahorran tiempo a tu equipo y ofrecen a tus clientes una mejor experiencia.",
|
||||
"bottomNote": "Cada funcionalidad de IA se adapta a tu negocio — tus datos permanecen en tus servidores",
|
||||
"capabilities": [
|
||||
{ "id": "ai-teammate", "title": "IA como Compañero de Equipo", "description": "Un asistente de IA integrado en tu flujo de trabajo — automatiza tareas repetitivas, saca a la superficie la información que tu equipo necesita y conecta tus herramientas." },
|
||||
{ "id": "customer-facing-ai", "title": "IA de Cara al Cliente", "description": "Funcionalidades inteligentes para tus clientes — búsqueda inteligente, recomendaciones personalizadas e interfaces conversacionales disponibles las 24 horas." },
|
||||
{ "id": "data-intelligence", "title": "Inteligencia de Datos", "description": "IA que te ayuda a entender tus datos — informes automatizados, detección de tendencias e insights sobre los que puedes actuar de verdad." }
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Hablemos",
|
||||
"title": "¿Listo para empezar?",
|
||||
"subtitle": "Responde a unas pocas preguntas y prepararemos un briefing de proyecto personalizado para ti — sin compromiso, solo claridad.",
|
||||
"primary": "Inicia tu Proyecto",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "Sin compromiso — solo una conversación sobre lo que es posible."
|
||||
}
|
||||
},
|
||||
"caseStudy": {
|
||||
"labels": {
|
||||
"challenge": "El Reto",
|
||||
"challengeHeading": "El problema que nos propusimos resolver",
|
||||
"approach": "Nuestro Enfoque",
|
||||
"approachHeading": "Cómo lo pensamos",
|
||||
"outcome": "El Resultado",
|
||||
"outcomeHeading": "Lo que entregamos",
|
||||
"builtWith": "Desarrollado con",
|
||||
"yourTurn": "Tu Turno",
|
||||
"ctaTitle": "¿Listo para construir algo así?",
|
||||
"ctaSubtitle": "Cada proyecto empieza con una conversación. Cuéntanos en qué estás trabajando y encontraremos la mejor manera de hacerlo realidad.",
|
||||
"ctaButton": "Inicia tu proyecto"
|
||||
},
|
||||
"projects": {
|
||||
"monaco-ocean": {
|
||||
"subtitle": "Plataforma de Evaluación y Análisis con IA",
|
||||
"description": "Un sistema completo de evaluación y análisis con integración avanzada de jurado de IA para uno de los eventos de conservación más prestigiosos del Mediterráneo.",
|
||||
"challenge": "El Monaco Ocean Protection Challenge necesitaba una plataforma moderna para gestionar las candidaturas, coordinar jueces en distintas zonas horarias y proporcionar una evaluación asistida por IA de las propuestas de conservación — todo ello manteniendo el prestigio y la seguridad que se esperan de una institución monegasca.",
|
||||
"approach": "Construimos una plataforma personalizada desde cero usando Next.js y una infraestructura PostgreSQL privada. El módulo de jurado de IA utiliza procesamiento del lenguaje natural para pre-seleccionar candidaturas y generar informes de resumen, mientras que los jueces humanos conservan el control total sobre las decisiones finales.",
|
||||
"outcome": "La plataforma procesó más de 200 candidaturas en su primera temporada, reduciendo la carga de trabajo de los jueces en un 40% gracias a la pre-selección asistida por IA. El cliente elogió la fiabilidad del sistema y la elegancia de su interfaz."
|
||||
},
|
||||
"port-nimara": {
|
||||
"subtitle": "Hub Digital Marítimo",
|
||||
"description": "Hub digital escalable para logística marítima.",
|
||||
"challenge": "Port Nimara necesitaba una presencia digital moderna que pudiera funcionar tanto como sitio de marketing como hub operativo para consultas de amarres, gestión de eventos y comunicaciones con socios.",
|
||||
"approach": "Diseñamos y desarrollamos una aplicación Nuxt.js de alto rendimiento con un CMS headless para la gestión de contenidos, integrada con sus sistemas de planificación marítima existentes a través de middleware API personalizado.",
|
||||
"outcome": "La nueva plataforma triplicó las consultas de amarres en línea y proporcionó a la autoridad portuaria capacidades de gestión de contenidos en tiempo real de las que antes carecía."
|
||||
},
|
||||
"port-amador": {
|
||||
"subtitle": "Experiencia Náutica Premium",
|
||||
"description": "Experiencia digital premium para servicios náuticos de élite.",
|
||||
"challenge": "Port Amador requería una experiencia digital de categoría lujo que estuviera a la altura de la exclusividad de sus servicios náuticos, con soporte multiidioma e integración de reservas sin fricciones.",
|
||||
"approach": "Creamos un sitio web a medida con imágenes cinematográficas, animaciones fluidas y un flujo de reservas integrado. El sitio se construyó sobre tecnologías web modernas con un enfoque en el rendimiento y el SEO para el competitivo mercado marítimo de lujo.",
|
||||
"outcome": "La plataforma rediseñada elevó la presencia digital de Port Amador a su posicionamiento premium, con una mejora del 60% en los tiempos de carga de las páginas y un aumento significativo del tráfico orgánico."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,33 @@
|
||||
{
|
||||
"meta": {
|
||||
"siteName": "LetsBe.",
|
||||
"home": {
|
||||
"title": "LetsBe. | Design Web Sur Mesure, Logiciels & Infrastructure Digitale",
|
||||
"description": "Sites web sur mesure, logiciels dédiés, intégration IA et infrastructure privée — conçus, développés et gérés par une seule équipe."
|
||||
},
|
||||
"about": {
|
||||
"title": "À Propos de LetsBe. | Notre Histoire & Approche",
|
||||
"description": "Un studio digital fondé aux États-Unis, créant des sites web, logiciels et plateformes sur mesure pour les entreprises exigeantes."
|
||||
},
|
||||
"services": {
|
||||
"title": "Services | LetsBe. — Design Web, Logiciels & Infrastructure",
|
||||
"description": "Design web sur mesure, logiciels dédiés, automatisation IA et infrastructure privée — trois piliers d'excellence digitale sous un même toit."
|
||||
},
|
||||
"work": {
|
||||
"monaco-ocean": {
|
||||
"title": "Monaco Ocean Protection Challenge | LetsBe.",
|
||||
"description": "Plateforme de jugement et d'analyse propulsée par l'IA pour l'un des événements de conservation majeurs de la Méditerranée."
|
||||
},
|
||||
"port-nimara": {
|
||||
"title": "Port Nimara — Hub Digital Maritime | LetsBe.",
|
||||
"description": "Site web sur mesure et CRM complet pour la gestion des prospects, l'attribution des places et les opérations de la marina."
|
||||
},
|
||||
"port-amador": {
|
||||
"title": "Port Amador — Expérience Nautique Premium | LetsBe.",
|
||||
"description": "Site web et infrastructure digitale privée pour une marina premium — stockage cloud, email et gestion de fichiers."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"services": "Services",
|
||||
"configure": "Démarrer",
|
||||
@@ -9,47 +38,47 @@
|
||||
"bookCall": "Réserver un Appel"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Sites web, logiciels et infrastructure — conçus et développés {accentWord} autour de vous.",
|
||||
"accentWord": "entièrement",
|
||||
"subtitle": "Nous concevons des sites web sur mesure, développons des logiciels dédiés et gérons une infrastructure privée qui vous appartient. Une seule équipe, du premier pixel au déploiement final.",
|
||||
"title": "Votre site web. Vos logiciels. {accentWord} votre univers digital.",
|
||||
"accentWord": "Tout",
|
||||
"subtitle": "Sites web sur mesure, logiciels dédiés et l'infrastructure pour tout faire tourner - conçus, développés et gérés par une seule équipe.",
|
||||
"cta": "Démarrer Votre Projet",
|
||||
"ctaSecondary": "Voir Nos Réalisations",
|
||||
"trust": "La confiance des entreprises de la Riviera"
|
||||
"trust": "La confiance d'entreprises dans le monde entier"
|
||||
},
|
||||
"trustBar": {
|
||||
"customBuilt": {
|
||||
"title": "Conçu Sur Mesure",
|
||||
"description": "Chaque projet est designé et codé selon vos besoins exacts. Pas de templates, pas de raccourcis."
|
||||
"title": "Conçu De Zéro",
|
||||
"description": "Pas de templates, pas de constructeurs de pages. Chaque site est conçu sur mesure et développé à la main pour votre marque."
|
||||
},
|
||||
"privateInfra": {
|
||||
"title": "Tout Vous Appartient",
|
||||
"description": "Serveurs privés, vos données, vos outils — entièrement contrôlés et détenus par vous."
|
||||
"description": "Votre code, vos données, vos serveurs. On construit, vous possédez - pas de verrouillage, pas de surprises."
|
||||
},
|
||||
"aiPowered": {
|
||||
"title": "Une Seule Équipe",
|
||||
"description": "Design, développement, hébergement et support par une seule équipe dédiée."
|
||||
"title": "Une Équipe, De A à Z",
|
||||
"description": "Fini le jonglage entre freelances. Une seule équipe gère design, code, hébergement et support."
|
||||
},
|
||||
"rivieraBased": {
|
||||
"title": "L'IA Là Où Ça Compte",
|
||||
"description": "Fonctionnalités intelligentes et automatisation intégrées directement dans vos systèmes."
|
||||
"title": "IA Intégrée",
|
||||
"description": "Fonctionnalités intelligentes et automatisation intégrées directement dans votre site web et vos logiciels."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"eyebrow": "Ce Que Nous Faisons",
|
||||
"title": "Concevoir. Développer. Héberger.",
|
||||
"title": "Concevoir. Développer. Grandir.",
|
||||
"web": {
|
||||
"title": "Sites Web & Applications",
|
||||
"features": ["Design Web Sur Mesure", "Développement Responsive", "SEO & Marketing Digital", "Gestion de Contenu"]
|
||||
"title": "Design & Développement Web",
|
||||
"features": ["Design de Site Sur Mesure", "Développement Responsive", "SEO & Performance", "Gestion de Contenu"]
|
||||
},
|
||||
"systems": {
|
||||
"title": "Logiciels Sur Mesure",
|
||||
"features": ["Plateformes de Gestion", "CRM & Outils Métier", "Réservation & Planning", "Intégrations API"]
|
||||
"title": "Logiciels & Plateformes",
|
||||
"features": ["Outils de Gestion", "CRM & Tableaux de Bord", "Systèmes de Réservation", "Intégrations API"]
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Infrastructure Privée",
|
||||
"features": ["Serveurs Dédiés", "Email & Stockage Cloud", "Sécurité & Chiffrement", "Monitoring & Support"]
|
||||
"title": "Hébergement & Infrastructure",
|
||||
"features": ["Serveurs Dédiés", "Email & Stockage Cloud", "Sécurité & Monitoring", "Support Continu"]
|
||||
},
|
||||
"aiNarrative": "Et quand vous êtes prêt, nous intégrons l'IA dans l'ensemble — des fonctionnalités intelligentes dans vos logiciels à l'automatisation qui connecte tous vos outils."
|
||||
"aiNarrative": "Et nous intégrons l'IA dans tout ce que nous faisons - des fonctionnalités intelligentes dans votre site web à l'automatisation qui connecte tous vos outils."
|
||||
},
|
||||
"configurator": {
|
||||
"eyebrow": "Démarrer",
|
||||
@@ -69,9 +98,9 @@
|
||||
},
|
||||
"complete": {
|
||||
"title": "Votre brief projet est prêt",
|
||||
"subtitle": "Vérifiez votre boîte mail — nous avons envoyé un brief détaillé à {email}",
|
||||
"bookTitle": "Réservez une Consultation",
|
||||
"bookSubtitle": "30 minutes pour discuter de votre brief avec notre équipe",
|
||||
"subtitle": "Vérifiez votre boîte mail - nous avons envoyé un brief détaillé à {email}",
|
||||
"nextStep": "Prochaine étape : discutons de votre brief",
|
||||
"bookSubtitle": "Réservez un appel gratuit de 30 minutes pour discuter de votre projet.",
|
||||
"bookCall": "Réserver un Appel",
|
||||
"briefPreview": "Votre brief projet",
|
||||
"reachDirectly": "Ou contactez-nous directement à"
|
||||
@@ -82,27 +111,27 @@
|
||||
"services": {
|
||||
"web": {
|
||||
"title": "Design & Développement Web",
|
||||
"description": "Sites web et applications sur mesure — conçus de zéro, optimisés pour la performance et le référencement."
|
||||
"description": "Sites web et applications sur mesure - conçus de zéro, performants et optimisés pour être trouvés."
|
||||
},
|
||||
"systems": {
|
||||
"title": "Logiciels Sur Mesure",
|
||||
"description": "Plateformes de gestion, CRM et outils métier conçus pour correspondre exactement à votre façon de travailler."
|
||||
"description": "CRM, plateformes de gestion et outils métier conçus autour de la façon dont votre équipe travaille vraiment."
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Infrastructure Privée",
|
||||
"description": "Serveurs dédiés avec email, stockage cloud et outils métier — entièrement détenus et contrôlés par vous."
|
||||
"description": "Hébergement dédié, email, stockage cloud et l'infrastructure sur laquelle votre entreprise fonctionne - entièrement à vous."
|
||||
}
|
||||
},
|
||||
"aiToggle": "Ajouter l'Intégration IA",
|
||||
"aiDescription": "Fonctionnalités intelligentes et automatisation intégrées directement dans vos systèmes.",
|
||||
"aiDescription": "Fonctionnalités IA pratiques et automatisation intégrées directement dans votre site web et vos logiciels.",
|
||||
"aiTypes": {
|
||||
"teammate": {
|
||||
"title": "IA Coéquipier",
|
||||
"description": "Un assistant IA interne qui aide votre équipe à travailler plus vite — automatise les tâches, répond aux questions, connecte vos outils."
|
||||
"description": "Un assistant IA interne qui aide votre équipe à travailler plus vite - automatise les tâches, répond aux questions, connecte vos outils."
|
||||
},
|
||||
"customer-facing": {
|
||||
"title": "IA Client",
|
||||
"description": "Des fonctionnalités IA avec lesquelles vos clients interagissent — recherche intelligente, recommandations personnalisées, interfaces conversationnelles."
|
||||
"description": "Des fonctionnalités IA avec lesquelles vos clients interagissent - recherche intelligente, recommandations personnalisées, interfaces conversationnelles."
|
||||
},
|
||||
"data-intelligence": {
|
||||
"title": "Intelligence Données",
|
||||
@@ -110,7 +139,7 @@
|
||||
},
|
||||
"notsure": {
|
||||
"title": "Pas Encore Sûr",
|
||||
"description": "Pas de problème — nous explorerons ensemble la meilleure approche IA lors de la phase de découverte."
|
||||
"description": "Pas de problème - nous explorerons ensemble la meilleure approche IA lors de la phase de découverte."
|
||||
}
|
||||
},
|
||||
"industries": {
|
||||
@@ -136,13 +165,46 @@
|
||||
"timeline": "Calendrier",
|
||||
"name": "Votre nom",
|
||||
"company": "Entreprise",
|
||||
"email": "Adresse email"
|
||||
"email": "Adresse email",
|
||||
"phone": "Téléphone",
|
||||
"phoneOptional": "(facultatif)",
|
||||
"contactPreference": "Mode de contact préféré",
|
||||
"contactEmail": "Email",
|
||||
"contactPhone": "Téléphone",
|
||||
"contactWhatsapp": "WhatsApp",
|
||||
"currentSiteUrl": "Site web actuel",
|
||||
"currentSiteUrlOptional": "(facultatif)",
|
||||
"currentSiteUrlPlaceholder": "https://votresite.com",
|
||||
"currentSiteThoughts": "Vos impressions sur votre site actuel",
|
||||
"currentSiteThoughtsPlaceholder": "Ce qui fonctionne, ce qui ne fonctionne pas, ce que vous aimeriez changer..."
|
||||
},
|
||||
"summary": {
|
||||
"heading": "Vos sélections",
|
||||
"aiEnhancement": "Enrichissement IA"
|
||||
},
|
||||
"generating": "Génération",
|
||||
"generatingSteps": {
|
||||
"preparingBrief": "Préparation de votre brief",
|
||||
"analyzingSite": "Analyse de votre site actuel",
|
||||
"runningAudit": "Audit de performance en cours",
|
||||
"generatingBrief": "Génération de votre brief personnalisé"
|
||||
},
|
||||
"voice": {
|
||||
"agentName": "Assistant projet LetsBe",
|
||||
"endConversation": "Terminer la conversation",
|
||||
"analyzingSite": "Analyse de votre site...",
|
||||
"connecting": "Connexion en cours...",
|
||||
"mute": "Couper le micro",
|
||||
"unmute": "Activer le micro",
|
||||
"generatingBrief": "Génération de votre brief...",
|
||||
"contactConfirm": "Est-ce correct ?",
|
||||
"contactEdit": "Modifier",
|
||||
"contactConfirmButton": "C'est correct",
|
||||
"reconnect": "Reconnecter",
|
||||
"connectionLost": "Connexion perdue. Votre conversation est sauvegardée.",
|
||||
"briefComplete": "Brief terminé",
|
||||
"startConversation": "Démarrer la conversation"
|
||||
},
|
||||
"privacy": "Vos informations sont privées et ne seront jamais partagées.",
|
||||
"generateBrief": "Générer Mon Brief",
|
||||
"nextStep": "Étape Suivante",
|
||||
@@ -153,6 +215,13 @@
|
||||
"network": "Erreur réseau. Veuillez vérifier votre connexion et réessayer."
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"eyebrow": "Trouvons ensemble",
|
||||
"title": "Vous ne savez pas par où commencer ?",
|
||||
"description": "Dites-nous ce que vous avez en tête et on trouvera la solution ensemble. Vous recevrez un brief personnalisé à la fin.",
|
||||
"cta": "Discutons",
|
||||
"privacy": "Les conversations vocales ne sont ni enregistrées ni stockées."
|
||||
},
|
||||
"process": {
|
||||
"eyebrow": "Notre Méthode",
|
||||
"title": "De l'Idée au Lancement",
|
||||
@@ -167,7 +236,7 @@
|
||||
},
|
||||
"build": {
|
||||
"title": "Design & Construction",
|
||||
"description": "Votre projet prend forme — pixel par pixel, fonctionnalité par fonctionnalité."
|
||||
"description": "Votre projet prend forme - pixel par pixel, fonctionnalité par fonctionnalité."
|
||||
},
|
||||
"launch": {
|
||||
"title": "Lancement & Support",
|
||||
@@ -193,62 +262,210 @@
|
||||
},
|
||||
"portAmador": {
|
||||
"title": "Port Amador",
|
||||
"description": "Site web et infrastructure digitale privée — stockage cloud, email et gestion de fichiers pour une marina premium.",
|
||||
"description": "Site web et infrastructure digitale privée - stockage cloud, email et gestion de fichiers pour une marina premium.",
|
||||
"tags": ["Site Web", "Infrastructure"]
|
||||
}
|
||||
},
|
||||
"comingSoonProjects": {
|
||||
"riviera": {
|
||||
"title": "Projet Confidentiel Riviera",
|
||||
"title": "Plateforme de Gestion Immobilière",
|
||||
"subtitle": "Bientôt Disponible"
|
||||
},
|
||||
"sophia": {
|
||||
"title": "Startup IA Sophia Antipolis",
|
||||
"title": "SaaS Entreprise — Austin, TX",
|
||||
"subtitle": "Lancement T4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"philosophy": {
|
||||
"eyebrow": "Pourquoi C'est Important",
|
||||
"title": "Vos outils devraient vous appartenir.",
|
||||
"subtitle": "La plupart des entreprises louent leur vie digitale à une dizaine de plateformes différentes. Nous pensons qu'il y a mieux — un monde où vous possédez vos données, contrôlez votre infrastructure et n'êtes enfermé dans la grille tarifaire de personne.",
|
||||
"eyebrow": "Pourquoi Nous",
|
||||
"title": "On fait les choses différemment.",
|
||||
"subtitle": "La plupart des agences vous donnent un template et appellent ça du sur-mesure. Nous pensons que votre entreprise mérite mieux - un vrai design, une vraie ingénierie, et une équipe qui reste après le lancement.",
|
||||
"ownership": {
|
||||
"title": "Maîtrisez Votre Stack",
|
||||
"description": "Nous vous libérons des abonnements SaaS dispersés pour vous installer sur une infrastructure privée — serveurs, email, stockage cloud et outils que vous contrôlez."
|
||||
"title": "Construit Pour Vous",
|
||||
"description": "Tout ce que nous construisons vous appartient. Votre code, vos données, votre infrastructure - pas de verrouillage, pas de dépendances, pas de surprises."
|
||||
},
|
||||
"craftsmanship": {
|
||||
"title": "Pas de Raccourcis",
|
||||
"description": "Nous écrivons du code propre, artisanal, optimisé pour la vitesse et le référencement. Pas de constructeurs de pages, pas de thèmes surchargés, pas de dette technique."
|
||||
"title": "L'Artisanat Avant Tout",
|
||||
"description": "Nous écrivons du code propre, construit à la main, optimisé pour la vitesse et le référencement. Pas de constructeurs de pages, pas de thèmes surchargés, pas de raccourcis."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "Une Seule Relation",
|
||||
"description": "Du design initial au développement jusqu'au support continu — une seule équipe qui connaît votre activité de A à Z."
|
||||
"description": "Du premier design au support continu - une seule équipe qui connaît votre activité de A à Z. Pas de sous-traitance, pas de briefs perdus en route."
|
||||
},
|
||||
"quote": "Nous construisons la technologie qui travaille pour votre entreprise — pas l'inverse.",
|
||||
"foundedLocation": "Basé sur la Côte d'Azur"
|
||||
"quote": "Nous construisons la technologie qui travaille pour votre entreprise - pas l'inverse.",
|
||||
"foundedLocation": "Matt Ciaccio, Fondateur"
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Parlons-en",
|
||||
"title": "Prêt à construire quelque chose ?",
|
||||
"subtitle": "Dites-nous ce dont vous avez besoin. Pas de slides, pas de pression — juste une conversation sur ce qui est possible.",
|
||||
"title": "Prêt à construire quelque chose de grand ?",
|
||||
"subtitle": "Dites-nous sur quoi vous travaillez. Pas de slides, pas de pression - juste une conversation honnête sur ce qui est possible.",
|
||||
"cta": "Démarrer Votre Projet",
|
||||
"configure": "Démarrer Votre Projet",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "Sans engagement."
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Sites web, logiciels et infrastructure sur mesure pour les entreprises qui veulent maîtriser leur avenir digital.",
|
||||
"location": "Fondé aux États-Unis. Au service de la Côte d'Azur et au-delà.",
|
||||
"tagline": "Sites web, logiciels et plateformes digitales sur mesure - conçus et développés pour les entreprises qui refusent de se contenter de moins.",
|
||||
"location": "Fondé aux États-Unis. Au service de clients dans le monde entier.",
|
||||
"services": "Services",
|
||||
"studio": "Studio",
|
||||
"connect": "Contact",
|
||||
"serviceLinks": {
|
||||
"designDev": "Sites Web & Applications",
|
||||
"designDev": "Design & Développement Web",
|
||||
"customSystems": "Logiciels Sur Mesure",
|
||||
"infrastructure": "Infrastructure Privée",
|
||||
"aiAutomation": "Intégration IA"
|
||||
},
|
||||
"privacy": "Politique de Confidentialité",
|
||||
"terms": "Conditions d'Utilisation"
|
||||
},
|
||||
"aboutPage": {
|
||||
"hero": {
|
||||
"eyebrow": "À propos de LetsBe.",
|
||||
"title": "Les grandes entreprises méritent de grands partenaires digitaux.",
|
||||
"subtitle": "Nous concevons et développons des sites web, des logiciels et des plateformes digitales sur mesure pour les entreprises qui exigent la qualité — et qui veulent une équipe qui la partage."
|
||||
},
|
||||
"story": {
|
||||
"eyebrow": "Notre Histoire",
|
||||
"title": "Conçu pour des entreprises comme la vôtre.",
|
||||
"p1": "LetsBe. est née d'une conviction simple : les entreprises ambitieuses méritent des outils digitaux aussi soigneusement pensés que le travail qu'elles accomplissent. Pas de templates. Pas de plateformes standardisées. Un vrai design et une vraie ingénierie, construits de zéro.",
|
||||
"p2": "Nos premiers clients étaient des fondateurs et des dirigeants qui avaient besoin de plus qu'un site web — ils avaient besoin d'un partenaire technique capable de concevoir, développer, héberger et maintenir l'ensemble sous un même toit. Ces projets ont façonné notre façon de travailler aujourd'hui.",
|
||||
"p3": "Nous construisons des plateformes faites pour être possédées, pas louées. Nous documentons tout, nous remettons des bases de code qui perdurent au-delà de la mission, et nous ne verrouillons jamais nos clients dans des systèmes qu'ils ne peuvent pas quitter. Ce n'est pas une fonctionnalité — c'est notre vision des affaires.",
|
||||
"quote": "Construire moins de choses. Les construire mieux. Les construire pour durer.",
|
||||
"quoteAttrib": "Principe fondateur de LetsBe."
|
||||
},
|
||||
"pillars": {
|
||||
"eyebrow": "Nos Convictions",
|
||||
"title": "Ce en quoi nous croyons",
|
||||
"subtitle": "Trois principes qui guident chaque projet que nous prenons en charge.",
|
||||
"craftsmanship": {
|
||||
"title": "L'Artisanat Avant Tout",
|
||||
"description": "La différence entre un site qui fonctionne et un site qui dure, c'est le soin apporté à sa réalisation. Nous soignons la typographie, les transitions, la performance, les cas limites. Chaque interface que nous livrons est quelque chose dont nous sommes fiers de signer."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "Une Équipe, Tout Inclus",
|
||||
"description": "Design, développement, hébergement, infrastructure — une seule équipe, un seul interlocuteur, un seul niveau d'exigence. Pas de sous-traitance entre agences. Pas de jonglage avec des freelances. Juste des gens qui se soucient de l'ensemble."
|
||||
},
|
||||
"ownership": {
|
||||
"title": "Construit Pour Vous Appartenir",
|
||||
"description": "Tout ce que nous construisons vous appartient — le code, les données, l'infrastructure. Pas de dépendance fournisseur, pas de contraintes de plateforme. Nous remettons un travail qui perdurera au-delà de notre collaboration."
|
||||
}
|
||||
},
|
||||
"quote": {
|
||||
"text": "Nous ne construisons pas seulement des sites web — nous construisons les fondations sur lesquelles votre entreprise s'appuie.",
|
||||
"attrib": "Philosophie fondatrice de LetsBe."
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Travaillez Avec Nous",
|
||||
"title": "Construisons quelque chose ensemble.",
|
||||
"subtitle": "Que vous ayez un brief précis ou simplement une idée naissante, nous serions ravis d'explorer ce qui est possible.",
|
||||
"primary": "Démarrer votre projet",
|
||||
"secondary": "Réserver un appel"
|
||||
}
|
||||
},
|
||||
"servicesPage": {
|
||||
"hero": {
|
||||
"eyebrow": "Nos Services",
|
||||
"title": "Tout ce dont votre entreprise",
|
||||
"titleAccent": "a besoin en ligne.",
|
||||
"subtitle": "Nous concevons des sites web sur mesure, développons des logiciels dédiés et gérons l'infrastructure derrière tout cela — une seule équipe, un seul niveau d'exigence, rien d'externalisé."
|
||||
},
|
||||
"pillars": [
|
||||
{
|
||||
"id": "design-development",
|
||||
"numeral": "01",
|
||||
"title": "Design & Développement Web",
|
||||
"description": "Votre site web ne devrait pas ressembler à celui de tout le monde — et ne devrait pas être construit comme celui de tout le monde non plus. Nous concevons et développons des sites web et des applications web sur mesure à partir d'une page blanche, en façonnant chaque mise en page, chaque interaction et chaque page avec intention. Le résultat est rapide, optimisé pour les moteurs de recherche, et conçu pour évoluer avec votre entreprise. Que vous ayez besoin d'un site marketing qui convertit, d'une application web sur laquelle votre équipe s'appuie, ou d'une plateforme e-commerce qui passe à l'échelle — nous la construisons de zéro, et nous la construisons pour durer.",
|
||||
"features": [
|
||||
{ "icon": "Palette", "title": "Design Sur Mesure", "description": "Chaque mise en page, composant et interaction est conçu pour votre marque. Pas de thèmes, pas de templates, pas de raccourcis." },
|
||||
{ "icon": "Globe", "title": "Applications Web", "description": "Des applications modernes et responsives développées avec les dernières technologies — rapides, fiables et prêtes à évoluer." },
|
||||
{ "icon": "ShoppingCart", "title": "E-Commerce", "description": "Boutiques sur mesure, tunnels de commande et plateformes multi-devises conçus pour le commerce en ligne sérieux." },
|
||||
{ "icon": "Zap", "title": "Performance & SEO", "description": "Des temps de chargement rapides, un code propre et une optimisation pour les moteurs de recherche intégrés dès la conception — pas ajoutés après coup." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "custom-systems",
|
||||
"numeral": "02",
|
||||
"title": "Logiciels & Plateformes",
|
||||
"description": "Les logiciels standard font des suppositions sur le fonctionnement de votre entreprise. Pas nous. Quand les tableurs et les outils génériques ne suffisent plus, nous développons le système exact dont votre équipe a besoin — conçu autour de votre processus, pas de celui d'un autre. Des CRM adaptés à votre cycle de vente, aux plateformes de gestion qui remplacent trois abonnements différents, en passant par les intégrations qui connectent vos outils existants — tout ce que nous construisons vous appartient, est entièrement documenté et conçu pour durer.",
|
||||
"features": [
|
||||
{ "icon": "Database", "title": "CRM & Outils de Gestion", "description": "La gestion des relations et des pipelines construite autour de la façon dont votre équipe travaille réellement — pas selon les schémas d'une plateforme générique." },
|
||||
{ "icon": "Code2", "title": "Logiciels Sur Mesure", "description": "Des plateformes de réservation aux outils internes en passant par les produits SaaS complets — conçus sur mesure pour votre entreprise." },
|
||||
{ "icon": "GitBranch", "title": "Intégrations & API", "description": "Nous connectons vos outils existants et construisons les passerelles entre systèmes pour que tout fonctionne ensemble." },
|
||||
{ "icon": "Wrench", "title": "Tableaux de Bord & Automatisation", "description": "Panneaux d'administration, outils de reporting et automatisation des processus qui donnent à votre équipe un avantage décisif." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "infrastructure",
|
||||
"numeral": "03",
|
||||
"title": "Hébergement & Infrastructure",
|
||||
"description": "Votre site web et vos logiciels ont besoin d'un chez-soi — et nous pensons que vous devriez en être propriétaire. Nous mettons en place et gérons des serveurs dédiés, l'email, le stockage cloud et toute l'infrastructure sur laquelle votre entreprise fonctionne. Pas d'hébergement mutualisé, pas de dépendances tierces obscures. Vous savez où vivent vos données, qui y a accès, et que quelqu'un surveille le tableau de bord en permanence.",
|
||||
"features": [
|
||||
{ "icon": "Server", "title": "Hébergement Dédié", "description": "Des serveurs privés gérés pour votre entreprise — pas d'hébergement mutualisé, pas de voisins encombrants, pas de surprises." },
|
||||
{ "icon": "Shield", "title": "Vos Données, Votre Contrôle", "description": "Vous possédez vos données et savez exactement où elles se trouvent. Accès complet, transparence totale, sans verrouillage." },
|
||||
{ "icon": "Lock", "title": "Sécurité & Protection", "description": "Une sécurité sérieuse, une surveillance proactive et une protection intégrée dans votre infrastructure dès le premier jour." },
|
||||
{ "icon": "Settings", "title": "Surveillance & Support", "description": "Surveillance proactive, mises à jour régulières et support continu pour que vous n'ayez jamais à vous soucier de la disponibilité." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"eyebrow": "Couche Intelligente",
|
||||
"title": "L'IA Intégrée Partout",
|
||||
"subtitle": "Votre plateforme, rendue plus intelligente.",
|
||||
"description": "Nous intégrons l'IA directement dans les sites web et les logiciels que nous développons pour vous. Non pas comme un mot à la mode ou un module complémentaire — mais comme des fonctionnalités concrètes qui font gagner du temps à votre équipe et offrent une meilleure expérience à vos clients.",
|
||||
"bottomNote": "Chaque fonctionnalité IA est adaptée à votre entreprise — vos données restent sur vos serveurs",
|
||||
"capabilities": [
|
||||
{ "id": "ai-teammate", "title": "IA Coéquipier", "description": "Un assistant IA intégré à votre flux de travail — automatise les tâches répétitives, fait remonter les informations dont votre équipe a besoin et connecte vos outils." },
|
||||
{ "id": "customer-facing-ai", "title": "IA Orientée Client", "description": "Des fonctionnalités intelligentes pour vos clients — recherche avancée, recommandations personnalisées et interfaces conversationnelles disponibles en permanence." },
|
||||
{ "id": "data-intelligence", "title": "Intelligence des Données", "description": "L'IA qui vous aide à comprendre vos données — rapports automatisés, détection de tendances et insights sur lesquels vous pouvez réellement agir." }
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Parlons-en",
|
||||
"title": "Prêt à vous lancer ?",
|
||||
"subtitle": "Répondez à quelques questions et nous préparerons un brief projet sur mesure pour vous — sans engagement, juste de la clarté.",
|
||||
"primary": "Démarrer Votre Projet",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "Sans engagement — juste une conversation sur ce qui est possible."
|
||||
}
|
||||
},
|
||||
"caseStudy": {
|
||||
"labels": {
|
||||
"challenge": "Le Défi",
|
||||
"challengeHeading": "Le problème que nous cherchions à résoudre",
|
||||
"approach": "Notre Approche",
|
||||
"approachHeading": "Comment nous avons réfléchi",
|
||||
"outcome": "Le Résultat",
|
||||
"outcomeHeading": "Ce que nous avons livré",
|
||||
"builtWith": "Développé avec",
|
||||
"yourTurn": "À Votre Tour",
|
||||
"ctaTitle": "Prêt à construire quelque chose comme ça ?",
|
||||
"ctaSubtitle": "Chaque projet commence par une conversation. Dites-nous sur quoi vous travaillez et nous trouverons la meilleure façon de le concrétiser.",
|
||||
"ctaButton": "Démarrer votre projet"
|
||||
},
|
||||
"projects": {
|
||||
"monaco-ocean": {
|
||||
"subtitle": "Plateforme de Jugement & d'Analyse Propulsée par l'IA",
|
||||
"description": "Un système complet de jugement et d'analyse avec intégration avancée d'un jury IA pour l'un des événements de conservation les plus prestigieux de la Méditerranée.",
|
||||
"challenge": "Le Monaco Ocean Protection Challenge avait besoin d'une plateforme moderne pour gérer les candidatures, coordonner les juges à travers différents fuseaux horaires et fournir une évaluation assistée par l'IA des projets de conservation — tout en maintenant le prestige et la sécurité attendus d'une institution monégasque.",
|
||||
"approach": "Nous avons développé une plateforme sur mesure de A à Z en utilisant Next.js et une infrastructure PostgreSQL privée. Le module de jury IA utilise le traitement du langage naturel pour présélectionner les candidatures et générer des rapports de synthèse, tandis que les juges humains conservent le contrôle total des décisions finales.",
|
||||
"outcome": "La plateforme a traité plus de 200 candidatures lors de sa première saison, réduisant la charge de travail des juges de 40 % grâce à la présélection assistée par l'IA. Le client a salué la fiabilité du système et l'élégance de son interface."
|
||||
},
|
||||
"port-nimara": {
|
||||
"subtitle": "Hub Digital Maritime",
|
||||
"description": "Hub digital évolutif pour la logistique maritime.",
|
||||
"challenge": "Port Nimara avait besoin d'une présence digitale moderne pouvant servir à la fois de site vitrine et de hub opérationnel pour les demandes d'anneaux, la gestion d'événements et les communications avec les partenaires.",
|
||||
"approach": "Nous avons conçu et développé une application Nuxt.js performante avec un CMS headless pour la gestion de contenu, intégrée à leurs systèmes de planification maritime existants via un middleware API sur mesure.",
|
||||
"outcome": "La nouvelle plateforme a multiplié par 3 les demandes d'anneaux en ligne et a fourni aux autorités portuaires des capacités de gestion de contenu en temps réel dont elles manquaient jusqu'alors."
|
||||
},
|
||||
"port-amador": {
|
||||
"subtitle": "Expérience Nautique Premium",
|
||||
"description": "Expérience digitale premium pour des services nautiques d'élite.",
|
||||
"challenge": "Port Amador avait besoin d'une expérience digitale de standing luxe à la hauteur de l'exclusivité de ses services nautiques, avec une prise en charge multilingue et une intégration de réservation transparente.",
|
||||
"approach": "Nous avons créé un site web sur mesure avec des visuels cinématographiques, des animations fluides et un tunnel de réservation intégré. Le site a été développé avec des technologies web modernes en mettant l'accent sur la performance et le SEO pour le marché maritime de luxe très concurrentiel.",
|
||||
"outcome": "La plateforme repensée a élevé la présence digitale de Port Amador pour correspondre à son positionnement premium, avec une amélioration de 60 % des temps de chargement des pages et une augmentation significative du trafic organique."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
471
src/i18n/messages/it.json
Normal file
@@ -0,0 +1,471 @@
|
||||
{
|
||||
"meta": {
|
||||
"siteName": "LetsBe.",
|
||||
"home": {
|
||||
"title": "LetsBe. | Web Design Su Misura, Software & Infrastruttura Digitale",
|
||||
"description": "Siti web su misura, software dedicato, integrazione IA e infrastruttura privata — progettati, sviluppati e gestiti da un unico team."
|
||||
},
|
||||
"about": {
|
||||
"title": "Chi Siamo | LetsBe. — La Nostra Storia & Approccio",
|
||||
"description": "Uno studio digitale fondato negli Stati Uniti che crea siti web, software e piattaforme su misura per aziende che puntano alla qualità."
|
||||
},
|
||||
"services": {
|
||||
"title": "Servizi | LetsBe. — Web Design, Software & Infrastruttura",
|
||||
"description": "Web design su misura, software dedicato, automazione IA e infrastruttura privata — tre pilastri dell'eccellenza digitale sotto un unico tetto."
|
||||
},
|
||||
"work": {
|
||||
"monaco-ocean": {
|
||||
"title": "Monaco Ocean Protection Challenge | LetsBe.",
|
||||
"description": "Piattaforma di giuria e analisi basata sull'IA per uno dei principali eventi di conservazione del Mediterraneo."
|
||||
},
|
||||
"port-nimara": {
|
||||
"title": "Port Nimara — Hub Digitale Marittimo | LetsBe.",
|
||||
"description": "Sito web su misura e CRM completo per la gestione dei lead, l'assegnazione dei posti barca e le operazioni del porto."
|
||||
},
|
||||
"port-amador": {
|
||||
"title": "Port Amador — Esperienza Nautica Premium | LetsBe.",
|
||||
"description": "Sito web e infrastruttura digitale privata per un marina di lusso — cloud storage, email e gestione dei file."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"services": "Servizi",
|
||||
"configure": "Inizia",
|
||||
"process": "Metodo",
|
||||
"work": "Lavori",
|
||||
"about": "Chi Siamo",
|
||||
"startProject": "Avvia un Progetto",
|
||||
"bookCall": "Prenota una Chiamata"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Il tuo sito. Il tuo software. Il tuo mondo digitale {accentWord}.",
|
||||
"accentWord": "completo",
|
||||
"subtitle": "Siti web su misura, software dedicato e l'infrastruttura per far girare tutto — progettati, sviluppati e gestiti da un unico team.",
|
||||
"cta": "Avvia il Tuo Progetto",
|
||||
"ctaSecondary": "Scopri i Nostri Lavori",
|
||||
"trust": "La fiducia di aziende in tutto il mondo"
|
||||
},
|
||||
"trustBar": {
|
||||
"customBuilt": {
|
||||
"title": "Progettato da Zero",
|
||||
"description": "Nessun template, nessun page builder. Ogni sito è progettato su misura e costruito a mano per il tuo brand."
|
||||
},
|
||||
"privateInfra": {
|
||||
"title": "Tutto è Tuo",
|
||||
"description": "Il tuo codice, i tuoi dati, i tuoi server. Lo costruiamo noi, lo possiedi tu — nessun lock-in, nessuna sorpresa."
|
||||
},
|
||||
"aiPowered": {
|
||||
"title": "Un Team, dall'Inizio alla Fine",
|
||||
"description": "Niente freelance da coordinare. Un solo team gestisce design, codice, hosting e supporto."
|
||||
},
|
||||
"rivieraBased": {
|
||||
"title": "IA Integrata",
|
||||
"description": "Funzionalità intelligenti e automazione integrate direttamente nel tuo sito web e nei tuoi software."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"eyebrow": "Cosa Facciamo",
|
||||
"title": "Progettare. Sviluppare. Crescere.",
|
||||
"web": {
|
||||
"title": "Web Design & Sviluppo",
|
||||
"features": ["Design di Siti Su Misura", "Sviluppo Responsive", "SEO & Performance", "Gestione dei Contenuti"]
|
||||
},
|
||||
"systems": {
|
||||
"title": "Software & Piattaforme",
|
||||
"features": ["Strumenti di Gestione Aziendale", "CRM & Dashboard", "Sistemi di Prenotazione", "Integrazioni API"]
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Hosting & Infrastruttura",
|
||||
"features": ["Server Dedicati", "Email & Cloud Storage", "Sicurezza & Monitoraggio", "Supporto Continuativo"]
|
||||
},
|
||||
"aiNarrative": "E integriamo l'IA in tutto — dalle funzionalità intelligenti nel tuo sito web all'automazione che connette tutti i tuoi strumenti."
|
||||
},
|
||||
"configurator": {
|
||||
"eyebrow": "Inizia",
|
||||
"title": "Dicci di cosa hai bisogno.",
|
||||
"description": "Rispondi a qualche domanda e prepareremo un brief di progetto su misura per te.",
|
||||
"step1": {
|
||||
"title": "Di cosa hai bisogno?",
|
||||
"subtitle": "Seleziona i servizi che si adattano al tuo progetto."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Parlaci del tuo progetto",
|
||||
"subtitle": "Qualche dettaglio ci aiuta a preparare l'approccio giusto."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Quasi fatto",
|
||||
"subtitle": "Controlla le tue selezioni e dicci come contattarti."
|
||||
},
|
||||
"complete": {
|
||||
"title": "Il tuo brief di progetto è pronto",
|
||||
"subtitle": "Controlla la tua casella — abbiamo inviato un brief dettagliato a {email}",
|
||||
"nextStep": "Prossimo passo: parliamo del tuo brief",
|
||||
"bookSubtitle": "Prenota una chiamata gratuita di 30 minuti per discutere del tuo progetto e dei prossimi passi.",
|
||||
"bookCall": "Prenota una Chiamata",
|
||||
"briefPreview": "Il tuo brief di progetto",
|
||||
"reachDirectly": "O contattaci direttamente a"
|
||||
},
|
||||
"howItWorks": "Come funziona",
|
||||
"noCommitment": "Nessun impegno richiesto",
|
||||
"selectService": "Seleziona almeno un servizio per continuare",
|
||||
"services": {
|
||||
"web": {
|
||||
"title": "Web Design & Sviluppo",
|
||||
"description": "Siti web e applicazioni su misura — progettati da zero, costruiti per performare e ottimizzati per essere trovati."
|
||||
},
|
||||
"systems": {
|
||||
"title": "Software Su Misura",
|
||||
"description": "CRM, piattaforme di gestione e strumenti aziendali costruiti attorno al modo in cui il tuo team lavora davvero."
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Infrastruttura Privata",
|
||||
"description": "Hosting dedicato, email, cloud storage e l'infrastruttura su cui gira la tua azienda — interamente di tua proprietà."
|
||||
}
|
||||
},
|
||||
"aiToggle": "Aggiungi Integrazione IA",
|
||||
"aiDescription": "Funzionalità IA pratiche e automazione integrate direttamente nel tuo sito web e nei tuoi software.",
|
||||
"aiTypes": {
|
||||
"teammate": {
|
||||
"title": "IA come Collaboratore",
|
||||
"description": "Un assistente IA interno che aiuta il tuo team a lavorare più velocemente — automatizza le attività, risponde alle domande, connette i tuoi strumenti."
|
||||
},
|
||||
"customer-facing": {
|
||||
"title": "IA per i Clienti",
|
||||
"description": "Funzionalità IA con cui interagiscono i tuoi clienti — ricerca intelligente, raccomandazioni personalizzate, interfacce conversazionali."
|
||||
},
|
||||
"data-intelligence": {
|
||||
"title": "Intelligenza dei Dati",
|
||||
"description": "L'IA analizza i dati della tua azienda per far emergere insight, trend e raccomandazioni concrete."
|
||||
},
|
||||
"notsure": {
|
||||
"title": "Non Ancora Sicuro",
|
||||
"description": "Nessun problema — esploreremo insieme il miglior approccio IA durante la fase di discovery."
|
||||
}
|
||||
},
|
||||
"industries": {
|
||||
"maritime": "Marittimo / Yachting",
|
||||
"hospitality": "Ospitalità",
|
||||
"technology": "Tecnologia",
|
||||
"realestate": "Immobiliare",
|
||||
"finance": "Finanza",
|
||||
"ngo": "ONG / Non Profit",
|
||||
"other": "Altro"
|
||||
},
|
||||
"timelines": {
|
||||
"asap": "Prima possibile",
|
||||
"1-3months": "1–3 mesi",
|
||||
"3-6months": "3–6 mesi",
|
||||
"exploring": "Sto solo esplorando"
|
||||
},
|
||||
"fields": {
|
||||
"industry": "Il tuo settore",
|
||||
"scope": "Cosa vuoi ottenere?",
|
||||
"scopeOptional": "(opzionale)",
|
||||
"scopePlaceholder": "es. Dobbiamo sostituire il nostro sistema di prenotazione attuale e migliorare l'esperienza cliente…",
|
||||
"timeline": "Tempistiche",
|
||||
"name": "Il tuo nome",
|
||||
"company": "Azienda",
|
||||
"email": "Indirizzo email",
|
||||
"phone": "Telefono",
|
||||
"phoneOptional": "(opzionale)",
|
||||
"contactPreference": "Metodo di contatto preferito",
|
||||
"contactEmail": "Email",
|
||||
"contactPhone": "Telefono",
|
||||
"contactWhatsapp": "WhatsApp",
|
||||
"currentSiteUrl": "Sito web attuale",
|
||||
"currentSiteUrlOptional": "(opzionale)",
|
||||
"currentSiteUrlPlaceholder": "https://iltuosito.com",
|
||||
"currentSiteThoughts": "Opinioni sul tuo sito attuale",
|
||||
"currentSiteThoughtsPlaceholder": "Cosa funziona, cosa non funziona, cosa vorresti cambiare..."
|
||||
},
|
||||
"summary": {
|
||||
"heading": "Le tue selezioni",
|
||||
"aiEnhancement": "Potenziamento IA"
|
||||
},
|
||||
"generating": "Generazione",
|
||||
"generatingSteps": {
|
||||
"preparingBrief": "Preparazione del tuo brief",
|
||||
"analyzingSite": "Analisi del tuo sito attuale",
|
||||
"runningAudit": "Audit delle performance in corso",
|
||||
"generatingBrief": "Generazione del tuo brief personalizzato"
|
||||
},
|
||||
"voice": {
|
||||
"agentName": "Assistente di progetto LetsBe",
|
||||
"endConversation": "Termina la Conversazione",
|
||||
"analyzingSite": "Analisi del tuo sito...",
|
||||
"connecting": "Connessione in corso...",
|
||||
"mute": "Silenzia",
|
||||
"unmute": "Riattiva audio",
|
||||
"generatingBrief": "Generazione del tuo brief...",
|
||||
"contactConfirm": "È tutto corretto?",
|
||||
"contactEdit": "Modifica",
|
||||
"contactConfirmButton": "È corretto",
|
||||
"reconnect": "Riconnetti",
|
||||
"connectionLost": "Connessione persa. La tua conversazione è salvata.",
|
||||
"briefComplete": "Brief completato",
|
||||
"startConversation": "Avvia conversazione"
|
||||
},
|
||||
"privacy": "Le tue informazioni sono private e non saranno mai condivise.",
|
||||
"generateBrief": "Genera il Mio Brief",
|
||||
"nextStep": "Prossimo Passo",
|
||||
"back": "Indietro",
|
||||
"startOver": "Ricomincia",
|
||||
"errors": {
|
||||
"general": "Qualcosa è andato storto. Riprova.",
|
||||
"network": "Errore di rete. Controlla la connessione e riprova."
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"eyebrow": "Troviamolo Insieme",
|
||||
"title": "Non sai da dove iniziare?",
|
||||
"description": "Dicci cosa hai in mente e lo capiremo insieme. Riceverai un brief personalizzato alla fine.",
|
||||
"cta": "Parliamone",
|
||||
"privacy": "Le conversazioni vocali non vengono registrate né memorizzate."
|
||||
},
|
||||
"process": {
|
||||
"eyebrow": "Come Lavoriamo",
|
||||
"title": "Dall'Idea al Lancio",
|
||||
"steps": {
|
||||
"discovery": {
|
||||
"title": "Discovery",
|
||||
"description": "Studiamo la tua azienda, i tuoi utenti e ciò che hai davvero bisogno di costruire."
|
||||
},
|
||||
"strategy": {
|
||||
"title": "Strategia & Pianificazione",
|
||||
"description": "Definiamo l'architettura, scegliamo gli strumenti giusti e pianifichiamo lo sviluppo."
|
||||
},
|
||||
"build": {
|
||||
"title": "Design & Sviluppo",
|
||||
"description": "Il tuo progetto prende forma — pixel dopo pixel, funzionalità dopo funzionalità."
|
||||
},
|
||||
"launch": {
|
||||
"title": "Lancio & Supporto",
|
||||
"description": "Facciamo il deploy, monitoriamo e manteniamo tutto funzionante senza intoppi."
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"eyebrow": "I Nostri Lavori",
|
||||
"title": "Progetti Che Abbiamo Realizzato",
|
||||
"readCaseStudy": "Vedi Progetto",
|
||||
"comingSoon": "Prossimamente",
|
||||
"projects": {
|
||||
"monaco": {
|
||||
"title": "Monaco Ocean Protection Challenge",
|
||||
"description": "Piattaforma di giuria e analisi con filtraggio basato su IA, assegnazione automatica dei giudici e valutazione intelligente dei progetti per uno dei principali eventi di conservazione del Mediterraneo.",
|
||||
"tags": ["Software Su Misura", "Integrazione IA"]
|
||||
},
|
||||
"portNimara": {
|
||||
"title": "Port Nimara",
|
||||
"description": "Sito web su misura e CRM completo per la gestione dei lead, l'assegnazione dei posti barca e le operazioni del porto.",
|
||||
"tags": ["Sito Web", "Software Su Misura"]
|
||||
},
|
||||
"portAmador": {
|
||||
"title": "Port Amador",
|
||||
"description": "Sito web e infrastruttura digitale privata — cloud storage, email e gestione dei file per un marina di lusso.",
|
||||
"tags": ["Sito Web", "Infrastruttura"]
|
||||
}
|
||||
},
|
||||
"comingSoonProjects": {
|
||||
"riviera": {
|
||||
"title": "Piattaforma di Gestione Immobiliare",
|
||||
"subtitle": "Prossimamente"
|
||||
},
|
||||
"sophia": {
|
||||
"title": "SaaS Enterprise — Austin, TX",
|
||||
"subtitle": "Lancio Q4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"philosophy": {
|
||||
"eyebrow": "Perché Noi",
|
||||
"title": "Facciamo le cose diversamente.",
|
||||
"subtitle": "La maggior parte delle agenzie ti dà un template e lo chiama su misura. Noi pensiamo che la tua azienda meriti di meglio — design vero, ingegneria vera e un team che resta anche dopo il lancio.",
|
||||
"ownership": {
|
||||
"title": "Costruito per Essere Tuo",
|
||||
"description": "Tutto quello che costruiamo è tuo. Il tuo codice, i tuoi dati, la tua infrastruttura — nessun lock-in, nessuna dipendenza da piattaforme, nessuna sorpresa."
|
||||
},
|
||||
"craftsmanship": {
|
||||
"title": "Qualità Prima della Comodità",
|
||||
"description": "Scriviamo codice pulito, costruito a mano, ottimizzato per velocità e motori di ricerca. Nessun page builder, nessun tema gonfio, nessuna scorciatoia."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "Un'Unica Relazione",
|
||||
"description": "Dal primo design al supporto continuativo — un solo team che conosce la tua azienda a fondo. Nessun passaggio di mano, nessun brief perso per strada."
|
||||
},
|
||||
"quote": "Costruiamo tecnologia che lavora per la tua azienda — non il contrario.",
|
||||
"foundedLocation": "Matt Ciaccio, Fondatore"
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Parliamone",
|
||||
"title": "Pronto a costruire qualcosa di grande?",
|
||||
"subtitle": "Dicci su cosa stai lavorando. Niente presentazioni, niente pressioni — solo una conversazione onesta su ciò che è possibile.",
|
||||
"cta": "Avvia il Tuo Progetto",
|
||||
"configure": "Avvia il Tuo Progetto",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "Nessun impegno richiesto."
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Siti web, software e piattaforme digitali su misura — progettati e sviluppati per aziende che rifiutano di accontentarsi.",
|
||||
"location": "Fondato negli Stati Uniti. Al servizio di clienti in tutto il mondo.",
|
||||
"services": "Servizi",
|
||||
"studio": "Studio",
|
||||
"connect": "Contatti",
|
||||
"serviceLinks": {
|
||||
"designDev": "Web Design & Sviluppo",
|
||||
"customSystems": "Software Su Misura",
|
||||
"infrastructure": "Infrastruttura Privata",
|
||||
"aiAutomation": "Integrazione IA"
|
||||
},
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Termini di Servizio"
|
||||
},
|
||||
"aboutPage": {
|
||||
"hero": {
|
||||
"eyebrow": "Chi Siamo",
|
||||
"title": "Le grandi aziende meritano grandi partner digitali.",
|
||||
"subtitle": "Progettiamo e sviluppiamo siti web, software e piattaforme digitali su misura per aziende che puntano alla qualità — e che cercano un team che faccia lo stesso."
|
||||
},
|
||||
"story": {
|
||||
"eyebrow": "La Nostra Storia",
|
||||
"title": "Costruito per aziende come la tua.",
|
||||
"p1": "LetsBe. è nata da una convinzione semplice: le aziende ambiziose meritano strumenti digitali curati quanto il lavoro che svolgono. Non template. Non piattaforme preconfezionate. Design e ingegneria reali, costruiti da zero.",
|
||||
"p2": "I nostri primi clienti erano founder e imprenditori che avevano bisogno di qualcosa di più di un sito web — un partner tecnico capace di progettare, sviluppare, ospitare e mantenere tutto sotto un unico tetto. Quei progetti hanno plasmato il nostro modo di lavorare oggi.",
|
||||
"p3": "Costruiamo piattaforme pensate per essere possedute, non affittate. Documentiamo tutto, consegniamo codebase che sopravvivono al progetto e non leghiamo mai i clienti a sistemi che non possono lasciare. Non è una caratteristica — è come pensiamo che il business dovrebbe funzionare.",
|
||||
"quote": "Costruire meno cose. Costruirle meglio. Costruirle per durare.",
|
||||
"quoteAttrib": "Principio fondante di LetsBe."
|
||||
},
|
||||
"pillars": {
|
||||
"eyebrow": "Le Nostre Convinzioni",
|
||||
"title": "In Cosa Crediamo",
|
||||
"subtitle": "Tre principi alla base di ogni progetto che realizziamo.",
|
||||
"craftsmanship": {
|
||||
"title": "Prima la Qualità",
|
||||
"description": "La differenza tra un sito che funziona e uno che dura è la cura artigianale. Ci preoccupiamo della tipografia, delle transizioni, delle performance, dei casi limite. Ogni interfaccia che rilasciamo è qualcosa di cui siamo orgogliosi di firmare."
|
||||
},
|
||||
"oneTeam": {
|
||||
"title": "Un Team, Tutto",
|
||||
"description": "Design, sviluppo, hosting, infrastruttura — un unico team, un unico punto di contatto, un unico standard di qualità. Nessun passaggio tra agenzie. Nessun freelance da coordinare. Solo persone che si preoccupano dell'intero progetto."
|
||||
},
|
||||
"ownership": {
|
||||
"title": "Costruito per Essere Tuo",
|
||||
"description": "Tutto quello che costruiamo è tuo — il codice, i dati, l'infrastruttura. Nessun vendor lock-in, nessuna dipendenza da piattaforme. Consegniamo lavoro che sopravvive al progetto."
|
||||
}
|
||||
},
|
||||
"quote": {
|
||||
"text": "Non costruiamo solo siti web — costruiamo le fondamenta su cui gira la tua azienda.",
|
||||
"attrib": "Filosofia fondante di LetsBe."
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Lavora Con Noi",
|
||||
"title": "Costruiamo qualcosa insieme.",
|
||||
"subtitle": "Che tu abbia un brief chiaro o solo un'idea iniziale, ci farebbe piacere parlare di ciò che è possibile.",
|
||||
"primary": "Avvia il tuo progetto",
|
||||
"secondary": "Prenota una chiamata"
|
||||
}
|
||||
},
|
||||
"servicesPage": {
|
||||
"hero": {
|
||||
"eyebrow": "I Nostri Servizi",
|
||||
"title": "Tutto ciò di cui la tua azienda",
|
||||
"titleAccent": "ha bisogno online.",
|
||||
"subtitle": "Progettiamo siti web su misura, sviluppiamo software dedicato e gestiamo l'infrastruttura che li supporta — un unico team, un unico standard di qualità, niente esternalizzato."
|
||||
},
|
||||
"pillars": [
|
||||
{
|
||||
"id": "design-development",
|
||||
"numeral": "01",
|
||||
"title": "Web Design & Sviluppo",
|
||||
"description": "Il tuo sito web non dovrebbe assomigliare a quello di tutti gli altri — e non dovrebbe essere costruito come quello di tutti gli altri. Progettiamo e sviluppiamo siti web e applicazioni web da una tela bianca, curando ogni layout, ogni interazione e ogni pagina con intenzione. Il risultato è veloce, ottimizzato per i motori di ricerca e costruito per crescere con la tua azienda. Che tu abbia bisogno di un sito marketing che converte, di un'applicazione web su cui il tuo team fa affidamento, o di una piattaforma e-commerce che scala — lo costruiamo da zero, e lo costruiamo per durare.",
|
||||
"features": [
|
||||
{ "icon": "Palette", "title": "Design Su Misura", "description": "Ogni layout, componente e interazione è progettato per il tuo brand. Nessun tema, nessun template, nessuna scorciatoia." },
|
||||
{ "icon": "Globe", "title": "Applicazioni Web", "description": "Applicazioni moderne e responsive sviluppate con le tecnologie più recenti — veloci, affidabili e pronte a scalare." },
|
||||
{ "icon": "ShoppingCart", "title": "E-Commerce", "description": "Storefront personalizzati, flussi di checkout e piattaforme multi-valuta costruite per il retail online serio." },
|
||||
{ "icon": "Zap", "title": "Performance & SEO", "description": "Tempi di caricamento rapidi, codice pulito e ottimizzazione per i motori di ricerca integrati nelle fondamenta — non aggiunti in un secondo momento." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "custom-systems",
|
||||
"numeral": "02",
|
||||
"title": "Software & Piattaforme",
|
||||
"description": "I software preconfezionati fanno ipotesi su come funziona la tua azienda. Noi no. Quando fogli di calcolo e strumenti generici non bastano più, costruiamo esattamente il sistema di cui il tuo team ha bisogno — progettato attorno al tuo flusso di lavoro, non a quello di qualcun altro. Dai CRM adatti al tuo processo di vendita, alle piattaforme di gestione che sostituiscono tre abbonamenti diversi, alle integrazioni che connettono i tuoi strumenti esistenti — tutto quello che costruiamo è tuo, completamente documentato e costruito per durare.",
|
||||
"features": [
|
||||
{ "icon": "Database", "title": "CRM & Strumenti di Gestione", "description": "Gestione delle relazioni e della pipeline costruita attorno a come lavora davvero il tuo team — non come pensa una piattaforma generica che dovresti lavorare." },
|
||||
{ "icon": "Code2", "title": "Software Su Misura", "description": "Dalle piattaforme di prenotazione agli strumenti interni, fino ai prodotti SaaS completi — costruiti su misura per la tua azienda." },
|
||||
{ "icon": "GitBranch", "title": "Integrazioni & API", "description": "Connettiamo i tuoi strumenti esistenti e costruiamo i ponti tra i sistemi affinché tutto funzioni insieme." },
|
||||
{ "icon": "Wrench", "title": "Dashboard & Automazione", "description": "Pannelli di amministrazione, strumenti di reporting e automazione dei flussi di lavoro che danno al tuo team un vantaggio competitivo." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "infrastructure",
|
||||
"numeral": "03",
|
||||
"title": "Hosting & Infrastruttura",
|
||||
"description": "Il tuo sito web e il tuo software hanno bisogno di una casa — e noi pensiamo che dovresti possederla. Configuriamo e gestiamo server dedicati, email, cloud storage e tutta l'infrastruttura su cui gira la tua azienda. Nessun hosting condiviso, nessuna misteriosa dipendenza da terze parti. Sai dove vivono i tuoi dati, chi vi ha accesso e che qualcuno tiene d'occhio la dashboard 24 ore su 24.",
|
||||
"features": [
|
||||
{ "icon": "Server", "title": "Hosting Dedicato", "description": "Server privati gestiti per la tua azienda — nessun hosting condiviso, nessun vicino rumoroso, nessuna sorpresa." },
|
||||
{ "icon": "Shield", "title": "I Tuoi Dati, Il Tuo Controllo", "description": "Possiedi i tuoi dati e sai esattamente dove si trovano. Accesso completo, piena trasparenza, nessun lock-in." },
|
||||
{ "icon": "Lock", "title": "Sicurezza & Protezione", "description": "Sicurezza seria, monitoraggio proattivo e protezione integrata nella tua infrastruttura fin dal primo giorno." },
|
||||
{ "icon": "Settings", "title": "Monitoraggio & Supporto", "description": "Monitoraggio proattivo, aggiornamenti regolari e supporto continuativo così non dovrai mai preoccuparti dell'uptime." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"eyebrow": "Livello Intelligente",
|
||||
"title": "IA Integrata in Tutto",
|
||||
"subtitle": "La tua piattaforma, resa più intelligente.",
|
||||
"description": "Integriamo l'IA direttamente nei siti web e nei software che costruiamo per te. Non come parola d'ordine o un componente aggiuntivo — come funzionalità pratiche che fanno risparmiare tempo al tuo team e offrono ai tuoi clienti un'esperienza migliore.",
|
||||
"bottomNote": "Ogni funzionalità IA è adattata alla tua azienda — i tuoi dati rimangono sui tuoi server",
|
||||
"capabilities": [
|
||||
{ "id": "ai-teammate", "title": "IA come Collaboratore", "description": "Un assistente IA integrato nel tuo flusso di lavoro — automatizza le attività ripetitive, porta in superficie le informazioni di cui il tuo team ha bisogno e connette i tuoi strumenti." },
|
||||
{ "id": "customer-facing-ai", "title": "IA per i Clienti", "description": "Funzionalità intelligenti per i tuoi clienti — ricerca intelligente, raccomandazioni personalizzate e interfacce conversazionali attive 24 ore su 24." },
|
||||
{ "id": "data-intelligence", "title": "Intelligenza dei Dati", "description": "IA che ti aiuta a capire i tuoi dati — report automatizzati, individuazione di trend e insight su cui puoi davvero agire." }
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"eyebrow": "Parliamone",
|
||||
"title": "Pronto per iniziare?",
|
||||
"subtitle": "Rispondi a qualche domanda e prepareremo un brief di progetto su misura per te — nessun impegno richiesto, solo chiarezza.",
|
||||
"primary": "Avvia il Tuo Progetto",
|
||||
"email": "hello@letsbe.biz",
|
||||
"reassurance": "Nessun impegno richiesto — solo una conversazione su ciò che è possibile."
|
||||
}
|
||||
},
|
||||
"caseStudy": {
|
||||
"labels": {
|
||||
"challenge": "La Sfida",
|
||||
"challengeHeading": "Il problema che ci siamo proposti di risolvere",
|
||||
"approach": "Il Nostro Approccio",
|
||||
"approachHeading": "Come abbiamo ragionato",
|
||||
"outcome": "Il Risultato",
|
||||
"outcomeHeading": "Cosa abbiamo consegnato",
|
||||
"builtWith": "Sviluppato con",
|
||||
"yourTurn": "Tocca a Te",
|
||||
"ctaTitle": "Pronto a costruire qualcosa di simile?",
|
||||
"ctaSubtitle": "Ogni progetto inizia con una conversazione. Dicci su cosa stai lavorando e troveremo insieme il modo migliore per realizzarlo.",
|
||||
"ctaButton": "Avvia il tuo progetto"
|
||||
},
|
||||
"projects": {
|
||||
"monaco-ocean": {
|
||||
"subtitle": "Piattaforma di Giuria e Analisi con IA",
|
||||
"description": "Un sistema completo di giuria e analisi con integrazione avanzata di giuria IA per uno degli eventi di conservazione più prestigiosi del Mediterraneo.",
|
||||
"challenge": "Il Monaco Ocean Protection Challenge aveva bisogno di una piattaforma moderna per gestire le candidature, coordinare i giudici in fusi orari diversi e fornire una valutazione assistita dall'IA delle proposte di conservazione — il tutto mantenendo il prestigio e la sicurezza attesi da un'istituzione monegasca.",
|
||||
"approach": "Abbiamo costruito una piattaforma personalizzata da zero usando Next.js e un'infrastruttura PostgreSQL privata. Il modulo di giuria IA utilizza l'elaborazione del linguaggio naturale per pre-selezionare le candidature e generare report riassuntivi, mentre i giudici umani mantengono il pieno controllo sulle decisioni finali.",
|
||||
"outcome": "La piattaforma ha elaborato oltre 200 candidature nella sua prima stagione, riducendo il carico di lavoro dei giudici del 40% grazie alla pre-selezione assistita dall'IA. Il cliente ha elogiato l'affidabilità del sistema e l'eleganza della sua interfaccia."
|
||||
},
|
||||
"port-nimara": {
|
||||
"subtitle": "Hub Digitale Marittimo",
|
||||
"description": "Hub digitale scalabile per la logistica marittima.",
|
||||
"challenge": "Port Nimara aveva bisogno di una presenza digitale moderna capace di servire sia come sito di marketing che come hub operativo per le richieste di ormeggio, la gestione degli eventi e le comunicazioni con i partner.",
|
||||
"approach": "Abbiamo progettato e sviluppato un'applicazione Nuxt.js performante con un CMS headless per la gestione dei contenuti, integrata con i loro sistemi di pianificazione marittima esistenti tramite middleware API personalizzato.",
|
||||
"outcome": "La nuova piattaforma ha triplicato le richieste di ormeggio online e ha fornito all'autorità portuale capacità di gestione dei contenuti in tempo reale di cui prima erano privi."
|
||||
},
|
||||
"port-amador": {
|
||||
"subtitle": "Esperienza Nautica Premium",
|
||||
"description": "Esperienza digitale premium per servizi nautici di élite.",
|
||||
"challenge": "Port Amador richiedeva un'esperienza digitale di livello lusso che rispecchiasse l'esclusività dei loro servizi nautici, con supporto multilingue e un'integrazione di prenotazione fluida.",
|
||||
"approach": "Abbiamo realizzato un sito web su misura con immagini cinematografiche, animazioni fluide e un flusso di prenotazione integrato. Il sito è stato costruito su tecnologie web moderne con un focus su performance e SEO per il competitivo mercato marittimo di lusso.",
|
||||
"outcome": "La piattaforma ridisegnata ha elevato la presenza digitale di Port Amador alla sua posizione premium, con un miglioramento del 60% nei tempi di caricamento delle pagine e un aumento significativo del traffico organico."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/lib/analytics.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Send a custom event to Google Analytics 4.
|
||||
* Safe to call anywhere — silently no-ops if gtag isn't loaded.
|
||||
*/
|
||||
export function trackEvent(
|
||||
eventName: string,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) {
|
||||
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
|
||||
window.gtag('event', eventName, params)
|
||||
}
|
||||
}
|
||||
306
src/lib/email.ts
@@ -17,68 +17,286 @@ interface SendBriefEmailOptions {
|
||||
brief: string
|
||||
}
|
||||
|
||||
interface SendLeadNotificationOptions {
|
||||
to: string
|
||||
name: string
|
||||
company: string
|
||||
brief: string
|
||||
email: string
|
||||
services: string[]
|
||||
phone?: string
|
||||
contactPreference?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a markdown-style brief string into styled HTML.
|
||||
* Handles: **bold**, ---, numbered lists, section headings, paragraphs.
|
||||
*/
|
||||
function convertBriefToHtml(brief: string): string {
|
||||
const lines = brief.split('\n')
|
||||
const outputLines: string[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
|
||||
// Horizontal rule
|
||||
if (line.trim() === '---') {
|
||||
outputLines.push('<hr style="border:none;border-top:1px solid #d1e8f5;margin:20px 0">')
|
||||
continue
|
||||
}
|
||||
|
||||
// Empty line — paragraph break spacer
|
||||
if (line.trim() === '') {
|
||||
outputLines.push('<div style="height:8px"></div>')
|
||||
continue
|
||||
}
|
||||
|
||||
// Bold inline
|
||||
line = line.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#191c1d;font-weight:600">$1</strong>')
|
||||
|
||||
// Numbered list items: "1. text" or "2. text"
|
||||
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/)
|
||||
if (numberedMatch) {
|
||||
outputLines.push(
|
||||
`<div style="display:flex;gap:10px;margin:6px 0;line-height:1.65;font-size:14px;color:#374151">` +
|
||||
`<span style="font-weight:700;color:#006494;min-width:20px">${numberedMatch[1]}.</span>` +
|
||||
`<span>${numberedMatch[2]}</span>` +
|
||||
`</div>`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Section heading detection: lines that are entirely bold (e.g. **Heading**)
|
||||
const headingMatch = line.match(/^<strong[^>]*>(.*?)<\/strong>$/)
|
||||
if (headingMatch) {
|
||||
outputLines.push(
|
||||
`<p style="margin:16px 0 4px;font-size:15px;font-weight:700;color:#006494;letter-spacing:0.01em">${headingMatch[1]}</p>`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular paragraph line
|
||||
outputLines.push(
|
||||
`<p style="margin:0 0 8px;font-size:14px;line-height:1.7;color:#374151">${line}</p>`
|
||||
)
|
||||
}
|
||||
|
||||
return outputLines.join('\n')
|
||||
}
|
||||
|
||||
export async function sendBriefToClient({ to, name, brief }: SendBriefEmailOptions) {
|
||||
const firstName = name.split(' ')[0] || 'there'
|
||||
|
||||
// Convert markdown-style **bold** to HTML <strong>
|
||||
const htmlBrief = brief
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/---/g, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">')
|
||||
.replace(/\n\n/g, '</p><p style="margin:0 0 12px;line-height:1.6">')
|
||||
.replace(/\n/g, '<br>')
|
||||
const htmlBrief = convertBriefToHtml(brief)
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"LetsBe." <${process.env.SMTP_FROM || 'hello@letsbe.biz'}>`,
|
||||
to,
|
||||
subject: 'Your Project Brief from LetsBe.',
|
||||
html: `
|
||||
<div style="font-family:'Inter',Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;color:#191c1d">
|
||||
<div style="padding:32px 0;border-bottom:2px solid #006494">
|
||||
<h1 style="font-family:Georgia,serif;font-size:24px;margin:0;color:#006494">LetsBe.</h1>
|
||||
</div>
|
||||
<div style="padding:32px 0">
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6">Hi ${firstName},</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#555">
|
||||
Thank you for configuring your project with us. Here's your personalized brief:
|
||||
</p>
|
||||
<div style="background:#f8f9fa;border-radius:12px;padding:24px;font-size:14px;line-height:1.7;color:#333">
|
||||
<p style="margin:0 0 12px;line-height:1.6">${htmlBrief}</p>
|
||||
</div>
|
||||
<div style="margin-top:32px;text-align:center">
|
||||
<a href="https://scheduling.letsbe.solutions/matt-ciaccio/letsbe"
|
||||
style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#006494,#5BA4D9);color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:500">
|
||||
Book a Consultation
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:32px 0 0;font-size:13px;color:#999;text-align:center">
|
||||
Or reply to this email — we'll get back to you within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e5e7eb;padding:24px 0;font-size:12px;color:#999;text-align:center">
|
||||
LetsBe. Digital Studio · Côte d'Azur, France
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your Project Brief from LetsBe.</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:'Inter',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4f8;padding:32px 16px">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
|
||||
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;border-bottom:2px solid #e8eef3;border-radius:16px 16px 0 0;padding:28px 32px;text-align:center">
|
||||
<img src="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.letsbe.biz'}/images/letsbe-logo-short.png" alt="LetsBe." width="52" style="display:block;margin:0 auto" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- GREETING -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:36px 40px 28px">
|
||||
<p style="margin:0 0 8px;font-size:22px;font-weight:600;color:#191c1d;line-height:1.3">Hi ${firstName},</p>
|
||||
<p style="margin:0;font-size:15px;line-height:1.7;color:#6b7280">
|
||||
Thank you for configuring your project with us. Here's your personalized brief — a summary of everything we discussed, ready for your review.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- BRIEF CARD -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:0 40px 36px">
|
||||
<div style="background:#f0f7fb;border-left:3px solid #006494;border-radius:12px;padding:28px">
|
||||
${htmlBrief}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:0 40px">
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CTA -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:36px 40px;text-align:center">
|
||||
<p style="margin:0 0 6px;font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:400;color:#191c1d">Ready to discuss your brief?</p>
|
||||
<p style="margin:0 0 28px;font-size:14px;color:#6b7280;line-height:1.6">We'll walk through your goals, answer questions, and outline next steps — no pressure.</p>
|
||||
<a href="https://scheduling.letsbe.solutions/matt-ciaccio/letsbe"
|
||||
style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#006494 0%,#5BA4D9 100%);color:#ffffff;text-decoration:none;border-radius:10px;font-size:15px;font-weight:600;letter-spacing:0.01em;box-shadow:0 4px 14px rgba(0,100,148,0.35)">
|
||||
Book a 30-Minute Call
|
||||
</a>
|
||||
<p style="margin:20px 0 0;font-size:13px;color:#9ca3af;line-height:1.6">
|
||||
Or reply to this email — we'll get back to you within 24 hours.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td style="background:#f8fafb;border-top:1px solid #e5e7eb;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
|
||||
<p style="margin:0 0 4px;font-size:13px;font-weight:600;color:#374151">LetsBe Solutions LLC</p>
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6">Custom websites, software, and infrastructure — designed and built around you.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendLeadNotification({ to, name, company, brief }: SendBriefEmailOptions & { services: string[]; email: string }) {
|
||||
export async function sendLeadNotification({
|
||||
to,
|
||||
name,
|
||||
company,
|
||||
brief,
|
||||
email,
|
||||
phone,
|
||||
contactPreference,
|
||||
}: SendLeadNotificationOptions) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'hello@letsbe.biz'
|
||||
const htmlBrief = convertBriefToHtml(brief)
|
||||
|
||||
// Build contact rows with alternating backgrounds
|
||||
const contactRows: string[] = []
|
||||
const rowData: Array<{ label: string; value: string; isLink?: string }> = [
|
||||
{ label: 'Name', value: name },
|
||||
{ label: 'Company', value: company || '—' },
|
||||
{ label: 'Email', value: email, isLink: `mailto:${email}` },
|
||||
]
|
||||
if (phone) {
|
||||
rowData.push({ label: 'Phone', value: phone, isLink: `tel:${phone}` })
|
||||
}
|
||||
if (contactPreference) {
|
||||
rowData.push({ label: 'Preferred Contact', value: contactPreference })
|
||||
}
|
||||
|
||||
rowData.forEach((row, idx) => {
|
||||
const bg = idx % 2 === 0 ? '#f8fafb' : '#ffffff'
|
||||
const valueHtml = row.isLink
|
||||
? `<a href="${row.isLink}" style="color:#006494;text-decoration:none;font-weight:500">${row.value}</a>`
|
||||
: `<span style="color:#191c1d">${row.value}</span>`
|
||||
contactRows.push(`
|
||||
<tr style="background-color:${bg}">
|
||||
<td style="padding:10px 16px;font-size:13px;font-weight:600;color:#6b7280;white-space:nowrap;width:140px">${row.label}</td>
|
||||
<td style="padding:10px 16px;font-size:14px">${valueHtml}</td>
|
||||
</tr>
|
||||
`)
|
||||
})
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"LetsBe. Configurator" <${process.env.SMTP_FROM || 'hello@letsbe.biz'}>`,
|
||||
to: adminEmail,
|
||||
subject: `New Lead: ${name}${company ? ` — ${company}` : ''}`,
|
||||
html: `
|
||||
<div style="font-family:'Inter',Helvetica,Arial,sans-serif;color:#191c1d">
|
||||
<h2 style="font-family:Georgia,serif;color:#006494;margin:0 0 16px">New Configurator Submission</h2>
|
||||
<table style="font-size:14px;line-height:1.6;border-collapse:collapse">
|
||||
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Name:</td><td>${name}</td></tr>
|
||||
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Company:</td><td>${company || '—'}</td></tr>
|
||||
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Email:</td><td><a href="mailto:${to}">${to}</a></td></tr>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Configurator Lead</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:'Inter',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4f8;padding:32px 16px">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
|
||||
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;border-bottom:2px solid #e8eef3;border-radius:16px 16px 0 0;padding:24px 32px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.letsbe.biz'}/images/letsbe-logo-short.png" alt="LetsBe." width="44" style="display:block;max-width:44px;height:auto" />
|
||||
</td>
|
||||
<td align="right" valign="middle">
|
||||
<span style="display:inline-block;background:#006494;color:#ffffff;font-size:11px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;padding:5px 14px;border-radius:20px">New Lead</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CONTACT INFO CARD -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:32px 40px 24px">
|
||||
<p style="margin:0 0 16px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:0.08em;text-transform:uppercase">Contact Details</p>
|
||||
<div style="border:1px solid #e5e7eb;border-radius:10px;overflow:hidden">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse">
|
||||
${contactRows.join('')}
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- BRIEF SECTION -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:8px 40px 36px">
|
||||
<p style="margin:0 0 16px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:0.08em;text-transform:uppercase">Project Brief</p>
|
||||
<div style="background:#f0f7fb;border-left:3px solid #006494;border-radius:12px;padding:28px">
|
||||
${htmlBrief}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:0 40px">
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- QUICK ACTION -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:28px 40px;text-align:center">
|
||||
<a href="mailto:${email}?subject=Re: Your LetsBe. Project Brief"
|
||||
style="display:inline-block;padding:14px 36px;background:linear-gradient(135deg,#006494 0%,#5BA4D9 100%);color:#ffffff;text-decoration:none;border-radius:10px;font-size:14px;font-weight:600;letter-spacing:0.01em">
|
||||
Reply to Lead
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td style="background:#f8fafb;border-top:1px solid #e5e7eb;border-radius:0 0 16px 16px;padding:20px 40px;text-align:center">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af">LetsBe Solutions LLC — Internal Notification</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<div style="margin-top:24px;background:#f8f9fa;border-radius:8px;padding:16px;font-size:13px;white-space:pre-wrap">${brief}</div>
|
||||
</div>
|
||||
`,
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
284
src/lib/gemini-live.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Type } from '@google/genai';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GEMINI_LIVE_MODEL = 'gemini-3.1-flash-live-preview';
|
||||
|
||||
// ─── Agent Tools ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const AGENT_TOOLS = [
|
||||
{
|
||||
name: 'update_selections',
|
||||
description:
|
||||
'Emit structured project data as it is confirmed during conversation. Call incrementally as each detail is captured.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
services: {
|
||||
type: Type.ARRAY,
|
||||
items: { type: Type.STRING },
|
||||
description: 'Selected services: web, systems, infrastructure',
|
||||
},
|
||||
aiEnabled: {
|
||||
type: Type.BOOLEAN,
|
||||
description: 'Whether AI integration is requested',
|
||||
},
|
||||
aiTypes: {
|
||||
type: Type.ARRAY,
|
||||
items: { type: Type.STRING },
|
||||
description: 'AI types: teammate, customer-facing, data-intelligence, notsure',
|
||||
},
|
||||
industry: { type: Type.STRING, description: 'Industry sector' },
|
||||
timeline: { type: Type.STRING, description: 'Timeline preference' },
|
||||
currentSiteUrl: { type: Type.STRING, description: 'Current website URL' },
|
||||
scope: { type: Type.STRING, description: 'Project goals/scope summary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'analyze_website',
|
||||
description:
|
||||
'Analyze a website URL to understand its current technology, performance, and structure. Call when the user provides their current website URL.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
url: { type: Type.STRING, description: 'The website URL to analyze' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request_contact',
|
||||
description:
|
||||
'Display a contact confirmation card on screen for the user to verify their name and email. Call this instead of spelling back their details verbally. The user will confirm or edit on screen.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
name: { type: Type.STRING, description: 'The name the user provided' },
|
||||
email: { type: Type.STRING, description: 'The email the user provided' },
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'complete_brief',
|
||||
description:
|
||||
'Generate and send the project brief. The conversationSummary is the most important field — it should capture the full richness of the conversation. Structured fields (services, industry, timeline) are supporting metadata. Only call after the user has confirmed their contact details on screen.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
name: { type: Type.STRING },
|
||||
email: { type: Type.STRING },
|
||||
company: { type: Type.STRING },
|
||||
phone: { type: Type.STRING },
|
||||
contactPreference: { type: Type.STRING },
|
||||
services: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||
aiEnabled: { type: Type.BOOLEAN },
|
||||
aiTypes: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||
industry: { type: Type.STRING },
|
||||
timeline: { type: Type.STRING },
|
||||
currentSiteUrl: { type: Type.STRING },
|
||||
currentSiteThoughts: { type: Type.STRING },
|
||||
scope: { type: Type.STRING },
|
||||
conversationSummary: { type: Type.STRING, description: 'Detailed summary of the entire conversation including: specific pain points mentioned, current tools/systems in use, what they want to keep vs change, budget signals, decision-making context, and any unique requirements or preferences discussed. This feeds directly into the brief generation.' },
|
||||
},
|
||||
required: ['name', 'email', 'services', 'conversationSummary'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── System Prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildSystemPrompt(locale: string): string {
|
||||
if (locale === 'fr') {
|
||||
return `Tu es l'assistant de projets LetsBe — un consultant chaleureux et expérimenté pour LetsBe Solutions. Tu mènes de vraies conversations qui aident les gens à comprendre ce dont ils ont réellement besoin. Toute la conversation se fait en français.
|
||||
|
||||
Présente-toi : "Bonjour, je suis l'assistant de projets LetsBe. Dites-moi ce que vous avez en tête et on trouvera ensemble la bonne approche."
|
||||
|
||||
Ton objectif : comprendre les besoins de cette personne assez profondément pour rédiger un brief convaincant et personnalisé. Tu ne remplis pas un formulaire. Tu as une vraie conversation de consultant.
|
||||
|
||||
Comment te comporter :
|
||||
- Suis le fil de la conversation. S'ils mentionnent une frustration, creuse. Si un sujet connexe apparaît, il est probablement important. Ne redirige pas vers ton prochain sujet.
|
||||
- Pose une seule question à la fois. Laisse-les finir avant de continuer.
|
||||
- Offre ta perspective, pas seulement des questions. "Ça ressemble davantage à un problème d'intégration de systèmes qu'à une refonte de site web" — tu as des opinions et de l'expérience, partage-les.
|
||||
- Mentionne le travail de LetsBe naturellement quand c'est pertinent. "On a construit quelque chose de similaire pour un groupe hôtelier" — pas une liste de fonctionnalités.
|
||||
- Garde chaque réponse à 2-3 phrases. Tu es consultant, pas conférencier.
|
||||
- C'est OK si les sujets arrivent dans un ordre différent. C'est OK si certains sujets n'arrivent jamais.
|
||||
|
||||
Sujets qui méritent d'être explorés (mais ne traite pas ça comme une checklist) :
|
||||
- Qu'est-ce qui les a poussés à nous contacter maintenant ? Quel est le besoin sous-jacent ?
|
||||
- Qu'est-ce qui ne fonctionne pas ou qui est frustrant dans leur configuration actuelle ?
|
||||
- Quels outils ou systèmes leur équipe utilise-t-elle aujourd'hui ?
|
||||
- S'ils ont un site web, propose de l'analyser — puis discute des résultats naturellement.
|
||||
- À quoi ressemblerait le succès pour eux ?
|
||||
- Qui d'autre est impliqué dans la décision ?
|
||||
- Qu'est-ce qui motive leur calendrier ?
|
||||
|
||||
Utilisation des outils :
|
||||
- Appelle update_selections silencieusement dès que tu captes une donnée structurée. Fais correspondre ce que tu entends à la valeur prédéfinie la plus proche. Ne pose jamais de questions de type formulaire.
|
||||
- services : "web", "systems", "infrastructure"
|
||||
- aiTypes : "teammate", "customer-facing", "data-intelligence", "notsure"
|
||||
- industry : "maritime", "hospitality", "technology", "realestate", "finance", "ngo", "other"
|
||||
- timeline : "asap", "1-3months", "3-6months", "exploring"
|
||||
- Appelle analyze_website quand ils mentionnent une URL.
|
||||
- Quand la conversation atteint une conclusion naturelle et que tu as une bonne compréhension de leurs besoins, demande leur nom et email. Dis quelque chose comme "J'ai une bonne vision de vos besoins — laissez-moi préparer un brief. Quel est votre nom et votre email ?"
|
||||
- Après qu'ils aient donné nom et email, appelle request_contact pour afficher leurs coordonnées à l'écran. Dis "J'ai mis vos coordonnées à l'écran — vérifiez et dites-moi si c'est correct." Attends leur confirmation avant de continuer.
|
||||
- Après confirmation, appelle complete_brief immédiatement. Dis "Parfait, je génère votre brief maintenant." Inclus un conversationSummary détaillé capturant TOUS les détails : points de friction, outils actuels, ce qu'ils veulent garder ou changer, contexte business, décideurs, ce que le succès représente, besoins uniques. Le conversationSummary est l'input principal du brief — plus il y a de détails, meilleur sera le brief.
|
||||
|
||||
À propos de LetsBe (mentionner naturellement, ne pas réciter) :
|
||||
- Tout est développé sur mesure — aucun template, aucun constructeur de pages
|
||||
- Infrastructure privée : les clients possèdent et contrôlent entièrement leurs données et serveurs
|
||||
- Petite équipe expérimentée avec des décennies d'expérience combinée en design et ingénierie
|
||||
- Intégration IA profonde dans tout type de système
|
||||
- Souveraineté des données et confidentialité numérique comme priorité`;
|
||||
}
|
||||
|
||||
if (locale === 'it') {
|
||||
return `Sei l'assistente di progetti LetsBe — un consulente esperto e cordiale di LetsBe Solutions. Conduci conversazioni reali che aiutano le persone a capire di cosa hanno effettivamente bisogno. Tutta la conversazione si svolge in italiano.
|
||||
|
||||
Presentati: "Ciao, sono l'assistente di progetti LetsBe. Dimmi cosa hai in mente e troveremo insieme l'approccio giusto."
|
||||
|
||||
Il tuo obiettivo: capire le esigenze di questa persona in modo abbastanza approfondito da redigere un brief convincente e personalizzato. Non stai compilando un modulo. Stai avendo una vera conversazione da consulente.
|
||||
|
||||
Come comportarti:
|
||||
- Segui il filo della conversazione. Se menzionano una frustrazione, approfondisci. Se emergono argomenti correlati, probabilmente sono importanti. Non reindirizzare verso il tuo prossimo argomento.
|
||||
- Fai una domanda alla volta. Lasciali finire prima di proseguire.
|
||||
- Offri la tua prospettiva, non solo domande. "Sembra più un problema di integrazione dei sistemi che di rifacimento del sito web" — hai opinioni ed esperienza, condividile.
|
||||
- Menziona il lavoro di LetsBe in modo naturale quando è pertinente. "Abbiamo costruito qualcosa di simile per un gruppo alberghiero" — non una lista di funzionalità.
|
||||
- Mantieni ogni risposta a 2-3 frasi. Sei un consulente, non un conferenziere.
|
||||
- Va bene se gli argomenti emergono in ordine diverso. Va bene se certi argomenti non emergono affatto.
|
||||
|
||||
Argomenti da esplorare (ma non trattarli come una checklist):
|
||||
- Cosa li ha spinti a contattarci ora? Qual è il bisogno sottostante?
|
||||
- Cosa non funziona o è frustrante nella loro configurazione attuale?
|
||||
- Quali strumenti o sistemi usa il loro team oggi?
|
||||
- Se hanno un sito web, offri di analizzarlo — poi discuti i risultati in modo naturale.
|
||||
- Come sarebbe il successo per loro?
|
||||
- Chi altro è coinvolto nella decisione?
|
||||
- Cosa guida la loro tempistica?
|
||||
|
||||
Utilizzo degli strumenti:
|
||||
- Chiama update_selections silenziosamente ogni volta che capi un dato strutturato. Mappa ciò che senti al valore predefinito più vicino. Non fare mai domande in stile modulo.
|
||||
- services: "web", "systems", "infrastructure"
|
||||
- aiTypes: "teammate", "customer-facing", "data-intelligence", "notsure"
|
||||
- industry: "maritime", "hospitality", "technology", "realestate", "finance", "ngo", "other"
|
||||
- timeline: "asap", "1-3months", "3-6months", "exploring"
|
||||
- Chiama analyze_website quando menzionano un URL.
|
||||
- Quando la conversazione raggiunge una conclusione naturale e hai una buona comprensione delle loro esigenze, chiedi nome ed email. Di' qualcosa come "Ho un quadro chiaro di ciò di cui hai bisogno — lasciami preparare un brief. Come ti chiami e qual è la tua email?"
|
||||
- Dopo che hanno fornito nome ed email, chiama request_contact per mostrare i loro dati sullo schermo. Di' "Ho messo i tuoi dati sullo schermo — dai un'occhiata e dimmi se è tutto corretto." Aspetta la loro conferma prima di procedere.
|
||||
- Dopo la conferma, chiama complete_brief immediatamente. Di' "Perfetto, sto generando il tuo brief ora." Includi un conversationSummary dettagliato che catturi TUTTI i dettagli: punti critici, strumenti attuali, cosa vogliono mantenere o cambiare, contesto business, decisori, come appare il successo, requisiti unici. Il conversationSummary è l'input principale del brief — più dettagli ci sono, migliore sarà il brief.
|
||||
|
||||
Su LetsBe (menzionare naturalmente, non recitare):
|
||||
- Tutto sviluppato su misura da zero — nessun template, nessun page builder
|
||||
- Infrastruttura privata: i clienti possiedono e controllano completamente i loro dati e server
|
||||
- Team piccolo ed esperto con decenni di esperienza combinata in design e ingegneria
|
||||
- Integrazione IA profonda in qualsiasi tipo di sistema
|
||||
- Sovranità dei dati e privacy digitale come priorità fondamentale`;
|
||||
}
|
||||
|
||||
if (locale === 'es') {
|
||||
return `Eres el asistente de proyectos LetsBe — un consultor experimentado y cercano de LetsBe Solutions. Tienes conversaciones reales que ayudan a las personas a descubrir lo que realmente necesitan. Toda la conversación se realiza en español.
|
||||
|
||||
Preséntate: "Hola, soy el asistente de proyectos LetsBe. Cuéntame qué tienes en mente y encontraremos juntos el enfoque adecuado."
|
||||
|
||||
Tu objetivo: entender las necesidades de esta persona con suficiente profundidad para redactar un brief convincente y personalizado. No estás rellenando un formulario. Estás teniendo una conversación consultiva genuina.
|
||||
|
||||
Cómo comportarte:
|
||||
- Sigue el hilo de la conversación. Si mencionan una frustración, profundiza en ella. Si surge un tema tangencial, probablemente sea importante. No redirigirles hacia tu próximo tema.
|
||||
- Haz una sola pregunta a la vez. Deja que terminen antes de continuar.
|
||||
- Ofrece tu perspectiva, no solo preguntas. "Eso suena más a un problema de integración de sistemas que a un rediseño de sitio web" — tienes opiniones y experiencia, compártelas.
|
||||
- Menciona el trabajo de LetsBe de forma natural cuando sea relevante. "Construimos algo similar para un grupo hotelero" — no una lista de funcionalidades.
|
||||
- Mantén cada respuesta en 2-3 frases. Eres un consultor, no un conferenciante.
|
||||
- Está bien si los temas surgen en un orden diferente. Está bien si algunos temas no surgen en absoluto.
|
||||
|
||||
Temas que vale la pena explorar (pero no los trates como una lista de verificación):
|
||||
- ¿Qué les llevó a contactarnos ahora? ¿Cuál es la necesidad subyacente?
|
||||
- ¿Qué no funciona o es frustrante en su configuración actual?
|
||||
- ¿Qué herramientas o sistemas usa su equipo hoy en día?
|
||||
- Si tienen un sitio web, ofrécete a analizarlo — luego comenta los resultados de forma natural.
|
||||
- ¿Cómo sería el éxito para ellos?
|
||||
- ¿Quién más está involucrado en la decisión?
|
||||
- ¿Qué impulsa su calendario?
|
||||
|
||||
Uso de herramientas:
|
||||
- Llama a update_selections silenciosamente cada vez que captes un dato estructurado. Mapea lo que escuchas al valor predefinido más cercano. Nunca hagas preguntas tipo formulario.
|
||||
- services: "web", "systems", "infrastructure"
|
||||
- aiTypes: "teammate", "customer-facing", "data-intelligence", "notsure"
|
||||
- industry: "maritime", "hospitality", "technology", "realestate", "finance", "ngo", "other"
|
||||
- timeline: "asap", "1-3months", "3-6months", "exploring"
|
||||
- Llama a analyze_website cuando mencionen una URL.
|
||||
- Cuando la conversación llegue a una conclusión natural y tengas una buena comprensión de sus necesidades, pide su nombre y email. Di algo como "Tengo una imagen clara de lo que necesitas — déjame preparar un brief. ¿Cuál es tu nombre y tu email?"
|
||||
- Después de que proporcionen nombre y email, llama a request_contact para mostrar sus datos en pantalla. Di "He puesto tus datos en pantalla — échales un vistazo y dime si está todo correcto." Espera su confirmación antes de continuar.
|
||||
- Después de la confirmación, llama a complete_brief inmediatamente. Di "Perfecto, generando tu brief ahora." Incluye un conversationSummary detallado que capture TODOS los detalles: puntos de dolor, herramientas actuales, qué quieren mantener o cambiar, contexto del negocio, decisores, cómo es el éxito, requisitos únicos. El conversationSummary es el input principal del brief — cuantos más detalles, mejor será el brief.
|
||||
|
||||
Sobre LetsBe (mencionar de forma natural, no recitar):
|
||||
- Todo desarrollado a medida desde cero — sin plantillas, sin page builders
|
||||
- Infraestructura privada: los clientes son dueños y controlan completamente sus datos y servidores
|
||||
- Equipo pequeño y experimentado con décadas de experiencia combinada en diseño e ingeniería
|
||||
- Integración profunda de IA en cualquier tipo de sistema
|
||||
- Soberanía de datos y privacidad digital como enfoque principal`;
|
||||
}
|
||||
|
||||
return `You are the LetsBe project assistant — a warm, experienced consultant for LetsBe Solutions. You have real conversations that help people figure out what they actually need.
|
||||
|
||||
Introduce yourself: "Hi, I'm the LetsBe project assistant. Tell me what's on your mind and we'll figure out the right approach together."
|
||||
|
||||
Your goal: understand what this person needs deeply enough to write a compelling, personalized brief. You are not filling out a form. You are having a genuine consultative conversation.
|
||||
|
||||
How to behave:
|
||||
- Follow their thread. If they mention a frustration, dig into it. If they go on a tangent, that tangent probably matters. Don't redirect to your next topic.
|
||||
- Ask one question at a time. Let them finish before moving on.
|
||||
- Offer perspective, not just questions. "That sounds like it might be more of a systems integration problem than a website redesign" — you have opinions and experience, share them.
|
||||
- Reference LetsBe's work naturally when relevant. "We built something similar for a hospitality group" — not a feature list.
|
||||
- Keep each response to 2-3 sentences. You're a consultant, not a lecturer.
|
||||
- It's OK if topics come up organically out of order. It's OK if some topics never come up at all.
|
||||
|
||||
Things worth exploring (but don't treat this as a checklist):
|
||||
- What prompted them to reach out now? What's the underlying need?
|
||||
- What's broken or frustrating about their current setup?
|
||||
- What tools or systems does their team use today?
|
||||
- If they have a website, offer to analyze it — then discuss what you find naturally.
|
||||
- What would success look like for them?
|
||||
- Who else is involved in the decision?
|
||||
- What's driving their timeline?
|
||||
|
||||
Tool usage:
|
||||
- Call update_selections silently whenever you pick up on structured data. Map what you hear to the closest predefined value. Never ask checkbox-style questions.
|
||||
- services: "web", "systems", "infrastructure"
|
||||
- aiTypes: "teammate", "customer-facing", "data-intelligence", "notsure"
|
||||
- industry: "maritime", "hospitality", "technology", "realestate", "finance", "ngo", "other"
|
||||
- timeline: "asap", "1-3months", "3-6months", "exploring"
|
||||
- Call analyze_website when they mention a URL.
|
||||
- When the conversation reaches a natural conclusion and you have a solid understanding of their needs, ask for their name and email. Say something like "I've got a great picture of what you need — let me put together a brief. What's your name and email?"
|
||||
- After they provide name and email, call request_contact to show their details on screen. Say "I've put your details on screen — take a look and let me know if that's right." Wait for them to confirm before proceeding.
|
||||
- After confirmation, call complete_brief immediately. Say "Perfect, generating your brief now." Include a detailed conversationSummary capturing ALL specifics: pain points, current tools, what they want to keep vs change, business context, decision-makers, what success looks like, any unique requirements. The conversationSummary is the primary input for the brief — the more detail, the better.
|
||||
|
||||
About LetsBe (reference naturally, don't recite):
|
||||
- Everything custom-built from scratch — no templates, no page builders
|
||||
- Private infrastructure: clients fully own and control their data and servers
|
||||
- Small, experienced team with decades of combined design and engineering expertise
|
||||
- Deep AI integration into any type of system
|
||||
- Data sovereignty and digital privacy as a core focus`;
|
||||
}
|
||||
|
||||
// ─── Live Config ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildLiveConfig(locale: string) {
|
||||
return {
|
||||
responseModalities: ['AUDIO'],
|
||||
systemInstruction: buildSystemPrompt(locale),
|
||||
tools: [{ functionDeclarations: AGENT_TOOLS }],
|
||||
speechConfig: {
|
||||
voiceConfig: {
|
||||
prebuiltVoiceConfig: { voiceName: 'Aoede' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ephemeral Token ──────────────────────────────────────────────────────────
|
||||
|
||||
export function generateEphemeralToken(locale: string) {
|
||||
const config = buildLiveConfig(locale);
|
||||
return { config, model: GEMINI_LIVE_MODEL };
|
||||
}
|
||||
316
src/lib/site-analysis.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import * as cheerio from 'cheerio'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TechStack {
|
||||
cms: string | null
|
||||
framework: string | null
|
||||
ecommerce: string | null
|
||||
analytics: string[]
|
||||
hosting: string | null
|
||||
}
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
score: number
|
||||
fcp: number
|
||||
lcp: number
|
||||
cls: number
|
||||
tbt: number
|
||||
speedIndex: number
|
||||
}
|
||||
|
||||
export interface SiteAnalysis {
|
||||
url: string
|
||||
fetchedAt: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
themeColor: string | null
|
||||
primaryColors: string[]
|
||||
headingStructure: { h1: string[]; h2: string[] }
|
||||
navLinks: string[]
|
||||
hasForms: boolean
|
||||
techStack: TechStack | null
|
||||
performance: PerformanceMetrics | null
|
||||
fetchError: string | null
|
||||
}
|
||||
|
||||
// ─── Internal result types ────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedHtml {
|
||||
title: string | null
|
||||
description: string | null
|
||||
themeColor: string | null
|
||||
primaryColors: string[]
|
||||
headingStructure: { h1: string[]; h2: string[] }
|
||||
navLinks: string[]
|
||||
hasForms: boolean
|
||||
}
|
||||
|
||||
// ─── HTML parser ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parseHtml(html: string): ParsedHtml {
|
||||
const $ = cheerio.load(html)
|
||||
|
||||
const title = $('title').first().text().trim() || null
|
||||
|
||||
const description =
|
||||
$('meta[name="description"]').attr('content')?.trim() ?? null
|
||||
|
||||
const themeColor =
|
||||
$('meta[name="theme-color"]').attr('content')?.trim() ?? null
|
||||
|
||||
const colorPattern = /(#[0-9a-fA-F]{3,8})|rgb[a]?\(\s*\d[\d\s,./%]*\)/g
|
||||
const colorSet = new Set<string>()
|
||||
|
||||
$('style').each((_, el) => {
|
||||
const text = $(el).text()
|
||||
const matches = text.match(colorPattern)
|
||||
if (matches) matches.forEach(c => colorSet.add(c))
|
||||
})
|
||||
|
||||
$('[style]').each((_, el) => {
|
||||
const style = $(el).attr('style') ?? ''
|
||||
const matches = style.match(colorPattern)
|
||||
if (matches) matches.forEach(c => colorSet.add(c))
|
||||
})
|
||||
|
||||
const primaryColors = [...colorSet].slice(0, 8)
|
||||
|
||||
const h1: string[] = []
|
||||
$('h1').each((_, el) => {
|
||||
if (h1.length < 3) h1.push($(el).text().trim())
|
||||
})
|
||||
|
||||
const h2: string[] = []
|
||||
$('h2').each((_, el) => {
|
||||
if (h2.length < 3) h2.push($(el).text().trim())
|
||||
})
|
||||
|
||||
const navLinks: string[] = []
|
||||
$('nav').first().find('a').each((_, el) => {
|
||||
const text = $(el).text().trim()
|
||||
if (text && navLinks.length < 10) navLinks.push(text)
|
||||
})
|
||||
|
||||
const hasForms = $('form').length > 0
|
||||
|
||||
return { title, description, themeColor, primaryColors, headingStructure: { h1, h2 }, navLinks, hasForms }
|
||||
}
|
||||
|
||||
// ─── Tech stack detector ──────────────────────────────────────────────────────
|
||||
|
||||
function detectStack(html: string, headers: Record<string, string>): TechStack {
|
||||
const h = html.toLowerCase()
|
||||
const headerLower: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
headerLower[k.toLowerCase()] = v.toLowerCase()
|
||||
}
|
||||
|
||||
// CMS
|
||||
let cms: string | null = null
|
||||
|
||||
if (
|
||||
h.includes('wp-content/') ||
|
||||
(headerLower['x-powered-by']?.includes('php') && h.includes('wp-json'))
|
||||
) {
|
||||
cms = 'WordPress'
|
||||
} else if (h.includes('cdn.shopify.com') || h.includes('shopify.theme')) {
|
||||
cms = 'Shopify'
|
||||
} else if (
|
||||
h.includes('wixsite.com') ||
|
||||
Object.keys(headerLower).some(k => k.includes('x-wix'))
|
||||
) {
|
||||
cms = 'Wix'
|
||||
} else if (h.includes('static1.squarespace.com') || h.includes('squarespace-cdn')) {
|
||||
cms = 'Squarespace'
|
||||
} else if (h.includes('webflow.io') || h.includes('data-wf-site')) {
|
||||
cms = 'Webflow'
|
||||
} else if (h.includes('/media/jui/') || h.includes('joomla')) {
|
||||
cms = 'Joomla'
|
||||
} else if (
|
||||
h.includes('/sites/default/files/') ||
|
||||
headerLower['x-generator']?.includes('drupal')
|
||||
) {
|
||||
cms = 'Drupal'
|
||||
} else if (h.includes('ghost.io') || h.includes('content="ghost')) {
|
||||
cms = 'Ghost'
|
||||
}
|
||||
|
||||
// Framework
|
||||
let framework: string | null = null
|
||||
|
||||
if (h.includes('__next_data__') || h.includes('_next/static')) {
|
||||
framework = 'Next.js'
|
||||
} else if (h.includes('__nuxt__') || h.includes('_nuxt/')) {
|
||||
framework = 'Nuxt'
|
||||
} else if (!framework && (h.includes('data-reactroot') || h.includes('react-root'))) {
|
||||
framework = 'React'
|
||||
} else if (!framework && h.includes('data-v-')) {
|
||||
framework = 'Vue'
|
||||
} else if (h.includes('ng-version')) {
|
||||
framework = 'Angular'
|
||||
}
|
||||
|
||||
// Ecommerce
|
||||
let ecommerce: string | null = null
|
||||
|
||||
if (h.includes('woocommerce')) {
|
||||
ecommerce = 'WooCommerce'
|
||||
} else if (h.includes('prestashop')) {
|
||||
ecommerce = 'PrestaShop'
|
||||
} else if (h.includes('mage.cookies') || h.includes('skin/frontend')) {
|
||||
ecommerce = 'Magento'
|
||||
}
|
||||
|
||||
// Analytics (collect all)
|
||||
const analytics: string[] = []
|
||||
|
||||
if (h.includes('gtag') && /\/g-[a-z0-9]+\//i.test(html)) {
|
||||
analytics.push('Google Analytics 4')
|
||||
}
|
||||
if (h.includes('googletagmanager.com')) {
|
||||
analytics.push('Google Tag Manager')
|
||||
}
|
||||
if (h.includes('hotjar.com')) {
|
||||
analytics.push('Hotjar')
|
||||
}
|
||||
if (h.includes('matomo.js') || h.includes('piwik.js')) {
|
||||
analytics.push('Matomo')
|
||||
}
|
||||
if (h.includes('fbq(')) {
|
||||
analytics.push('Facebook Pixel')
|
||||
}
|
||||
|
||||
// Hosting
|
||||
let hosting: string | null = null
|
||||
|
||||
if ('cf-ray' in headerLower) {
|
||||
hosting = 'Cloudflare'
|
||||
} else if (Object.keys(headerLower).some(k => k.startsWith('x-vercel'))) {
|
||||
hosting = 'Vercel'
|
||||
} else if ('x-nf-request-id' in headerLower) {
|
||||
hosting = 'Netlify'
|
||||
} else if (
|
||||
'wpe-backend' in headerLower ||
|
||||
headerLower['server']?.includes('wpe')
|
||||
) {
|
||||
hosting = 'WP Engine'
|
||||
}
|
||||
|
||||
return { cms, framework, ecommerce, analytics, hosting }
|
||||
}
|
||||
|
||||
// ─── PageSpeed fetcher ────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchPageSpeed(url: string): Promise<PerformanceMetrics | null> {
|
||||
try {
|
||||
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile`
|
||||
const res = await fetch(apiUrl)
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
|
||||
const lr = json['lighthouseResult'] as Record<string, unknown>
|
||||
const categories = lr['categories'] as Record<string, Record<string, unknown>>
|
||||
const audits = lr['audits'] as Record<string, Record<string, unknown>>
|
||||
|
||||
const score = Math.round((categories['performance']['score'] as number) * 100)
|
||||
const fcp = audits['first-contentful-paint']['numericValue'] as number
|
||||
const lcp = audits['largest-contentful-paint']['numericValue'] as number
|
||||
const cls = audits['cumulative-layout-shift']['numericValue'] as number
|
||||
const tbt = audits['total-blocking-time']['numericValue'] as number
|
||||
const speedIndex = audits['speed-index']['numericValue'] as number
|
||||
|
||||
return { score, fcp, lcp, cls, tbt, speedIndex }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── URL validation ───────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeUrl(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
return `https://${trimmed}`
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function isHttpUrl(input: string): boolean {
|
||||
return /^https?:\/\//i.test(input)
|
||||
}
|
||||
|
||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function analyzeSite(url: string): Promise<SiteAnalysis> {
|
||||
const normalizedUrl = normalizeUrl(url)
|
||||
const fetchedAt = new Date().toISOString()
|
||||
|
||||
const base: SiteAnalysis = {
|
||||
url: normalizedUrl,
|
||||
fetchedAt,
|
||||
title: null,
|
||||
description: null,
|
||||
themeColor: null,
|
||||
primaryColors: [],
|
||||
headingStructure: { h1: [], h2: [] },
|
||||
navLinks: [],
|
||||
hasForms: false,
|
||||
techStack: null,
|
||||
performance: null,
|
||||
fetchError: null,
|
||||
}
|
||||
|
||||
if (!isHttpUrl(normalizedUrl)) {
|
||||
return { ...base, fetchError: 'Invalid URL: only http and https schemes are supported.' }
|
||||
}
|
||||
|
||||
let html: string
|
||||
let headers: Record<string, string>
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const response = await fetch(normalizedUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SiteAnalyzer/1.0)' },
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
html = await response.text()
|
||||
|
||||
headers = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { ...base, fetchError: message }
|
||||
}
|
||||
|
||||
const [htmlResult, stackResult, perfResult] = await Promise.allSettled([
|
||||
Promise.resolve(parseHtml(html)),
|
||||
Promise.resolve(detectStack(html, headers)),
|
||||
fetchPageSpeed(normalizedUrl),
|
||||
])
|
||||
|
||||
const parsed = htmlResult.status === 'fulfilled' ? htmlResult.value : null
|
||||
const stack = stackResult.status === 'fulfilled' ? stackResult.value : null
|
||||
const perf = perfResult.status === 'fulfilled' ? perfResult.value : null
|
||||
|
||||
return {
|
||||
url: normalizedUrl,
|
||||
fetchedAt,
|
||||
title: parsed?.title ?? null,
|
||||
description: parsed?.description ?? null,
|
||||
themeColor: parsed?.themeColor ?? null,
|
||||
primaryColors: parsed?.primaryColors ?? [],
|
||||
headingStructure: parsed?.headingStructure ?? { h1: [], h2: [] },
|
||||
navLinks: parsed?.navLinks ?? [],
|
||||
hasForms: parsed?.hasForms ?? false,
|
||||
techStack: stack,
|
||||
performance: perf,
|
||||
fetchError: null,
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ import { routing } from './i18n/routing'
|
||||
export default createMiddleware(routing)
|
||||
|
||||
export const config = {
|
||||
matcher: ['/', '/(fr|en)/:path*', '/((?!api|_next|admin|media|fonts|images|favicon.ico).*)'],
|
||||
matcher: ['/', '/(fr|en|it|es)/:path*', '/((?!api|_next|admin|media|fonts|images|favicon.ico).*)'],
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export default buildConfig({
|
||||
locales: [
|
||||
{ label: 'English', code: 'en' },
|
||||
{ label: 'Français', code: 'fr' },
|
||||
{ label: 'Italiano', code: 'it' },
|
||||
{ label: 'Español', code: 'es' },
|
||||
],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
87.5% { transform: translate(-120px, 120px); }
|
||||
100% { transform: translate(0px, 170px); }
|
||||
}
|
||||
@keyframes cookie-slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes hero-orbit-b {
|
||||
0% { transform: translate(0px, -130px); }
|
||||
12.5% { transform: translate(-90px, -90px); }
|
||||
|
||||
3
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
interface Window {
|
||||
gtag?: (...args: unknown[]) => void
|
||||
}
|
||||