Compare commits
42 commits
001-multi-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 17a90e5821 | |||
| d9743818b3 | |||
|
|
15eb481b35 | ||
|
|
464f91370d | ||
|
|
b0de69b1d9 | ||
|
|
9146ebfbf0 | ||
|
|
16fcf793d7 | ||
|
|
d1ff9c7ad1 | ||
|
|
d5da487b4b | ||
|
|
c3bcf83212 | ||
|
|
6cd63f1bdf | ||
|
|
1f1c7508b4 | ||
|
|
7e67369600 | ||
|
|
f5423d202c | ||
|
|
bffaa1663d | ||
|
|
0ffb58ca74 | ||
|
|
f7a010fc93 | ||
|
|
4fc85ef99e | ||
|
|
272839d7a9 | ||
|
|
b6a6a55e05 | ||
|
|
d42ab963bc | ||
|
|
a3040d9852 | ||
|
|
2fb3fce608 | ||
|
|
2ab22e5efb | ||
|
|
09943e9f5e | ||
|
|
5d0aaab339 | ||
|
|
8d5b499c16 | ||
|
|
5fa337d97a | ||
|
|
f7f1376568 | ||
|
|
3958080de8 | ||
|
|
af2f8a08ac | ||
|
|
9484ce0587 | ||
| 356338e3de | |||
|
|
ee4deec1f7 | ||
|
|
f53b64760a | ||
|
|
1cca59dbdd | ||
|
|
0fa1a9ab9a | ||
|
|
2f3e4d3dd8 | ||
|
|
fdf598422c | ||
|
|
985d21b53d | ||
|
|
de292da095 | ||
|
|
01eef07e90 |
69 changed files with 20168 additions and 99 deletions
24
.github/workflows/deploy.yml
vendored
24
.github/workflows/deploy.yml
vendored
|
|
@ -17,7 +17,27 @@ jobs:
|
|||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Force TinaCloud schema re-index
|
||||
run: |
|
||||
echo "Triggering TinaCloud schema refresh..."
|
||||
curl -s -X POST \
|
||||
"https://content.tinajs.io/db/${TINA_PUBLIC_CLIENT_ID}/reset/main?refreshSchema=true" \
|
||||
-H "X-API-KEY: ${TINA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-o /tmp/tina-reset.json
|
||||
cat /tmp/tina-reset.json
|
||||
echo "Waiting 60s for TinaCloud to re-index..."
|
||||
sleep 60
|
||||
env:
|
||||
TINA_PUBLIC_CLIENT_ID: ${{ secrets.TINA_PUBLIC_CLIENT_ID }}
|
||||
TINA_TOKEN: ${{ secrets.TINA_TOKEN }}
|
||||
|
||||
- run: npm run build
|
||||
env:
|
||||
TINA_PUBLIC_CLIENT_ID: ${{ secrets.TINA_PUBLIC_CLIENT_ID }}
|
||||
TINA_TOKEN: ${{ secrets.TINA_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
|
|
@ -33,5 +53,9 @@ jobs:
|
|||
-e "ssh -p 1220 -i ~/.ssh/id_deploy" \
|
||||
dist/ ubuntu@57.128.160.249:/opt/00-infrastructure/Website/website/dist/
|
||||
|
||||
rsync -avz \
|
||||
-e "ssh -p 1220 -i ~/.ssh/id_deploy" \
|
||||
server/nginx.conf ubuntu@57.128.160.249:/opt/00-infrastructure/Website/website/nginx.conf
|
||||
|
||||
ssh -p 1220 -i ~/.ssh/id_deploy ubuntu@57.128.160.249 \
|
||||
"cd /opt/00-infrastructure/Website/website && docker compose restart web"
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -5,6 +5,17 @@ dist
|
|||
.env
|
||||
.env.*
|
||||
public/blog
|
||||
public/admin
|
||||
# tina/__generated__/ is committed — TinaCloud needs it for branch indexing
|
||||
# Generated runtime files — regenerated by tinacms build in CI
|
||||
tina/__generated__/client.ts
|
||||
tina/__generated__/types.ts
|
||||
tina/__generated__/queries.gql
|
||||
tina/__generated__/frags.gql
|
||||
tina/__generated__/schema.gql
|
||||
tina/__generated__/static-media.json
|
||||
tina/__generated__/.cache
|
||||
# Schema files below ARE committed (TinaCloud needs them for branch indexing)
|
||||
.claude/
|
||||
pronpt.txt
|
||||
.mcp.json
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
title: "Claude Code Is Pulling Users Away From ChatGPT, and Anthropic's Servers Are Feeling It"
|
||||
date: "2026-03-09T00:00:00.000Z"
|
||||
excerpt: "I opened Claude Code on Tuesday and got a spinner for about 45 seconds before anything happened. I assumed it was my connection. It wasn't --..."
|
||||
coverImage: "/blog/images/claude-code-is-pulling-users-away-from-chatgpt-and-anthropic-s-servers-are-feeli.png"
|
||||
hashtags:
|
||||
- claude
|
||||
- anthropic
|
||||
- openai
|
||||
- claude-code
|
||||
- developer-tools
|
||||
- ai-coding
|
||||
sourceTitle: "Claude and Claude Code traffic grew faster than expected this week"
|
||||
sourceUrl: "https://reddit.com/r/ClaudeAI/comments/1rjyp7d/claude_and_claude_code_traffic_grew_faster_than/"
|
||||
---
|
||||
|
||||
I opened Claude Code on Tuesday and got a spinner for about 45 seconds before anything happened. I assumed it was my connection. It wasn't -- Anthropic's infrastructure was getting crushed by a wave of new users that, by their own admission, they did not see coming.
|
||||
|
||||
What Actually Happened
|
||||
|
||||
The short version: OpenAI agreed to a Department of Defense contract that Anthropic had previously declined. The backlash was immediate. ChatGPT uninstall rates reportedly jumped 295% and roughly 1.5 million subscribers cancelled within 48 hours. A lot of those people needed somewhere to go, and a meaningful chunk of them apparently went to Claude.
|
||||
|
||||
Anthropicposted that Claude and Claude Code traffic "grew faster than expected this week" and that the surge was genuinely difficult to forecast. That phrasing is doing some work. When a company says traffic was hard to forecast, they usually mean their capacity planning models got embarrassed. They're scaling infrastructure now, which explains the latency I was seeing and that r/ClaudeAI lit up with complaints about.
|
||||
|
||||
This is not a small footnote. Claude Code has been quietly building a real user base among developers who care about coding assistance specifically, not just chat. The timing of this political controversy accelerating that adoption is awkward for everyone involved, but the numbers are the numbers.
|
||||
|
||||
What This Means for Developer Workflows
|
||||
|
||||
I have been using Claude Code for a few months alongside other tools. My honest take: it handles longer context and multi-file reasoning better than I expected, and it is less likely to confidently invent an API that does not exist. That said, I had a moment this week where Claude Code literally told me "No" to a refactoring request without much explanation (there is a whole thread about this on r/ClaudeAI, apparently it is not just me). That is its own can of worms.
|
||||
|
||||
The practical concern right now is reliability. When a tool becomes load-bearing in your workflow, outages stop being an inconvenience and start being a problem. Someone in the r/ClaudeAI outage thread described it as "someone pulled the power cable on half my brain," which is dramatic but also not entirely wrong if you have restructured how you work around a specific model.
|
||||
|
||||
If you are migrating from ChatGPT or just evaluating options, the current moment is probably the worst time to stress-test Claude's uptime, because they are actively scaling. Give it a few weeks before you make strong conclusions about reliability. The underlying capability is real -- the infrastructure is just catching up to demand that showed up faster than the capacity team planned for.
|
||||
|
||||
The Broader Shift Worth Watching
|
||||
|
||||
OpenAI also lost its VP of Research, Max Schwarzer, to Anthropic this week, on top of the Head of Hardware and Robotics resigning over the DoD deal. Talent movement at that level is a lagging indicator, but it points in a direction.
|
||||
|
||||
I am not going to pretend this is purely about ethics. People switch tools for messy reasons: peer pressure, curiosity, genuine principle, FOMO, whatever. But the net effect for developers is that the Claude ecosystem is about to get a lot more crowded and a lot more tested. The integrations, the plugins, the third-party tooling -- all of it is about to matter more than it did two weeks ago. If you have been sitting on the fence about getting familiar with the Anthropic API, the fence just got shorter.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: "CodePen Sends Your Keystrokes to Its Servers Before You Click Save"
|
||||
date: "2026-03-08T00:00:00.000Z"
|
||||
excerpt: "I was pasting a database connection string into an online editor last month, just to test something quickly, and I remember thinking 'this is..."
|
||||
coverImage: "/blog/images/codepen-sends-your-keystrokes-to-its-servers-before-you-click-save.png"
|
||||
hashtags:
|
||||
- security
|
||||
- developer-tools
|
||||
- api-keys
|
||||
- online-editors
|
||||
- codepen
|
||||
- privacy
|
||||
sourceTitle: "I planted fake API keys in online code editors and monitored where they went. CodePen sends your code to servers as you type."
|
||||
sourceUrl: "https://reddit.com/r/webdev/comments/1rj1oac/i_planted_fake_api_keys_in_online_code_editors/"
|
||||
---
|
||||
|
||||
I was pasting a database connection string into an online editor last month, just to test something quickly, and I remember thinking 'this is probably fine.' It is probably not fine. A developer ran a controlled experiment on popular online editors, and what they found about CodePen should change how you use it.
|
||||
|
||||
What the Experiment Actually Found
|
||||
|
||||
The setup was straightforward. The researcher planted obviously fake credentials -- things like `const API_KEY = "sk-secret-test-12345"` and `const DB_PASSWORD = "hunter2"` -- inside several online editors, then monitored outbound network traffic.
|
||||
|
||||
CodePen transmits your code to its servers in real time as you type. Two endpoints are involved: `codepen.io/cpe/process` for Babel transpilation and `codepen.io/cpe/boomboom/store` for preview rendering. You do not need to click Save. You do not need to click Run. The moment your fingers hit the keys, the request goes out, and the fake API key traveled verbatim in the request body.
|
||||
|
||||
This is not a bug. CodePen needs to render a live preview, and transpilation has to happen somewhere. But most developers using CodePen for a quick test probably do not think of it as a keystroke logger, which functionally it is, for anything you type into it.
|
||||
|
||||
Why This Actually Matters for Automation Workflows
|
||||
|
||||
I have seen people use CodePen and similar tools for three things that are genuinely risky given this finding. First, quickly testing an API integration by pasting in a real key to check if it works. Second, sharing a reproduction case with a coworker that includes a connection string or token. Third, using an online editor during a live demo where real credentials are temporarily visible on screen.
|
||||
|
||||
The third one is how production secrets end up in conference talk screenshots. But the first two happen constantly in everyday dev work, and the keystroke transmission means the credential exposure happens before you even decide to share the pen publicly.
|
||||
|
||||
The original post focused on CodePen specifically because the other editors tested behaved differently. That distinction matters. Not every browser-based editor works this way. Some only transmit on explicit save or run actions. The specifics of which tools send what, and when, are exactly the kind of thing most developers never think to check.
|
||||
|
||||
What to Do About It
|
||||
|
||||
The practical answer is boring but correct: do not paste real credentials into any online editor, ever. Use obvious placeholder strings during development and swap them in from environment variables at runtime. If you are reproducing a bug that requires authentication, use a short-lived token with the minimum possible permissions and revoke it when you are done.
|
||||
|
||||
For teams running internal tooling, it is worth auditing which online editors appear in your documentation or onboarding guides. If your getting-started instructions tell new engineers to paste their API key into a CodePen example to verify setup, that guide needs to change.
|
||||
|
||||
The researcher's methodology is worth replicating for any tool your team relies on. Creating a clearly fake but realistic-looking credential, pasting it somewhere, and watching what your network inspector shows you is a fifteen-minute audit that can reveal a lot. I ran a quick version of this against a staging environment config tool we use internally and was mildly uncomfortable with one of the results. That discomfort was useful.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: "Vibe-Coded and Exposed: What the Huntarr Security Disaster Actually Means for Self-Hosters"
|
||||
date: "2026-03-01T00:00:00.000Z"
|
||||
excerpt: "I run Sonarr, Radarr, and Prowlarr on my home server, and when I saw the Huntarr post blow up, I went straight to my own stack before I finished..."
|
||||
coverImage: "/blog/images/vibe-coded-and-exposed-what-the-huntarr-security-disaster-actually-means-for-sel.png"
|
||||
hashtags:
|
||||
- security
|
||||
- self-hosting
|
||||
- vibe-coding
|
||||
- arr-stack
|
||||
- credentials
|
||||
- container-security
|
||||
sourceTitle: "Huntarr - Your passwords and your entire arr stack's API keys are exposed to anyone on your network, or worse, the internet."
|
||||
sourceUrl: "https://reddit.com/r/selfhosted/comments/1rckopd/huntarr_your_passwords_and_your_entire_arr_stacks/"
|
||||
---
|
||||
|
||||
I run Sonarr, Radarr, and Prowlarr on my home server, and when I saw the Huntarr post blow up, I went straight to my own stack before I finished reading it. That instinct turned out to be the right one.
|
||||
|
||||
What Huntarr Actually Did Wrong
|
||||
|
||||
The researcher who found this was already skeptical. They had raised concerns about development standards in the Huntarr community and got banned for it. That ban is what pushed them to do a proper code review. What they found was straightforward and brutal: unauthenticated API endpoints were returning stored credentials in plain text. No login required. If Huntarr was reachable on your network -- or worse, exposed to the internet -- any request to the right endpoint handed over your Sonarr, Radarr, and Prowlarr API keys to whoever asked.
|
||||
|
||||
Those API keys are not just read-only tokens. They give full control over your media stack. Someone with your Sonarr key can add indexers, change paths, delete shows, or point your downloads somewhere you do not want them going. The blast radius here is your entire arr setup, not just Huntarr.
|
||||
|
||||
The phrase that keeps coming up in the thread is "vibe coded." The codebase shows signs of being generated and iterated with AI assistance without anyone doing a structured security review at any point. That is not a condemnation of using AI to write code. I use it constantly. But there is a real difference between using AI to accelerate work you understand and shipping AI output as production software that handles credentials.
|
||||
|
||||
The Pattern You Should Watch For
|
||||
|
||||
I pulled up a few of the other threads spawned by this incident, including one asking for a community list of vibe-coded self-hosted projects. The replies are instructive. People are identifying the tells: no input validation, secrets stored in config that get echoed back through the UI, authentication middleware that is present but not actually applied to every route.
|
||||
|
||||
That last one is the classic mistake. I have made a version of it myself. You write auth middleware, you test the protected routes, and you forget to wire up three utility endpoints that seemed harmless at the time. An AI generating scaffolding code will do this constantly because it is optimizing for "here is a working example" rather than "here is a complete security model."
|
||||
|
||||
If you are running anything on your home network that touches credentials -- and almost everything in the arr ecosystem does -- the minimum bar is running something like trivy or grype against your containers and actually reading the output. The post about OpenClaw from the same week found over 2,000 CVEs in an official image, including 10 critical ones, in a container that had access to messaging channels, API keys, and filesystem read/write. These are not theoretical risks.
|
||||
|
||||
What I Actually Did After Reading This
|
||||
|
||||
I checked my Huntarr installation first (I had it running as a trial). Then I went through my other arr-adjacent services and looked at which ones were reachable from outside my Tailscale network. Then I ran a quick scan on a few containers I had pulled and not looked at closely.
|
||||
|
||||
The boring answer is: put everything behind a VPN or auth proxy, do not expose management interfaces directly to the internet, and treat any project that cannot point you to a security review as untrusted until proven otherwise. None of that is new advice. The Huntarr situation is a reminder that the consequences of skipping it are real.
|
||||
|
||||
The developer who found this got banned for asking questions. That is the part that should stick with you. A project that responds to security concerns by silencing the person raising them is telling you something about how it handles other problems you cannot see yet.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
title: "Vibe Coding Left Stripe Keys in Production. The Developer Isn't Blaming the AI."
|
||||
date: "2026-03-08T00:00:00.000Z"
|
||||
excerpt: "I was looking at the screenshot from r/webdev and my first reaction was not surprise. Someone vibe-coded a project, left live Stripe API keys in..."
|
||||
coverImage: "/blog/images/vibe-coding-left-stripe-keys-in-production-the-developer-isn-t-blaming-the-ai.png"
|
||||
hashtags:
|
||||
- security
|
||||
- vibe-coding
|
||||
- ai-tools
|
||||
- secrets-management
|
||||
- developer-workflow
|
||||
sourceTitle: "Vibe code IRL: left Stripe API keys public"
|
||||
sourceUrl: "https://reddit.com/r/webdev/comments/1rl0yp0/vibe_code_irl_left_stripe_api_keys_public/"
|
||||
---
|
||||
|
||||
I was looking at the screenshot from r/webdev and my first reaction was not surprise. Someone vibe-coded a project, left live Stripe API keys in public-facing code, and the comment thread noted they weren't blaming Claude.
|
||||
|
||||
What the Screenshot Actually Shows
|
||||
|
||||
The post is light on details by design -- it's a screenshot, not a writeup -- but the shape of the story is familiar. A developer built something using an AI code editor, moved fast, and shipped with real credentials baked into code that ended up visible. The comment that stands out is 'I'm surprised they'd want to go public. Of course they don't blame Claude.'
|
||||
|
||||
That last line is doing a lot of work. Not blaming the AI is the correct take, technically. The developer is responsible for what they ship. But it papers over a real problem: when you're generating code in chunks and accepting suggestions at speed, the review discipline that would normally catch a hardcoded API key gets skipped. You're reading outputs instead of writing code, and your brain is in a different mode. I know because I've done it. I caught myself once only because I happened to grep for 'sk-' before a commit. That was luck, not process.
|
||||
|
||||
Stripe keys are particularly bad here. A leaked publishable key is embarrassing. A leaked secret key means someone can issue refunds, pull customer data, create charges, or drain a balance depending on your permissions setup. The blast radius is not theoretical.
|
||||
|
||||
The Workflow Problem Nobody Wants to Admit
|
||||
|
||||
I've spent time this year working with Cursor and Claude on side projects. The speed is real. The problem is also real. When an AI generates a config file or an environment setup block, it will sometimes inline a placeholder that looks like a real value. If you're moving fast, you swap in your actual key to test, and then you forget the file exists. The AI did not tell you to commit it. But it also did not make the credential-handling pattern obvious, and you were not in a headspace to notice.
|
||||
|
||||
The fix is not complicated. Environment variables, a secrets manager, or even just a pre-commit hook that scans for key patterns -- any of these would have caught this. Tools like git-secrets or truffleHog take about ten minutes to set up. The r/selfhosted community was arguing this same week about whether to require AI disclosure on project posts, specifically because vibe-coded apps were showing up with security holes that the authors did not know about.
|
||||
|
||||
That debate is worth having. But disclosure labels do not fix the underlying issue, which is that AI-assisted development at speed produces code that requires slower, more deliberate review than most people are giving it.
|
||||
|
||||
What I Actually Do Now
|
||||
|
||||
After my own near-miss, I added two things to every project I touch. First, a pre-commit hook using detect-secrets. It runs in under a second and it has caught two things I would have missed. Second, I keep a short mental checklist for any AI-generated file that involves config, environment, or authentication: does this file get committed, does it contain any value that looks like a credential, and is that value coming from an environment variable rather than being hardcoded. That is three questions. It takes maybe fifteen seconds.
|
||||
|
||||
The Stripe key story is not really about AI being dangerous. It is about moving fast without adjusting your review habits to match the new way code gets produced. The velocity is higher now. The mistakes are the same old ones.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
title: "Your Self-Hosted AI Container Has 2,000 CVEs. That's a Problem."
|
||||
date: "2026-03-01T00:00:00.000Z"
|
||||
excerpt: "I was reading through the r/selfhosted thread about vulnerable AI containers and had the same sinking feeling I get when I realize I left a port open..."
|
||||
coverImage: "/blog/images/your-self-hosted-ai-container-has-2-000-cves-that-s-a-problem.png"
|
||||
hashtags:
|
||||
- security
|
||||
- self-hosting
|
||||
- containers
|
||||
- ai
|
||||
- homelab
|
||||
- devops
|
||||
sourceTitle: "The whole point of self-hosting your AI is to control your data. Kind of defeats the purpose if the container has 2,000 known vulnerabilities"
|
||||
sourceUrl: "https://www.reddit.com/r/selfhosted/comments/1rfrtg7/the_whole_point_of_selfhosting_your_ai_is_to/"
|
||||
---
|
||||
|
||||
I was reading through the r/selfhosted thread about vulnerable AI containers and had the same sinking feeling I get when I realize I left a port open for six months. The post is short, but the problem it describes is not.
|
||||
|
||||
The Setup Makes This Worse Than Usual
|
||||
|
||||
The person who posted this ran a scan on their OpenClaw container -- a self-hosted AI setup wired into WhatsApp and Telegram -- and found 2,000-plus CVEs in the official image, with 10 rated critical. That number alone is bad. But the part that stuck with me is what OpenClaw actually has access to: messaging channels, API keys, filesystem read and write, and command execution.
|
||||
|
||||
This is the thing people miss when they talk about self-hosting AI for privacy. You pull the official image, docker-compose up, and you're done. But you've just handed a container full of unpatched vulnerabilities the keys to your messaging history, your credentials, and your filesystem. The whole point of running local AI is supposed to be that your data doesn't leave your control. A container with 10 critical CVEs is a different kind of data leak waiting to happen.
|
||||
|
||||
I've done the same thing. I've pulled official images assuming that "official" meant someone was watching the security side of things. Sometimes they are. Often they aren't, especially for smaller open source projects that are moving fast and don't have a dedicated security person.
|
||||
|
||||
What You Can Actually Do
|
||||
|
||||
Scanning your containers is not hard. Trivy is free and takes about two minutes to set up. You run `trivy image your-image-name` and it gives you a full CVE list sorted by severity. Grype is another option. Neither requires an account or any external service -- they run locally against your image. I connected Trivy to a simple cron job on my homelab that scans my running containers weekly and dumps the output to a log file. It's not fancy but it means I'm not flying blind.
|
||||
|
||||
The harder question is what to do when you find vulnerabilities. For most hobby containers, your options are: wait for the upstream maintainer to fix it, build your own image from a cleaner base, or accept the risk. None of those feel great. If the project is active, opening an issue with your scan results is actually useful -- maintainers often don't scan their own published images regularly, and a concrete Trivy report with reproducible steps is more actionable than a vague complaint.
|
||||
|
||||
For AI containers specifically, I'd add one more thing: think hard about what you're mounting and what network access you're giving. If your local LLM doesn't need access to your messaging apps, don't wire it up. Least-privilege applies here the same as anywhere else. The reason the OpenClaw situation is particularly uncomfortable is that the container was given maximum access to the most sensitive parts of someone's digital life. Even if you can't fix the CVEs immediately, you can limit the blast radius.
|
||||
|
||||
The Vibe-Coded Problem Underneath This
|
||||
|
||||
There's a related thread from this week about AI-assisted and vibe-coded self-hosted services having security issues, and I think these two posts are actually about the same underlying problem. A lot of self-hosted software is being shipped fast right now -- AI tooling especially -- and security hygiene is lagging behind the feature velocity. Projects get popular quickly, people pull the images without thinking, and suddenly thousands of homelab setups are running containers that haven't had a security review.
|
||||
|
||||
I'm not saying don't use these tools. I use several of them. But I've started treating any AI-adjacent container the same way I'd treat exposing a service to the public internet: scan it first, scope its permissions tightly, and check back every few months. The privacy argument for self-hosting only holds if the thing you're running is actually more secure than the cloud alternative. Right now, for a lot of these images, that assumption deserves more scrutiny than it's getting.
|
||||
0
content/pages/.gitkeep
Normal file
0
content/pages/.gitkeep
Normal file
30
content/pages/test.json
Normal file
30
content/pages/test.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"title": "Test",
|
||||
"blocks": [
|
||||
{
|
||||
"title": "test",
|
||||
"plans": [
|
||||
{
|
||||
"name": "Test",
|
||||
"price": "344",
|
||||
"period": "2",
|
||||
"features": [
|
||||
"квайцук"
|
||||
],
|
||||
"highlighted": true
|
||||
}
|
||||
],
|
||||
"_template": "pricing"
|
||||
},
|
||||
{
|
||||
"content": "",
|
||||
"_template": "textBlock"
|
||||
},
|
||||
{
|
||||
"_template": "hero"
|
||||
},
|
||||
{
|
||||
"_template": "testimonials"
|
||||
}
|
||||
]
|
||||
}
|
||||
778
content/translations/en.json
Normal file
778
content/translations/en.json
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
{
|
||||
"design": {
|
||||
"colorPrimary": "#FF5B04",
|
||||
"colorBackground": "#233038",
|
||||
"colorAccent": "#075056",
|
||||
"colorYellow": "#FCA82E",
|
||||
"colorText": "#D3DDDE"
|
||||
},
|
||||
"header": {
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"about": "About Us",
|
||||
"services": "Services",
|
||||
"pricing": "Pricing",
|
||||
"blog": "Blog",
|
||||
"contacts": "Contacts"
|
||||
},
|
||||
"lang": {
|
||||
"en": "Eng",
|
||||
"uk": "Ukr"
|
||||
},
|
||||
"login": "Log in",
|
||||
"loginModal": {
|
||||
"title": "Welcome Back",
|
||||
"emailLabel": "Email / Login",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"submit": "Log In",
|
||||
"signupPrompt": "Don't have an account?",
|
||||
"signupLink": "Sign up now"
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"circle1": "Build.",
|
||||
"circle2": "Automate.",
|
||||
"circle3": "Impress.",
|
||||
"title": "Stop Hiring. Start Scaling. Deploy Your AI Digital Workforce.",
|
||||
"cta": "Get your free consultation"
|
||||
},
|
||||
"benefits": {
|
||||
"card1": {
|
||||
"front": "Cost & Scale",
|
||||
"subtitle": "Elastic Growth, Zero Headcount",
|
||||
"back": "Scale 10x without hiring. AI assistants handle peak loads automatically, keeping your operations elastic and efficient."
|
||||
},
|
||||
"card2": {
|
||||
"front": "Accuracy",
|
||||
"subtitle": "Machine Learning Precision (99.9%)",
|
||||
"back": "Eliminate human error. Our ML-validated workflows guarantee data integrity across CRM, finance, and logistics systems."
|
||||
},
|
||||
"card3": {
|
||||
"front": "Availability",
|
||||
"subtitle": "True 24/7/365 Operations",
|
||||
"back": "Your AI workforce never sleeps, takes breaks, or burns out. Serve global clients nonstop."
|
||||
},
|
||||
"builtTitle": "Built for Local Entrepreneurs Like You",
|
||||
"builtDesc": "AImpress turns chaos into a system. We build automations that save up to 75% of your time and boost sales.",
|
||||
"static1": {
|
||||
"title": "Chatbots & AI Assistants",
|
||||
"desc": "→ instant answers 24/7"
|
||||
},
|
||||
"static2": {
|
||||
"title": "Business Process Automation (n8n, Make.com)",
|
||||
"desc": "→ CRM, email, finance, inventory"
|
||||
},
|
||||
"static3": {
|
||||
"title": "Content Farms & AI Copywriting",
|
||||
"desc": "→ AI creates posts, articles, product descriptions"
|
||||
},
|
||||
"static4": {
|
||||
"title": "Marketing Automation (email, CRM, ads)",
|
||||
"desc": "→ campaigns, lead nurturing, ads on autopilot"
|
||||
}
|
||||
},
|
||||
"banner1": {
|
||||
"q1": "How many employees in your company?",
|
||||
"q2": "How many hours per day on repetitive tasks?",
|
||||
"q3": "Which processes do you want to automate?",
|
||||
"cta": "Get Your Free Consultation"
|
||||
},
|
||||
"realResults": {
|
||||
"title": "Real Results from Real Local Clients",
|
||||
"card1": {
|
||||
"title": "AutoBrat Garage",
|
||||
"resultsLabel": "Results:",
|
||||
"desc": "Ukrainian-founded specialist garage struggled with local recognition. AImpress created bilingual content showcasing their German vehicle expertise.",
|
||||
"stat1": "157% - increase in bookings",
|
||||
"stat2": "85% - service bay utilisation",
|
||||
"stat3": "recognition in Oxford Mail within 6 months"
|
||||
},
|
||||
"card2": {
|
||||
"title": "Cotswolld Honey Company",
|
||||
"desc": "Artisanal honey producer limited to seasonal markets. AImpress developed e-commerce and educational content highlighting sustainable practices.",
|
||||
"stat1": "78% - increase in online sales",
|
||||
"stat2": "four new retail partnerships in just 4 months"
|
||||
},
|
||||
"card3": {
|
||||
"title": "Wcounting Accounting Partners",
|
||||
"desc": "Traditional firm losing younger clients. AImpress created accessible financial guides and Making Tax Digital content.",
|
||||
"stat1": "41% - increase in under-40 enquiries",
|
||||
"stat2": "12 - new e-commerce clients within 5 months"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Your Path to Autonomous Operations",
|
||||
"step1": {
|
||||
"title": "Challenge Briefing",
|
||||
"duration": "2h",
|
||||
"short": "Identify top-impact opportunities and define ROI metrics.",
|
||||
"detail": "We audit your workflows, pinpoint bottlenecks, and map out the highest-ROI automation targets — all in a single focused session."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Tech Assessment & Strategy",
|
||||
"duration": "2–3 days",
|
||||
"short": "Review existing stack, architecture, and compliance.",
|
||||
"detail": "Deep dive into your CRM, accounting, comms, and data flow. We design the integration blueprint and security model before writing a single line."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Proof of Concept",
|
||||
"duration": "8–12 weeks",
|
||||
"short": "Pilot for a critical process — visible ROI.",
|
||||
"detail": "We build and deploy a working automation for your most painful workflow. You'll see measurable time and cost savings within the first sprint."
|
||||
},
|
||||
"step4": {
|
||||
"title": "MVP Implementation",
|
||||
"duration": "2–3 months",
|
||||
"short": "Full deployment, team training, and integration.",
|
||||
"detail": "Roll out across departments with hands-on training, monitoring dashboards, and a dedicated Slack channel for your team to get instant support."
|
||||
},
|
||||
"step5": {
|
||||
"title": "Scaling & Optimization",
|
||||
"duration": "Ongoing",
|
||||
"short": "Continuous improvement and expansion.",
|
||||
"detail": "Monthly performance reviews, A/B tested automation tweaks, and expansion into new departments. Your AI workforce grows with you."
|
||||
}
|
||||
},
|
||||
"banner2": {
|
||||
"cta": "Get Your Free Consultation"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Why Businesses Switch to AImpress",
|
||||
"aiLabel": "AI-Powered",
|
||||
"metric1": {
|
||||
"label": "Cost",
|
||||
"ai": "From £1,500",
|
||||
"agency": "£5,000+ /mo agency",
|
||||
"inhouse": "£35,000+ /yr in-house"
|
||||
},
|
||||
"metric2": {
|
||||
"label": "Speed",
|
||||
"ai": "Instant",
|
||||
"agency": "Depends on manager",
|
||||
"inhouse": "9-to-5 only"
|
||||
},
|
||||
"metric3": {
|
||||
"label": "Availability",
|
||||
"ai": "24 / 7 / 365",
|
||||
"agency": "Business hours",
|
||||
"inhouse": "Sick leaves & holidays"
|
||||
},
|
||||
"metric4": {
|
||||
"label": "Scalability",
|
||||
"ai": "Unlimited",
|
||||
"agency": "Limited by staff",
|
||||
"inhouse": "Hard to scale"
|
||||
},
|
||||
"altHeading": "The alternatives",
|
||||
"alt1": "Traditional Agency",
|
||||
"alt2": "In-House Hire",
|
||||
"footer": "Switch from legacy methods — save up to 70% on costs and 30+ hours per week.",
|
||||
"cta": "Get Your Free Consultation"
|
||||
},
|
||||
"blogSection": {
|
||||
"title": "Recent Updates",
|
||||
"readMore": "Read More →",
|
||||
"viewAll": "View All Posts →"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Free Resources to Scale Your Business"
|
||||
},
|
||||
"contactSection": {
|
||||
"title": "Ready to Automate?",
|
||||
"subtitle": "Stop wasting time on routine. Start scaling with AI today."
|
||||
},
|
||||
"contactForm": {
|
||||
"title": "Get in touch:",
|
||||
"fullName": "Full Name",
|
||||
"fullNamePlaceholder": "John Doe",
|
||||
"jobTitle": "Job Title / Role",
|
||||
"jobTitlePlaceholder": "Project Manager",
|
||||
"email": "Work Email",
|
||||
"emailPlaceholder": "john@company.com",
|
||||
"need": "Automation Need",
|
||||
"needPlaceholder": "Workflow optimization",
|
||||
"company": "Company Name",
|
||||
"companyPlaceholder": "Tech Solutions Inc.",
|
||||
"phone": "Phone Number",
|
||||
"phonePlaceholder": "+44...",
|
||||
"submit": "Submit a request",
|
||||
"sending": "Sending...",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"successTitle": "Thank you!",
|
||||
"successText": "We have received your request and will contact you shortly.",
|
||||
"sendAnother": "Send another"
|
||||
},
|
||||
"footer": {
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Use",
|
||||
"copyright": "© 2026 AImpress LTD. All rights reserved."
|
||||
},
|
||||
"cookie": {
|
||||
"text": "We use cookies and similar technologies to analyse website traffic and improve your experience. By clicking \"Accept\", you consent to the use of analytics cookies. See our {privacyLink} for details.",
|
||||
"privacyLink": "Privacy Policy",
|
||||
"reject": "Reject",
|
||||
"accept": "Accept"
|
||||
},
|
||||
"chat": {
|
||||
"greeting": "Hi! How can I help you today?",
|
||||
"openChat": "Open chat",
|
||||
"headerTitle": "AImpress",
|
||||
"status": "Online",
|
||||
"clearChat": "Clear chat",
|
||||
"closeChat": "Close chat",
|
||||
"welcome": "Hi! I'm the AImpress AI assistant. Ask me about our AI & automation services, pricing, or book a free consultation.",
|
||||
"lead": {
|
||||
"title": "AImpress",
|
||||
"subtitle": "AI & Automation Consultancy",
|
||||
"intro": "Hi! Before we start, please introduce yourself so we can better assist you.",
|
||||
"namePlaceholder": "Your name *",
|
||||
"nameError": "Please enter your name",
|
||||
"emailPlaceholder": "Email *",
|
||||
"emailError": "Please enter your email",
|
||||
"emailInvalid": "Please enter a valid email",
|
||||
"companyPlaceholder": "Company (optional)",
|
||||
"consent": "I agree to the processing of my personal data in accordance with the {privacyLink}",
|
||||
"privacyLink": "Privacy Policy",
|
||||
"consentError": "Please accept to continue",
|
||||
"submit": "Start Chat"
|
||||
},
|
||||
"inputPlaceholder": "Type a message...",
|
||||
"send": "Send message"
|
||||
},
|
||||
"quoteForm": {
|
||||
"title": "Get Your Quote",
|
||||
"fullName": "Full Name",
|
||||
"fullNamePlaceholder": "John Doe",
|
||||
"jobTitle": "Job Title / Role",
|
||||
"jobTitlePlaceholder": "Project Manager",
|
||||
"email": "Work Email",
|
||||
"emailPlaceholder": "john@company.com",
|
||||
"phone": "Phone Number",
|
||||
"phonePlaceholder": "+44...",
|
||||
"company": "Company Name",
|
||||
"companyPlaceholder": "Tech Solutions Ltd",
|
||||
"service": "Service",
|
||||
"serviceDefault": "Select a service...",
|
||||
"service1": "Workflow Automation Implementation",
|
||||
"service2": "System Integration & Synchronisation",
|
||||
"service3": "CRM Workflow Optimisation",
|
||||
"service4": "Marketing Automation Setup",
|
||||
"service5": "AI Integration & Enhancement",
|
||||
"service6": "Infrastructure Setup & Configuration",
|
||||
"service7": "Support Retainer",
|
||||
"service8": "Training & Workshop",
|
||||
"service9": "Other / Not sure yet",
|
||||
"description": "Project Description",
|
||||
"descriptionPlaceholder": "Describe your automation needs, current challenges, and desired outcomes...",
|
||||
"submit": "Get Your Quote",
|
||||
"sending": "Sending...",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"successTitle": "Thank you!",
|
||||
"successText": "We've received your quote request. We'll review your requirements and get back to you within 24 hours with a detailed proposal.",
|
||||
"sendAnother": "Submit another request"
|
||||
},
|
||||
"about": {
|
||||
"hero": {
|
||||
"title": "About AImpress",
|
||||
"subtitle": "Making professional automation accessible to impact-driven organisations."
|
||||
},
|
||||
"story": {
|
||||
"title": "Our Story",
|
||||
"p1": "Automation is no longer optional — it's the difference between growing and getting left behind. Yet for most SMEs, the options have been limited: offshore teams that are cheap but unreliable, or UK agencies that charge enterprise prices for basic work.",
|
||||
"p2": "AImpress was founded to fill that gap. We're a London-based consultancy that brings enterprise-grade automation to small and medium businesses, charities, and public sector organisations — at prices that actually make sense.",
|
||||
"p3": "We don't believe in black-box solutions or vendor lock-in. Every system we build runs on infrastructure you own, with documentation your team can follow. When we leave, you keep everything — and you know how it works."
|
||||
},
|
||||
"diff": {
|
||||
"title": "What Makes Us Different"
|
||||
},
|
||||
"diff1": {
|
||||
"title": "UK-Based, London Standards",
|
||||
"desc": "Competitive rates of £90–120/hr. UK business hours, GDPR-compliant by default, full accountability under English law."
|
||||
},
|
||||
"diff2": {
|
||||
"title": "SME Specialisation",
|
||||
"desc": "We focus on CRM, marketing, finance, and e-commerce automation — the workflows that matter most to growing businesses."
|
||||
},
|
||||
"diff3": {
|
||||
"title": "Client-Owned Infrastructure",
|
||||
"desc": "Your servers, your data, your accounts. We build on infrastructure you own — no vendor lock-in, no hostage situations."
|
||||
},
|
||||
"diff4": {
|
||||
"title": "Knowledge Transfer, Not Dependency",
|
||||
"desc": "Every project includes full documentation and team training. We teach your people to maintain what we build."
|
||||
},
|
||||
"values": {
|
||||
"title": "Our Values"
|
||||
},
|
||||
"val1": {
|
||||
"name": "Transparency",
|
||||
"desc": "Fixed prices, clear scope, no hidden fees"
|
||||
},
|
||||
"val2": {
|
||||
"name": "Client Ownership",
|
||||
"desc": "You own everything we build — code, data, infrastructure"
|
||||
},
|
||||
"val3": {
|
||||
"name": "Excellence",
|
||||
"desc": "Enterprise-grade quality at SME-friendly prices"
|
||||
},
|
||||
"val4": {
|
||||
"name": "Impact Over Profit",
|
||||
"desc": "Discounted rates for charities, startups, and public sector"
|
||||
},
|
||||
"val5": {
|
||||
"name": "Pragmatism",
|
||||
"desc": "We recommend what works, not what costs more"
|
||||
},
|
||||
"founder": {
|
||||
"title": "Meet the Founder",
|
||||
"name": "Vadym Samoilenko",
|
||||
"role": "CEO & Founder of AImpress Ltd",
|
||||
"bgLabel": "Background:",
|
||||
"bgText": "2.5+ years at OLIVER Agency (WPP) as Global Automation & AI Specialist, architecting AI-powered workflows that reduced manual effort by 30–50% across creative operations for global brands",
|
||||
"certLabel": "AI & Automation Certifications:",
|
||||
"certText": "Prompt Engineering Specialization (Vanderbilt University), Generative AI for Marketing (Microsoft Copilot), Prompt Design in Vertex AI (Google), AI in Business (LinkedIn), Make Basics",
|
||||
"analyticsLabel": "Analytics Certifications:",
|
||||
"analyticsText": "Microsoft Power BI Data Analyst, Laba Business Analytics & Marketing Analytics",
|
||||
"eduLabel": "Education:",
|
||||
"eduText": "Master's in Economic Cybernetics — systems modelling, data analysis, process optimisation",
|
||||
"visionLabel": "Vision:",
|
||||
"visionText": "Founded AImpress to bring enterprise-level automation to SMEs at accessible price points"
|
||||
},
|
||||
"industries": {
|
||||
"title": "Industries We Serve"
|
||||
},
|
||||
"ind1": {
|
||||
"name": "E-commerce",
|
||||
"desc": "Order processing, inventory sync, customer journeys"
|
||||
},
|
||||
"ind2": {
|
||||
"name": "Professional Services",
|
||||
"desc": "CRM, invoicing, client onboarding automation"
|
||||
},
|
||||
"ind3": {
|
||||
"name": "SaaS",
|
||||
"desc": "User onboarding, billing, support ticket workflows"
|
||||
},
|
||||
"ind4": {
|
||||
"name": "Charities",
|
||||
"desc": "Donor management, grant reporting, volunteer coordination"
|
||||
},
|
||||
"ind5": {
|
||||
"name": "Education",
|
||||
"desc": "Enrolment, student comms, content delivery"
|
||||
},
|
||||
"ind6": {
|
||||
"name": "Healthcare",
|
||||
"desc": "Appointment scheduling, patient records, compliance"
|
||||
},
|
||||
"ind7": {
|
||||
"name": "Public Sector",
|
||||
"desc": "Case management, reporting, citizen services"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to Transform Your Operations?",
|
||||
"subtitle": "Book a free discovery call and find out how automation can work for your business.",
|
||||
"button": "Book a Free Discovery Call"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"hero": {
|
||||
"title": "Our Services",
|
||||
"subtitle": "End-to-end automation consulting — from discovery to deployment and beyond."
|
||||
},
|
||||
"s1": {
|
||||
"title": "AI Chatbots & Virtual Assistants",
|
||||
"price": "£3,000 – £10,000",
|
||||
"purpose": "Deploy intelligent chatbots that handle customer support, qualify leads, and book appointments 24/7 — no human required.",
|
||||
"f1": "Custom chatbot design & personality",
|
||||
"f2": "Integration with your website, WhatsApp, or Messenger",
|
||||
"f3": "Knowledge base training on your business data",
|
||||
"f4": "Lead qualification & CRM handoff",
|
||||
"f5": "Multi-language support",
|
||||
"f6": "Analytics dashboard & conversation insights"
|
||||
},
|
||||
"s2": {
|
||||
"title": "Custom Website Development",
|
||||
"price": "£2,500 – £15,000",
|
||||
"purpose": "High-performance, conversion-optimised websites built with modern tech — fast, SEO-ready, and fully yours.",
|
||||
"f1": "Custom design & responsive development",
|
||||
"f2": "React / Next.js or WordPress build",
|
||||
"f3": "SEO optimisation & Core Web Vitals",
|
||||
"f4": "CMS integration for easy content updates",
|
||||
"f5": "Contact forms, analytics & tracking setup",
|
||||
"f6": "Hosting setup on your own infrastructure"
|
||||
},
|
||||
"s3": {
|
||||
"title": "Workflow Automation Implementation",
|
||||
"price": "£3,500 – £12,000",
|
||||
"purpose": "Automate repetitive business processes end-to-end so your team focuses on high-value work.",
|
||||
"f1": "Process discovery & mapping",
|
||||
"f2": "Custom workflow design & build (n8n / Make.com)",
|
||||
"f3": "Multi-step logic with conditional branching",
|
||||
"f4": "Error handling & retry mechanisms",
|
||||
"f5": "Testing, deployment & documentation"
|
||||
},
|
||||
"s4": {
|
||||
"title": "System Integration & Synchronisation",
|
||||
"price": "£2,500 – £10,000+",
|
||||
"purpose": "Connect your tools into a single source of truth — CRM, accounting, e-commerce, comms, and more.",
|
||||
"f1": "API integration between platforms",
|
||||
"f2": "Bi-directional data sync",
|
||||
"f3": "Data mapping & transformation",
|
||||
"f4": "Webhook & event-driven triggers",
|
||||
"f5": "Monitoring & alerting setup"
|
||||
},
|
||||
"s5": {
|
||||
"title": "CRM Workflow Optimisation",
|
||||
"price": "£3,000 – £6,500",
|
||||
"purpose": "Streamline your sales pipeline, automate follow-ups, and ensure no lead falls through the cracks.",
|
||||
"f1": "CRM audit & pipeline restructure",
|
||||
"f2": "Automated lead scoring & routing",
|
||||
"f3": "Email sequence automation",
|
||||
"f4": "Task & reminder workflows",
|
||||
"f5": "Reporting dashboard setup"
|
||||
},
|
||||
"s6": {
|
||||
"title": "Marketing Automation Setup",
|
||||
"price": "£3,500 – £8,000",
|
||||
"purpose": "Put your campaigns, lead nurturing, and ads on autopilot with targeted, data-driven automation.",
|
||||
"f1": "Email drip campaign setup",
|
||||
"f2": "Lead capture & form automation",
|
||||
"f3": "Audience segmentation logic",
|
||||
"f4": "Social media scheduling integration",
|
||||
"f5": "Analytics & conversion tracking"
|
||||
},
|
||||
"s7": {
|
||||
"title": "AI Integration & Enhancement",
|
||||
"price": "£4,000 – £12,000",
|
||||
"purpose": "Add AI capabilities to your existing workflows — chatbots, content generation, document processing, and more.",
|
||||
"f1": "AI model selection & integration (OpenAI, Claude, etc.)",
|
||||
"f2": "Custom chatbot / virtual assistant build",
|
||||
"f3": "Document & data extraction with AI",
|
||||
"f4": "AI-powered content generation pipelines",
|
||||
"f5": "Prompt engineering & fine-tuning"
|
||||
},
|
||||
"s8": {
|
||||
"title": "Infrastructure Setup & Configuration",
|
||||
"price": "£1,500 – £4,000",
|
||||
"purpose": "Set up your automation infrastructure on servers you own — secure, scalable, and fully yours.",
|
||||
"f1": "Server provisioning (VPS / cloud)",
|
||||
"f2": "n8n / automation platform deployment",
|
||||
"f3": "SSL, firewall & security hardening",
|
||||
"f4": "Backup & recovery configuration",
|
||||
"f5": "Monitoring & uptime alerting"
|
||||
},
|
||||
"popular": "Popular",
|
||||
"whatsIncluded": "What's included",
|
||||
"showLess": "Show less",
|
||||
"assurance": {
|
||||
"title": "AImpress Assurance Pack",
|
||||
"subtitle": "Included free in every project (value £1,500)",
|
||||
"i1": "Dedicated project manager",
|
||||
"i2": "Weekly progress reports",
|
||||
"i3": "Full technical documentation",
|
||||
"i4": "End-user training session",
|
||||
"i5": "Admin training session",
|
||||
"i6": "30-day post-launch support",
|
||||
"i7": "Bug fixes within SLA",
|
||||
"i8": "Knowledge base & runbooks",
|
||||
"i9": "Handover & transition plan"
|
||||
},
|
||||
"metrics": {
|
||||
"title": "Results in Numbers",
|
||||
"v1": "30–50%",
|
||||
"l1": "Reduction in manual work",
|
||||
"v2": "2–8 wks",
|
||||
"l2": "Average delivery time",
|
||||
"v3": "24/7",
|
||||
"l3": "Automated uptime",
|
||||
"v4": "£0",
|
||||
"l4": "Vendor lock-in fees"
|
||||
},
|
||||
"selector": {
|
||||
"title": "Which Service Do You Need?",
|
||||
"step1": "Your Goal",
|
||||
"step2": "Budget",
|
||||
"step3": "Results",
|
||||
"goalQ": "What's your primary goal?",
|
||||
"goal1": "Automate workflows",
|
||||
"goal2": "Get more leads",
|
||||
"goal3": "Build online presence",
|
||||
"goal4": "Connect my tools",
|
||||
"goal5": "Add AI capabilities",
|
||||
"budgetQ": "What's your budget range?",
|
||||
"budget1": "Under £5K",
|
||||
"budget2": "£5K – £10K",
|
||||
"budget3": "£10K+",
|
||||
"resultsHeading": "We recommend these services:",
|
||||
"noMatch": "No exact match — but we can help! Contact us for a custom solution.",
|
||||
"viewButton": "View Services",
|
||||
"resetButton": "Start Over",
|
||||
"backButton": "← Back"
|
||||
},
|
||||
"bundles": {
|
||||
"title": "Popular Bundles"
|
||||
},
|
||||
"bundle1": {
|
||||
"name": "Starter",
|
||||
"tagline": "Launch & Engage",
|
||||
"price": "from £5,500"
|
||||
},
|
||||
"bundle2": {
|
||||
"name": "Growth",
|
||||
"tagline": "Scale & Convert",
|
||||
"price": "from £9,000",
|
||||
"badge": "Most Popular"
|
||||
},
|
||||
"bundle3": {
|
||||
"name": "Full Stack",
|
||||
"tagline": "Transform Everything",
|
||||
"price": "Custom pricing"
|
||||
},
|
||||
"bundle": {
|
||||
"cta": "Get Started"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Not Sure Where to Start?",
|
||||
"subtitle": "Book a free consultation and we'll map out the best automation strategy for your business.",
|
||||
"button": "Get Your Free Consultation"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"hero": {
|
||||
"title": "Simple, Transparent Pricing",
|
||||
"subtitle": "Fixed-price projects. No surprise costs. No vendor lock-in."
|
||||
},
|
||||
"impl": {
|
||||
"title": "Implementation Pricing",
|
||||
"s1": {
|
||||
"_": "AI Chatbots & Virtual Assistants",
|
||||
"price": "£3,000 – £10,000"
|
||||
},
|
||||
"s2": {
|
||||
"_": "Custom Website Development",
|
||||
"price": "£2,500 – £15,000"
|
||||
},
|
||||
"s3": {
|
||||
"_": "Workflow Automation Implementation",
|
||||
"price": "£3,500 – £12,000"
|
||||
},
|
||||
"s4": {
|
||||
"_": "System Integration & Synchronisation",
|
||||
"price": "£2,500 – £10,000+"
|
||||
},
|
||||
"s5": {
|
||||
"_": "CRM Workflow Optimisation",
|
||||
"price": "£3,000 – £6,500"
|
||||
},
|
||||
"s6": {
|
||||
"_": "Marketing Automation Setup",
|
||||
"price": "£3,500 – £8,000"
|
||||
},
|
||||
"s7": {
|
||||
"_": "AI Integration & Enhancement",
|
||||
"price": "£4,000 – £12,000"
|
||||
},
|
||||
"s8": {
|
||||
"_": "Infrastructure Setup & Configuration",
|
||||
"price": "£1,500 – £4,000"
|
||||
}
|
||||
},
|
||||
"popular": "Popular",
|
||||
"retainers": {
|
||||
"title": "Support Retainers"
|
||||
},
|
||||
"ret1": {
|
||||
"name": "Essential",
|
||||
"price": "£1,000",
|
||||
"period": "/month",
|
||||
"hours": "10 hours",
|
||||
"sla": "48h response",
|
||||
"f1": "10 hours of support",
|
||||
"f2": "Bug fixes & minor updates",
|
||||
"f3": "48-hour response SLA",
|
||||
"f4": "Email support",
|
||||
"f5": "Monthly health check"
|
||||
},
|
||||
"ret2": {
|
||||
"name": "Professional",
|
||||
"price": "£2,000",
|
||||
"hours": "22 hours",
|
||||
"sla": "24h response",
|
||||
"badge": "Most Popular",
|
||||
"f1": "22 hours of support",
|
||||
"f2": "Bug fixes, updates & new features",
|
||||
"f3": "24-hour response SLA",
|
||||
"f4": "Email + Slack support",
|
||||
"f5": "Bi-weekly strategy call",
|
||||
"f6": "Priority scheduling"
|
||||
},
|
||||
"ret3": {
|
||||
"name": "Enterprise",
|
||||
"price": "£3,500",
|
||||
"hours": "40 hours",
|
||||
"sla": "4h response",
|
||||
"f1": "40 hours of support",
|
||||
"f2": "Full-scope development & support",
|
||||
"f3": "4-hour response SLA",
|
||||
"f4": "Dedicated Slack channel",
|
||||
"f5": "Weekly strategy call",
|
||||
"f6": "Priority scheduling",
|
||||
"f7": "Quarterly roadmap review"
|
||||
},
|
||||
"training": {
|
||||
"title": "Training & Workshops",
|
||||
"t1": {
|
||||
"_": "End-User Training",
|
||||
"price": "£250",
|
||||
"desc": "Per session"
|
||||
},
|
||||
"t2": {
|
||||
"_": "Admin Training",
|
||||
"price": "£600 – £1,000"
|
||||
},
|
||||
"t3": {
|
||||
"_": "Certification Programme",
|
||||
"price": "£1,800",
|
||||
"desc": "Full course"
|
||||
},
|
||||
"t4": {
|
||||
"_": "Custom Workshop",
|
||||
"price": "£400 – £1,200",
|
||||
"desc": "Half to full day"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment Terms",
|
||||
"r1": {
|
||||
"_": "Under £5,000",
|
||||
"split": "100% upfront"
|
||||
},
|
||||
"r2": {
|
||||
"_": "£5,000 – £10,000",
|
||||
"split": "50% / 50%"
|
||||
},
|
||||
"r3": {
|
||||
"_": "Over £10,000",
|
||||
"split": "33% / 33% / 34%"
|
||||
},
|
||||
"r4": {
|
||||
"_": "Public Sector",
|
||||
"split": "Net 30 terms"
|
||||
}
|
||||
},
|
||||
"discounts": {
|
||||
"title": "Impact Grant Programme",
|
||||
"intro": "We believe automation should be accessible to organisations making a difference. Eligible groups receive significant discounts.",
|
||||
"g1": {
|
||||
"_": "Charities & Non-Profits",
|
||||
"disc": "Up to 50%"
|
||||
},
|
||||
"g2": "Startups (< 2 years)",
|
||||
"g3": "Education",
|
||||
"g4": {
|
||||
"_": "Public Sector",
|
||||
"disc": "25% + free pilot project"
|
||||
},
|
||||
"g5": "Ukrainian Businesses"
|
||||
},
|
||||
"compare": {
|
||||
"title": "How We Compare",
|
||||
"aimpress": "AImpress",
|
||||
"agency": "Agency",
|
||||
"inhouse": "In-House",
|
||||
"r1": {
|
||||
"metric": "Setup Cost",
|
||||
"ai": "From £1,500",
|
||||
"agency": "£5,000+ /mo",
|
||||
"inhouse": "£35,000+ /yr"
|
||||
},
|
||||
"r2": {
|
||||
"metric": "Time to Deploy",
|
||||
"ai": "2–8 weeks",
|
||||
"agency": "3–6 months",
|
||||
"inhouse": "6–12 months"
|
||||
},
|
||||
"r3": {
|
||||
"metric": "Availability",
|
||||
"ai": "24/7 automated",
|
||||
"agency": "Business hours",
|
||||
"inhouse": "9-to-5 only"
|
||||
},
|
||||
"r4": {
|
||||
"metric": "Scalability",
|
||||
"ai": "Unlimited",
|
||||
"agency": "Staff-limited",
|
||||
"inhouse": "Hard to scale"
|
||||
},
|
||||
"r5": {
|
||||
"metric": "You Own It",
|
||||
"ai": "Yes, always",
|
||||
"agency": "Rarely"
|
||||
},
|
||||
"r6": {
|
||||
"metric": "Hidden Costs",
|
||||
"ai": "None",
|
||||
"agency": "Change requests",
|
||||
"inhouse": "Benefits, turnover"
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently Asked Questions",
|
||||
"q1": "Are you a UK company?",
|
||||
"a1": "Yes. AImpress Ltd is registered in England & Wales (company number 16417799), VAT-registered, and ICO-registered. We operate under English law with full GDPR compliance.",
|
||||
"q2": "Do you offer managed hosting?",
|
||||
"a2": "We set up infrastructure on your own servers or cloud accounts. You own everything. We can provide ongoing management through our support retainers if needed.",
|
||||
"q3": "What platforms do you work with?",
|
||||
"a3": "We work with n8n, Make.com, Zapier, Power Automate, HubSpot, Salesforce, Pipedrive, Mailchimp, OpenAI, Claude, and many more. If your tool has an API, we can integrate it.",
|
||||
"q4": "Can you work with our existing tools?",
|
||||
"a4": "Absolutely. We build on top of your current stack — we don't rip and replace. Our goal is to connect and automate what you already have.",
|
||||
"q5": "What if I don't qualify for a discount?",
|
||||
"a5": "Our standard rates are already competitive at £90–120/hr — significantly below typical UK agency rates. We offer fixed-price projects so you always know the total cost upfront.",
|
||||
"q6": "Do you work with clients outside the UK?",
|
||||
"a6": "Yes, we work with international clients. Our primary focus is UK businesses, but we're happy to support companies anywhere that need professional automation consulting."
|
||||
},
|
||||
"cta": {
|
||||
"title": "Get Your Quote Today",
|
||||
"subtitle": "Tell us about your project and we'll send you a detailed, fixed-price proposal within 24 hours."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"loading": "Loading posts...",
|
||||
"noPosts": "No posts yet.",
|
||||
"readMore": "Read More →"
|
||||
},
|
||||
"blogPost": {
|
||||
"back": "← Back to Blog",
|
||||
"notFound": "Post not found",
|
||||
"loading": "Loading...",
|
||||
"source": "Source:"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
"title": "AImpress | AI & Automation Consulting for SMEs | London, UK",
|
||||
"description": "AImpress helps small and medium businesses in the UK automate operations, cut costs, and grow faster with AI-powered solutions. Based in London."
|
||||
},
|
||||
"about": {
|
||||
"title": "About Us | AImpress — AI & Automation Consulting, London",
|
||||
"description": "AImpress Ltd is a London-based automation consultancy for SMEs, charities, and public sector. Founded 2024. Client-owned infrastructure, no vendor lock-in."
|
||||
},
|
||||
"services": {
|
||||
"title": "AI & Automation Services for UK Businesses | AImpress",
|
||||
"description": "Workflow automation, system integration, CRM optimisation, marketing automation, AI integration, and infrastructure setup. Fixed-price projects from £1,500."
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing | AImpress — Transparent Automation Costs from £1,500",
|
||||
"description": "Fixed-price automation projects from £1,500. Support retainers from £1,000/month. Up to 50% discount for charities, startups, and public sector."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog | AImpress — AI Automation Insights",
|
||||
"description": "Insights, guides, and case studies on AI automation for small and medium businesses. Stay ahead with AImpress."
|
||||
},
|
||||
"siteName": "AImpress"
|
||||
}
|
||||
}
|
||||
771
content/translations/uk.json
Normal file
771
content/translations/uk.json
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
{
|
||||
"header": {
|
||||
"nav": {
|
||||
"home": "Головна",
|
||||
"about": "Про нас",
|
||||
"services": "Послуги",
|
||||
"pricing": "Ціни",
|
||||
"blog": "Блог",
|
||||
"contacts": "Контакти"
|
||||
},
|
||||
"lang": {
|
||||
"en": "Eng",
|
||||
"uk": "Укр"
|
||||
},
|
||||
"login": "Увійти",
|
||||
"loginModal": {
|
||||
"title": "З поверненням",
|
||||
"emailLabel": "Email / Логін",
|
||||
"emailPlaceholder": "Введіть ваш email",
|
||||
"passwordLabel": "Пароль",
|
||||
"passwordPlaceholder": "Введіть ваш пароль",
|
||||
"submit": "Увійти",
|
||||
"signupPrompt": "Немає акаунту?",
|
||||
"signupLink": "Зареєструватися"
|
||||
}
|
||||
},
|
||||
"hero": {
|
||||
"circle1": "Будуй.",
|
||||
"circle2": "Автоматизуй.",
|
||||
"circle3": "Вражай.",
|
||||
"title": "Припиніть наймати. Починайте масштабувати. Розгорніть свою AI-команду.",
|
||||
"cta": "Отримати безкоштовну консультацію"
|
||||
},
|
||||
"benefits": {
|
||||
"card1": {
|
||||
"front": "Вартість і масштаб",
|
||||
"subtitle": "Еластичне зростання, нуль штату",
|
||||
"back": "Масштабуйтесь у 10 разів без найму. AI-асистенти автоматично обробляють пікові навантаження, зберігаючи гнучкість та ефективність ваших операцій."
|
||||
},
|
||||
"card2": {
|
||||
"front": "Точність",
|
||||
"subtitle": "Точність машинного навчання (99,9%)",
|
||||
"back": "Усуньте людський фактор. Наші ML-валідовані процеси гарантують цілісність даних у CRM, фінансах та логістиці."
|
||||
},
|
||||
"card3": {
|
||||
"front": "Доступність",
|
||||
"subtitle": "Справжня робота 24/7/365",
|
||||
"back": "Ваша AI-команда ніколи не спить, не бере перерв і не вигоряє. Обслуговуйте клієнтів по всьому світу безперервно."
|
||||
},
|
||||
"builtTitle": "Створено для місцевих підприємців, таких як ви",
|
||||
"builtDesc": "AImpress перетворює хаос на систему. Ми створюємо автоматизації, які економлять до 75% вашого часу та збільшують продажі.",
|
||||
"static1": {
|
||||
"title": "Чат-боти та AI-асистенти",
|
||||
"desc": "→ миттєві відповіді 24/7"
|
||||
},
|
||||
"static2": {
|
||||
"title": "Автоматизація бізнес-процесів (n8n, Make.com)",
|
||||
"desc": "→ CRM, email, фінанси, складський облік"
|
||||
},
|
||||
"static3": {
|
||||
"title": "Контент-ферми та AI-копірайтинг",
|
||||
"desc": "→ AI створює пости, статті, описи товарів"
|
||||
},
|
||||
"static4": {
|
||||
"title": "Маркетингова автоматизація (email, CRM, реклама)",
|
||||
"desc": "→ кампанії, прогрів лідів, реклама на автопілоті"
|
||||
}
|
||||
},
|
||||
"banner1": {
|
||||
"q1": "Скільки працівників у вашій компанії?",
|
||||
"q2": "Скільки годин на день витрачається на рутинні завдання?",
|
||||
"q3": "Які процеси ви хочете автоматизувати?",
|
||||
"cta": "Отримати безкоштовну консультацію"
|
||||
},
|
||||
"realResults": {
|
||||
"title": "Реальні результати реальних клієнтів",
|
||||
"card1": {
|
||||
"title": "AutoBrat Garage",
|
||||
"resultsLabel": "Результати:",
|
||||
"desc": "Спеціалізована майстерня, заснована українцями, мала проблеми з місцевою впізнаваністю. AImpress створив двомовний контент, що демонструє їхню експертизу з німецьких автомобілів.",
|
||||
"stat1": "157% — зростання бронювань",
|
||||
"stat2": "85% — завантаженість сервісних боксів",
|
||||
"stat3": "згадка в Oxford Mail протягом 6 місяців"
|
||||
},
|
||||
"card2": {
|
||||
"title": "Cotswolld Honey Company",
|
||||
"desc": "Виробник крафтового меду, обмежений сезонними ринками. AImpress розробив e-commerce та освітній контент, що підкреслює сталі практики.",
|
||||
"stat1": "78% — зростання онлайн-продажів",
|
||||
"stat2": "чотири нових роздрібних партнерства всього за 4 місяці"
|
||||
},
|
||||
"card3": {
|
||||
"title": "Wcounting Accounting Partners",
|
||||
"desc": "Традиційна фірма, що втрачала молодих клієнтів. AImpress створив зрозумілі фінансові гіди та контент щодо Making Tax Digital.",
|
||||
"stat1": "41% — зростання запитів від клієнтів до 40 років",
|
||||
"stat2": "12 — нових e-commerce клієнтів за 5 місяців"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Ваш шлях до автономних операцій",
|
||||
"step1": {
|
||||
"title": "Брифінг завдання",
|
||||
"duration": "2 год",
|
||||
"short": "Визначте найвпливовіші можливості та метрики ROI.",
|
||||
"detail": "Ми аудируємо ваші процеси, знаходимо вузькі місця та визначаємо пріоритетні цілі автоматизації з найвищим ROI — все за одну фокусну сесію."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Технічна оцінка та стратегія",
|
||||
"duration": "2–3 дні",
|
||||
"short": "Огляд існуючого стеку, архітектури та відповідності.",
|
||||
"detail": "Глибокий аналіз вашої CRM, бухгалтерії, комунікацій та потоків даних. Ми проєктуємо план інтеграції та модель безпеки ще до написання першого рядка коду."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Proof of Concept",
|
||||
"duration": "8–12 тижнів",
|
||||
"short": "Пілот для критичного процесу — видимий ROI.",
|
||||
"detail": "Ми створюємо та впроваджуємо робочу автоматизацію для вашого найболючішого процесу. Ви побачите вимірну економію часу та коштів вже у першому спринті."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Впровадження MVP",
|
||||
"duration": "2–3 місяці",
|
||||
"short": "Повне розгортання, навчання команди та інтеграція.",
|
||||
"detail": "Впровадження у всіх відділах з практичним навчанням, моніторинговими дашбордами та виділеним Slack-каналом для миттєвої підтримки."
|
||||
},
|
||||
"step5": {
|
||||
"title": "Масштабування та оптимізація",
|
||||
"duration": "Постійно",
|
||||
"short": "Безперервне вдосконалення та розширення.",
|
||||
"detail": "Щомісячні огляди продуктивності, A/B-тестування автоматизацій та розширення на нові відділи. Ваша AI-команда зростає разом з вами."
|
||||
}
|
||||
},
|
||||
"banner2": {
|
||||
"cta": "Отримати безкоштовну консультацію"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Чому бізнеси переходять на AImpress",
|
||||
"aiLabel": "AI-Powered",
|
||||
"metric1": {
|
||||
"label": "Вартість",
|
||||
"ai": "Від £1 500",
|
||||
"agency": "£5 000+ /міс агенція",
|
||||
"inhouse": "£35 000+ /рік штатний спеціаліст"
|
||||
},
|
||||
"metric2": {
|
||||
"label": "Швидкість",
|
||||
"ai": "Миттєво",
|
||||
"agency": "Залежить від менеджера",
|
||||
"inhouse": "Тільки з 9 до 18"
|
||||
},
|
||||
"metric3": {
|
||||
"label": "Доступність",
|
||||
"ai": "24 / 7 / 365",
|
||||
"agency": "Робочі години",
|
||||
"inhouse": "Лікарняні та відпустки"
|
||||
},
|
||||
"metric4": {
|
||||
"label": "Масштабованість",
|
||||
"ai": "Необмежена",
|
||||
"agency": "Обмежена штатом",
|
||||
"inhouse": "Складно масштабувати"
|
||||
},
|
||||
"altHeading": "Альтернативи",
|
||||
"alt1": "Традиційна агенція",
|
||||
"alt2": "Штатний працівник",
|
||||
"footer": "Перейдіть з застарілих методів — заощаджуйте до <strong>70%</strong> на витратах та <strong>30+ годин</strong> на тиждень.",
|
||||
"cta": "Отримати безкоштовну консультацію"
|
||||
},
|
||||
"blogSection": {
|
||||
"title": "Останні оновлення",
|
||||
"readMore": "Читати далі →",
|
||||
"viewAll": "Усі публікації →"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Безкоштовні ресурси для масштабування вашого бізнесу"
|
||||
},
|
||||
"contactSection": {
|
||||
"title": "Готові до автоматизації?",
|
||||
"subtitle": "Припиніть витрачати час на рутину. Починайте масштабуватись з AI вже сьогодні."
|
||||
},
|
||||
"contactForm": {
|
||||
"title": "Зв'яжіться з нами:",
|
||||
"fullName": "Повне ім'я",
|
||||
"fullNamePlaceholder": "Іван Петренко",
|
||||
"jobTitle": "Посада / Роль",
|
||||
"jobTitlePlaceholder": "Менеджер проєктів",
|
||||
"email": "Робочий email",
|
||||
"emailPlaceholder": "ivan@company.com",
|
||||
"need": "Потреба в автоматизації",
|
||||
"needPlaceholder": "Оптимізація процесів",
|
||||
"company": "Назва компанії",
|
||||
"companyPlaceholder": "Тех Рішення",
|
||||
"phone": "Номер телефону",
|
||||
"phonePlaceholder": "+44...",
|
||||
"submit": "Надіслати запит",
|
||||
"sending": "Надсилання...",
|
||||
"error": "Щось пішло не так. Будь ласка, спробуйте ще раз.",
|
||||
"successTitle": "Дякуємо!",
|
||||
"successText": "Ми отримали ваш запит і зв'яжемося з вами найближчим часом.",
|
||||
"sendAnother": "Надіслати ще"
|
||||
},
|
||||
"footer": {
|
||||
"privacy": "Політика конфіденційності",
|
||||
"terms": "Умови використання",
|
||||
"copyright": "© 2026 AImpress LTD. Усі права захищені."
|
||||
},
|
||||
"cookie": {
|
||||
"text": "Ми використовуємо файли cookie та подібні технології для аналізу трафіку на сайті та покращення вашого досвіду. Натискаючи «Прийняти», ви погоджуєтесь на використання аналітичних cookie.",
|
||||
"privacyLink": "Політика конфіденційності",
|
||||
"reject": "Відхилити",
|
||||
"accept": "Прийняти"
|
||||
},
|
||||
"chat": {
|
||||
"greeting": "Привіт! Чим я можу вам допомогти?",
|
||||
"openChat": "Відкрити чат",
|
||||
"headerTitle": "AImpress",
|
||||
"status": "Онлайн",
|
||||
"clearChat": "Очистити чат",
|
||||
"closeChat": "Закрити чат",
|
||||
"welcome": "Привіт! Я AI-асистент AImpress. Запитайте мене про наші послуги з AI та автоматизації, ціни або запишіться на безкоштовну консультацію.",
|
||||
"lead": {
|
||||
"title": "AImpress",
|
||||
"subtitle": "Консалтинг з AI та автоматизації",
|
||||
"intro": "Привіт! Перед початком, будь ласка, представтеся, щоб ми могли краще вам допомогти.",
|
||||
"namePlaceholder": "Ваше ім'я *",
|
||||
"nameError": "Будь ласка, введіть ваше ім'я",
|
||||
"emailPlaceholder": "Email *",
|
||||
"emailError": "Будь ласка, введіть ваш email",
|
||||
"emailInvalid": "Будь ласка, введіть дійсний email",
|
||||
"companyPlaceholder": "Компанія (необов'язково)",
|
||||
"consent": "Я погоджуюсь на обробку моїх персональних даних відповідно до",
|
||||
"privacyLink": "Політики конфіденційності",
|
||||
"consentError": "Будь ласка, надайте згоду для продовження",
|
||||
"submit": "Розпочати чат"
|
||||
},
|
||||
"inputPlaceholder": "Введіть повідомлення...",
|
||||
"send": "Надіслати повідомлення"
|
||||
},
|
||||
"quoteForm": {
|
||||
"title": "Отримати пропозицію",
|
||||
"fullName": "Повне ім'я",
|
||||
"fullNamePlaceholder": "Іван Петренко",
|
||||
"jobTitle": "Посада / Роль",
|
||||
"jobTitlePlaceholder": "Менеджер проєктів",
|
||||
"email": "Робочий email",
|
||||
"emailPlaceholder": "ivan@company.com",
|
||||
"phone": "Номер телефону",
|
||||
"phonePlaceholder": "+44...",
|
||||
"company": "Назва компанії",
|
||||
"companyPlaceholder": "Тех Рішення",
|
||||
"service": "Послуга",
|
||||
"serviceDefault": "Оберіть послугу...",
|
||||
"service1": "Впровадження автоматизації процесів",
|
||||
"service2": "Системна інтеграція та синхронізація",
|
||||
"service3": "Оптимізація CRM-процесів",
|
||||
"service4": "Налаштування маркетингової автоматизації",
|
||||
"service5": "Інтеграція та впровадження AI",
|
||||
"service6": "Налаштування інфраструктури",
|
||||
"service7": "Ретейнер підтримки",
|
||||
"service8": "Навчання та воркшопи",
|
||||
"service9": "Інше / Ще не визначився",
|
||||
"description": "Опис проєкту",
|
||||
"descriptionPlaceholder": "Опишіть ваші потреби в автоматизації, поточні виклики та бажані результати...",
|
||||
"submit": "Отримати пропозицію",
|
||||
"sending": "Надсилання...",
|
||||
"error": "Щось пішло не так. Будь ласка, спробуйте ще раз.",
|
||||
"successTitle": "Дякуємо!",
|
||||
"successText": "Ми отримали ваш запит на пропозицію. Ми розглянемо ваші вимоги та надішлемо детальну пропозицію протягом 24 годин.",
|
||||
"sendAnother": "Надіслати ще один запит"
|
||||
},
|
||||
"about": {
|
||||
"hero": {
|
||||
"title": "Про AImpress",
|
||||
"subtitle": "Робимо професійну автоматизацію доступною для організацій, що створюють зміни."
|
||||
},
|
||||
"story": {
|
||||
"title": "Наша історія",
|
||||
"p1": "Автоматизація — це більше не опція, а різниця між зростанням та відставанням. Проте для більшості МСП варіанти були обмежені: офшорні команди — дешево, але ненадійно, або британські агенції, що беруть корпоративні ціни за базову роботу.",
|
||||
"p2": "AImpress створено, щоб заповнити цю прогалину. Ми — лондонська консалтингова компанія, що приносить автоматизацію корпоративного рівня малому та середньому бізнесу, благодійним організаціям та державному сектору — за цінами, які дійсно мають сенс.",
|
||||
"p3": "Ми не віримо в «чорні скриньки» чи прив'язку до постачальника. Кожна система, яку ми створюємо, працює на вашій власній інфраструктурі з документацією, яку ваша команда може використовувати. Коли ми завершуємо — ви зберігаєте все, і ви знаєте, як це працює."
|
||||
},
|
||||
"diff": {
|
||||
"title": "Що нас відрізняє"
|
||||
},
|
||||
"diff1": {
|
||||
"title": "Базування у Великобританії, лондонські стандарти",
|
||||
"desc": "Конкурентні ставки £90–120/год. Британські робочі години, GDPR-відповідність за замовчуванням, повна відповідальність за законодавством Англії."
|
||||
},
|
||||
"diff2": {
|
||||
"title": "Спеціалізація на МСП",
|
||||
"desc": "Ми фокусуємось на CRM, маркетингу, фінансах та e-commerce автоматизації — процесах, що найбільше важливі для бізнесу, що зростає."
|
||||
},
|
||||
"diff3": {
|
||||
"title": "Інфраструктура, що належить клієнту",
|
||||
"desc": "Ваші сервери, ваші дані, ваші акаунти. Ми будуємо на інфраструктурі, якою ви володієте — жодної прив'язки до постачальника."
|
||||
},
|
||||
"diff4": {
|
||||
"title": "Передача знань, а не залежність",
|
||||
"desc": "Кожен проєкт включає повну документацію та навчання команди. Ми вчимо ваших людей підтримувати те, що ми створили."
|
||||
},
|
||||
"values": {
|
||||
"title": "Наші цінності"
|
||||
},
|
||||
"val1": {
|
||||
"name": "Прозорість",
|
||||
"desc": "Фіксовані ціни, чіткий обсяг, жодних прихованих платежів"
|
||||
},
|
||||
"val2": {
|
||||
"name": "Власність клієнта",
|
||||
"desc": "Ви володієте всім, що ми створюємо — код, дані, інфраструктура"
|
||||
},
|
||||
"val3": {
|
||||
"name": "Досконалість",
|
||||
"desc": "Якість корпоративного рівня за цінами, доступними для МСП"
|
||||
},
|
||||
"val4": {
|
||||
"name": "Вплив, а не прибуток",
|
||||
"desc": "Знижені тарифи для благодійних організацій, стартапів та державного сектору"
|
||||
},
|
||||
"val5": {
|
||||
"name": "Прагматизм",
|
||||
"desc": "Ми рекомендуємо те, що працює, а не те, що коштує більше"
|
||||
},
|
||||
"founder": {
|
||||
"title": "Засновник",
|
||||
"name": "Вадим Самойленко",
|
||||
"role": "CEO та засновник AImpress Ltd",
|
||||
"bgLabel": "Досвід:",
|
||||
"bgText": "2,5+ роки в OLIVER Agency (WPP) як глобальний спеціаліст з автоматизації та AI, розробка AI-процесів, що зменшили ручну роботу на 30–50% у креативних операціях для глобальних брендів",
|
||||
"certLabel": "Сертифікації AI та автоматизації:",
|
||||
"certText": "Prompt Engineering Specialization (Vanderbilt University), Generative AI for Marketing (Microsoft Copilot), Prompt Design in Vertex AI (Google), AI in Business (LinkedIn), Make Basics",
|
||||
"analyticsLabel": "Сертифікації з аналітики:",
|
||||
"analyticsText": "Microsoft Power BI Data Analyst, Laba Business Analytics & Marketing Analytics",
|
||||
"eduLabel": "Освіта:",
|
||||
"eduText": "Магістр економічної кібернетики — системне моделювання, аналіз даних, оптимізація процесів",
|
||||
"visionLabel": "Бачення:",
|
||||
"visionText": "Заснував AImpress, щоб зробити автоматизацію корпоративного рівня доступною для МСП"
|
||||
},
|
||||
"industries": {
|
||||
"title": "Галузі, які ми обслуговуємо"
|
||||
},
|
||||
"ind1": {
|
||||
"name": "E-commerce",
|
||||
"desc": "Обробка замовлень, синхронізація складу, клієнтські шляхи"
|
||||
},
|
||||
"ind2": {
|
||||
"name": "Професійні послуги",
|
||||
"desc": "CRM, виставлення рахунків, автоматизація онбордингу клієнтів"
|
||||
},
|
||||
"ind3": {
|
||||
"name": "SaaS",
|
||||
"desc": "Онбординг користувачів, білінг, процеси обробки тікетів"
|
||||
},
|
||||
"ind4": {
|
||||
"name": "Благодійні організації",
|
||||
"desc": "Управління донорами, грантова звітність, координація волонтерів"
|
||||
},
|
||||
"ind5": {
|
||||
"name": "Освіта",
|
||||
"desc": "Зарахування, комунікація зі студентами, доставка контенту"
|
||||
},
|
||||
"ind6": {
|
||||
"name": "Охорона здоров'я",
|
||||
"desc": "Планування прийомів, медичні записи, відповідність нормам"
|
||||
},
|
||||
"ind7": {
|
||||
"name": "Державний сектор",
|
||||
"desc": "Управління справами, звітність, послуги для громадян"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Готові трансформувати свої операції?",
|
||||
"subtitle": "Замовте безкоштовний дзвінок та дізнайтеся, як автоматизація може працювати для вашого бізнесу.",
|
||||
"button": "Замовити безкоштовну консультацію"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"hero": {
|
||||
"title": "Наші послуги",
|
||||
"subtitle": "Комплексний консалтинг з автоматизації — від аналізу до впровадження та подальшої підтримки."
|
||||
},
|
||||
"s1": {
|
||||
"title": "AI чат-боти та віртуальні асистенти",
|
||||
"price": "£3 000 – £10 000",
|
||||
"purpose": "Розгорніть інтелектуальних чат-ботів, що обслуговують клієнтів, кваліфікують ліди та записують на зустрічі 24/7 — без участі людини.",
|
||||
"f1": "Розробка чат-бота та персоналізація",
|
||||
"f2": "Інтеграція з вашим сайтом, WhatsApp або Messenger",
|
||||
"f3": "Навчання бази знань на даних вашого бізнесу",
|
||||
"f4": "Кваліфікація лідів та передача в CRM",
|
||||
"f5": "Мультимовна підтримка",
|
||||
"f6": "Аналітична панель та інсайти з розмов"
|
||||
},
|
||||
"s2": {
|
||||
"title": "Розробка веб-сайтів",
|
||||
"price": "£2 500 – £15 000",
|
||||
"purpose": "Високопродуктивні, конверсійно-оптимізовані сайти на сучасних технологіях — швидкі, SEO-готові та повністю ваші.",
|
||||
"f1": "Індивідуальний дизайн та адаптивна розробка",
|
||||
"f2": "Розробка на React / Next.js або WordPress",
|
||||
"f3": "SEO-оптимізація та Core Web Vitals",
|
||||
"f4": "Інтеграція CMS для зручного оновлення контенту",
|
||||
"f5": "Контактні форми, аналітика та налаштування трекінгу",
|
||||
"f6": "Налаштування хостингу на вашій інфраструктурі"
|
||||
},
|
||||
"s3": {
|
||||
"title": "Впровадження автоматизації процесів",
|
||||
"price": "£3 500 – £12 000",
|
||||
"purpose": "Автоматизуйте повторювані бізнес-процеси від початку до кінця, щоб ваша команда фокусувалась на високоцінній роботі.",
|
||||
"f1": "Аналіз та картування процесів",
|
||||
"f2": "Розробка та створення процесів (n8n / Make.com)",
|
||||
"f3": "Багатокрокова логіка з умовним розгалуженням",
|
||||
"f4": "Обробка помилок та механізми повторних спроб",
|
||||
"f5": "Тестування, розгортання та документація"
|
||||
},
|
||||
"s4": {
|
||||
"title": "Системна інтеграція та синхронізація",
|
||||
"price": "£2 500 – £10 000+",
|
||||
"purpose": "Об'єднайте свої інструменти в єдине джерело правди — CRM, бухгалтерія, e-commerce, комунікації тощо.",
|
||||
"f1": "API-інтеграція між платформами",
|
||||
"f2": "Двонаправлена синхронізація даних",
|
||||
"f3": "Маппінг та трансформація даних",
|
||||
"f4": "Webhook та тригери на основі подій",
|
||||
"f5": "Налаштування моніторингу та сповіщень"
|
||||
},
|
||||
"s5": {
|
||||
"title": "Оптимізація CRM-процесів",
|
||||
"price": "£3 000 – £6 500",
|
||||
"purpose": "Оптимізуйте воронку продажів, автоматизуйте follow-up та переконайтеся, що жоден лід не загубиться.",
|
||||
"f1": "Аудит CRM та реструктуризація воронки",
|
||||
"f2": "Автоматичний скоринг та маршрутизація лідів",
|
||||
"f3": "Автоматизація email-послідовностей",
|
||||
"f4": "Процеси завдань та нагадувань",
|
||||
"f5": "Налаштування звітних панелей"
|
||||
},
|
||||
"s6": {
|
||||
"title": "Налаштування маркетингової автоматизації",
|
||||
"price": "£3 500 – £8 000",
|
||||
"purpose": "Переведіть кампанії, прогрів лідів та рекламу на автопілот з цілеспрямованою, дата-орієнтованою автоматизацією.",
|
||||
"f1": "Налаштування email drip-кампаній",
|
||||
"f2": "Автоматизація збору лідів та форм",
|
||||
"f3": "Логіка сегментації аудиторії",
|
||||
"f4": "Інтеграція з плануванням соціальних мереж",
|
||||
"f5": "Аналітика та відстеження конверсій"
|
||||
},
|
||||
"s7": {
|
||||
"title": "Інтеграція та впровадження AI",
|
||||
"price": "£4 000 – £12 000",
|
||||
"purpose": "Додайте AI-можливості до існуючих процесів — чат-боти, генерація контенту, обробка документів тощо.",
|
||||
"f1": "Вибір та інтеграція AI-моделей (OpenAI, Claude тощо)",
|
||||
"f2": "Розробка чат-бота / віртуального асистента",
|
||||
"f3": "Витяг даних з документів за допомогою AI",
|
||||
"f4": "Конвеєри AI-генерації контенту",
|
||||
"f5": "Prompt engineering та fine-tuning"
|
||||
},
|
||||
"s8": {
|
||||
"title": "Налаштування інфраструктури",
|
||||
"price": "£1 500 – £4 000",
|
||||
"purpose": "Налаштуйте інфраструктуру автоматизації на серверах, якими ви володієте — безпечно, масштабовано та повністю ваше.",
|
||||
"f1": "Розгортання серверів (VPS / хмара)",
|
||||
"f2": "Розгортання n8n / платформи автоматизації",
|
||||
"f3": "SSL, файервол та посилення безпеки",
|
||||
"f4": "Налаштування резервного копіювання та відновлення",
|
||||
"f5": "Моніторинг та сповіщення про доступність"
|
||||
},
|
||||
"popular": "Популярне",
|
||||
"whatsIncluded": "Що включено",
|
||||
"showLess": "Показати менше",
|
||||
"assurance": {
|
||||
"title": "Пакет гарантій AImpress",
|
||||
"subtitle": "Безкоштовно включено в кожен проєкт (вартість £1 500)",
|
||||
"i1": "Виділений менеджер проєкту",
|
||||
"i2": "Щотижневі звіти про прогрес",
|
||||
"i3": "Повна технічна документація",
|
||||
"i4": "Навчання кінцевих користувачів",
|
||||
"i5": "Навчання адміністраторів",
|
||||
"i6": "30-денна підтримка після запуску",
|
||||
"i7": "Виправлення помилок в рамках SLA",
|
||||
"i8": "База знань та runbooks",
|
||||
"i9": "План передачі та переходу"
|
||||
},
|
||||
"metrics": {
|
||||
"title": "Результати в цифрах",
|
||||
"v1": "30–50%",
|
||||
"l1": "Зменшення ручної роботи",
|
||||
"v2": "2–8 тижнів",
|
||||
"l2": "Середній час реалізації",
|
||||
"v3": "24/7",
|
||||
"l3": "Автоматизований аптайм",
|
||||
"v4": "£0",
|
||||
"l4": "Плата за прив'язку до постачальника"
|
||||
},
|
||||
"selector": {
|
||||
"title": "Яка послуга вам потрібна?",
|
||||
"step1": "Ваша мета",
|
||||
"step2": "Бюджет",
|
||||
"step3": "Результати",
|
||||
"goalQ": "Яка ваша основна мета?",
|
||||
"goal1": "Автоматизувати процеси",
|
||||
"goal2": "Отримати більше лідів",
|
||||
"goal3": "Побудувати онлайн-присутність",
|
||||
"goal4": "З'єднати мої інструменти",
|
||||
"goal5": "Додати AI-можливості",
|
||||
"budgetQ": "Який ваш діапазон бюджету?",
|
||||
"budget1": "До £5K",
|
||||
"budget2": "£5K – £10K",
|
||||
"budget3": "£10K+",
|
||||
"resultsHeading": "Ми рекомендуємо ці послуги:",
|
||||
"noMatch": "Точного збігу немає — але ми можемо допомогти! Зв'яжіться з нами для індивідуального рішення.",
|
||||
"viewButton": "Переглянути послуги",
|
||||
"resetButton": "Почати спочатку",
|
||||
"backButton": "← Назад"
|
||||
},
|
||||
"bundles": {
|
||||
"title": "Популярні пакети"
|
||||
},
|
||||
"bundle1": {
|
||||
"name": "Starter",
|
||||
"tagline": "Запуск та залучення",
|
||||
"price": "від £5 500"
|
||||
},
|
||||
"bundle2": {
|
||||
"name": "Growth",
|
||||
"tagline": "Масштабування та конверсія",
|
||||
"price": "від £9 000",
|
||||
"badge": "Найпопулярніший"
|
||||
},
|
||||
"bundle3": {
|
||||
"name": "Full Stack",
|
||||
"tagline": "Трансформація всього",
|
||||
"price": "Індивідуальна ціна"
|
||||
},
|
||||
"bundle": {
|
||||
"cta": "Розпочати"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Не знаєте, з чого почати?",
|
||||
"subtitle": "Замовте безкоштовну консультацію, і ми розробимо найкращу стратегію автоматизації для вашого бізнесу.",
|
||||
"button": "Отримати безкоштовну консультацію"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"hero": {
|
||||
"title": "Прості, прозорі ціни",
|
||||
"subtitle": "Проєкти з фіксованою ціною. Без несподіваних витрат. Без прив'язки до постачальника."
|
||||
},
|
||||
"impl": {
|
||||
"title": "Ціни на впровадження",
|
||||
"s1": {
|
||||
"_": "AI чат-боти та віртуальні асистенти",
|
||||
"price": "£3 000 – £10 000"
|
||||
},
|
||||
"s2": {
|
||||
"_": "Розробка веб-сайтів",
|
||||
"price": "£2 500 – £15 000"
|
||||
},
|
||||
"s3": {
|
||||
"_": "Впровадження автоматизації процесів",
|
||||
"price": "£3 500 – £12 000"
|
||||
},
|
||||
"s4": {
|
||||
"_": "Системна інтеграція та синхронізація",
|
||||
"price": "£2 500 – £10 000+"
|
||||
},
|
||||
"s5": {
|
||||
"_": "Оптимізація CRM-процесів",
|
||||
"price": "£3 000 – £6 500"
|
||||
},
|
||||
"s6": {
|
||||
"_": "Налаштування маркетингової автоматизації",
|
||||
"price": "£3 500 – £8 000"
|
||||
},
|
||||
"s7": {
|
||||
"_": "Інтеграція та впровадження AI",
|
||||
"price": "£4 000 – £12 000"
|
||||
},
|
||||
"s8": {
|
||||
"_": "Налаштування інфраструктури",
|
||||
"price": "£1 500 – £4 000"
|
||||
}
|
||||
},
|
||||
"popular": "Популярне",
|
||||
"retainers": {
|
||||
"title": "Ретейнери підтримки"
|
||||
},
|
||||
"ret1": {
|
||||
"name": "Essential",
|
||||
"price": "£1 000",
|
||||
"period": "/місяць",
|
||||
"hours": "10 годин",
|
||||
"sla": "Відповідь протягом 48 год",
|
||||
"f1": "10 годин підтримки",
|
||||
"f2": "Виправлення помилок та незначні оновлення",
|
||||
"f3": "SLA відповіді 48 годин",
|
||||
"f4": "Підтримка через email",
|
||||
"f5": "Щомісячна перевірка стану"
|
||||
},
|
||||
"ret2": {
|
||||
"name": "Professional",
|
||||
"price": "£2 000",
|
||||
"hours": "22 години",
|
||||
"sla": "Відповідь протягом 24 год",
|
||||
"f1": "22 години підтримки",
|
||||
"f2": "Виправлення помилок, оновлення та нові функції",
|
||||
"f3": "SLA відповіді 24 години",
|
||||
"f4": "Підтримка через email та Slack",
|
||||
"f5": "Стратегічний дзвінок раз на два тижні",
|
||||
"f6": "Пріоритетне планування",
|
||||
"badge": "Найпопулярніший"
|
||||
},
|
||||
"ret3": {
|
||||
"name": "Enterprise",
|
||||
"price": "£3 500",
|
||||
"hours": "40 годин",
|
||||
"sla": "Відповідь протягом 4 год",
|
||||
"f1": "40 годин підтримки",
|
||||
"f2": "Повний спектр розробки та підтримки",
|
||||
"f3": "SLA відповіді 4 години",
|
||||
"f4": "Виділений Slack-канал",
|
||||
"f5": "Щотижневий стратегічний дзвінок",
|
||||
"f6": "Пріоритетне планування",
|
||||
"f7": "Квартальний огляд дорожньої карти"
|
||||
},
|
||||
"training": {
|
||||
"title": "Навчання та воркшопи",
|
||||
"t1": {
|
||||
"_": "Навчання кінцевих користувачів",
|
||||
"price": "£250",
|
||||
"desc": "За сесію"
|
||||
},
|
||||
"t2": {
|
||||
"_": "Навчання адміністраторів",
|
||||
"price": "£600 – £1 000"
|
||||
},
|
||||
"t3": {
|
||||
"_": "Програма сертифікації",
|
||||
"price": "£1 800",
|
||||
"desc": "Повний курс"
|
||||
},
|
||||
"t4": {
|
||||
"_": "Індивідуальний воркшоп",
|
||||
"price": "£400 – £1 200",
|
||||
"desc": "Від пів дня до повного дня"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Умови оплати",
|
||||
"r1": {
|
||||
"_": "До £5 000",
|
||||
"split": "100% передоплата"
|
||||
},
|
||||
"r2": {
|
||||
"_": "£5 000 – £10 000",
|
||||
"split": "50% / 50%"
|
||||
},
|
||||
"r3": {
|
||||
"_": "Понад £10 000",
|
||||
"split": "33% / 33% / 34%"
|
||||
},
|
||||
"r4": {
|
||||
"_": "Державний сектор",
|
||||
"split": "Оплата протягом 30 днів"
|
||||
}
|
||||
},
|
||||
"discounts": {
|
||||
"title": "Програма грантів впливу",
|
||||
"intro": "Ми переконані, що автоматизація повинна бути доступною для організацій, що роблять різницю. Відповідні групи отримують значні знижки.",
|
||||
"g1": {
|
||||
"_": "Благодійні та некомерційні організації",
|
||||
"disc": "До 50%"
|
||||
},
|
||||
"g2": "Стартапи (менше 2 років)",
|
||||
"g3": "Освіта",
|
||||
"g4": {
|
||||
"_": "Державний сектор",
|
||||
"disc": "25% + безкоштовний пілотний проєкт"
|
||||
},
|
||||
"g5": "Українські бізнеси"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Як ми порівнюємося",
|
||||
"aimpress": "AImpress",
|
||||
"agency": "Агенція",
|
||||
"inhouse": "Штатний спеціаліст",
|
||||
"r1": {
|
||||
"metric": "Вартість запуску",
|
||||
"ai": "Від £1 500",
|
||||
"agency": "£5 000+ /міс",
|
||||
"inhouse": "£35 000+ /рік"
|
||||
},
|
||||
"r2": {
|
||||
"metric": "Час розгортання",
|
||||
"ai": "2–8 тижнів",
|
||||
"agency": "3–6 місяців",
|
||||
"inhouse": "6–12 місяців"
|
||||
},
|
||||
"r3": {
|
||||
"metric": "Доступність",
|
||||
"ai": "24/7 автоматизовано",
|
||||
"agency": "Робочі години",
|
||||
"inhouse": "Тільки з 9 до 18"
|
||||
},
|
||||
"r4": {
|
||||
"metric": "Масштабованість",
|
||||
"ai": "Необмежена",
|
||||
"agency": "Обмежена штатом",
|
||||
"inhouse": "Складно масштабувати"
|
||||
},
|
||||
"r5": {
|
||||
"metric": "Ви це володієте",
|
||||
"ai": "Так, завжди",
|
||||
"agency": "Рідко"
|
||||
},
|
||||
"r6": {
|
||||
"metric": "Приховані витрати",
|
||||
"ai": "Немає",
|
||||
"agency": "Запити на зміни",
|
||||
"inhouse": "Соцпакет, плинність кадрів"
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"title": "Часті запитання",
|
||||
"q1": "Ви британська компанія?",
|
||||
"a1": "Так. AImpress Ltd зареєстрована в Англії та Уельсі (реєстраційний номер 16417799), зареєстрована як платник ПДВ та в ICO. Ми працюємо за англійським законодавством з повною відповідністю GDPR.",
|
||||
"q2": "Ви пропонуєте керований хостинг?",
|
||||
"a2": "Ми налаштовуємо інфраструктуру на ваших власних серверах або хмарних акаунтах. Ви володієте всім. За потреби ми можемо забезпечити постійне управління через наші ретейнери підтримки.",
|
||||
"q3": "З якими платформами ви працюєте?",
|
||||
"a3": "Ми працюємо з n8n, Make.com, Zapier, Power Automate, HubSpot, Salesforce, Pipedrive, Mailchimp, OpenAI, Claude та багатьма іншими. Якщо ваш інструмент має API, ми можемо його інтегрувати.",
|
||||
"q4": "Чи можете ви працювати з нашими існуючими інструментами?",
|
||||
"a4": "Безумовно. Ми будуємо поверх вашого поточного стеку — ми не замінюємо все з нуля. Наша мета — з'єднати та автоматизувати те, що у вас вже є.",
|
||||
"q5": "Що якщо я не підпадаю під знижку?",
|
||||
"a5": "Наші стандартні ставки вже конкурентні — £90–120/год, що значно нижче типових ставок британських агенцій. Ми пропонуємо проєкти з фіксованою ціною, тому ви завжди знаєте загальну вартість наперед.",
|
||||
"q6": "Чи працюєте ви з клієнтами за межами Великобританії?",
|
||||
"a6": "Так, ми працюємо з міжнародними клієнтами. Наш основний фокус — британський бізнес, але ми раді допомогти компаніям будь-де, яким потрібен професійний консалтинг з автоматизації."
|
||||
},
|
||||
"cta": {
|
||||
"title": "Отримайте вашу пропозицію сьогодні",
|
||||
"subtitle": "Розкажіть про ваш проєкт, і ми надішлемо детальну пропозицію з фіксованою ціною протягом 24 годин."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"title": "Блог",
|
||||
"loading": "Завантаження публікацій...",
|
||||
"noPosts": "Публікацій поки немає.",
|
||||
"readMore": "Читати далі →"
|
||||
},
|
||||
"blogPost": {
|
||||
"back": "← Назад до блогу",
|
||||
"notFound": "Публікацію не знайдено",
|
||||
"loading": "Завантаження...",
|
||||
"source": "Джерело:"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
"title": "AImpress | Консалтинг з AI та автоматизації для МСП | Лондон, Великобританія",
|
||||
"description": "AImpress допомагає малому та середньому бізнесу у Великобританії автоматизувати операції, скоротити витрати та швидше зростати за допомогою AI-рішень. Базується в Лондоні."
|
||||
},
|
||||
"about": {
|
||||
"title": "Про нас | AImpress — Консалтинг з AI та автоматизації, Лондон",
|
||||
"description": "AImpress Ltd — лондонська консалтингова компанія з автоматизації для МСП, благодійних організацій та державного сектору. Заснована 2024. Інфраструктура клієнта, без прив'язки до постачальника."
|
||||
},
|
||||
"services": {
|
||||
"title": "Послуги AI та автоматизації для бізнесу у Великобританії | AImpress",
|
||||
"description": "Автоматизація процесів, системна інтеграція, оптимізація CRM, маркетингова автоматизація, інтеграція AI та налаштування інфраструктури. Проєкти з фіксованою ціною від £1 500."
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Ціни | AImpress — Прозорі витрати на автоматизацію від £1 500",
|
||||
"description": "Проєкти автоматизації з фіксованою ціною від £1 500. Ретейнери підтримки від £1 000/місяць. До 50% знижки для благодійних організацій, стартапів та державного сектору."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Блог | AImpress — Інсайти з AI-автоматизації",
|
||||
"description": "Інсайти, гіди та кейси з AI-автоматизації для малого та середнього бізнесу. Будьте попереду з AImpress."
|
||||
},
|
||||
"siteName": "AImpress"
|
||||
}
|
||||
}
|
||||
25
index.html
25
index.html
|
|
@ -136,6 +136,31 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data: LocalBusiness + AggregateRating -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"name": "AImpress Ltd",
|
||||
"url": "https://ai-impress.com",
|
||||
"image": "https://ai-impress.com/logo/webclip-256x256.png",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Suite 6065 Unit 3a, 34-35 Hatton Garden",
|
||||
"addressLocality": "London",
|
||||
"postalCode": "EC1N 8DX",
|
||||
"addressCountry": "GB"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5.0",
|
||||
"reviewCount": "5",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
14450
package-lock.json
generated
14450
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -4,8 +4,8 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "tinacms dev -c \"vite\"",
|
||||
"build": "tinacms build --skip-cloud-checks && node scripts/sync-blog.mjs && node scripts/copy-pages.mjs && node scripts/generate-sitemap.mjs && tsc -b && vite build && node scripts/prerender.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"sync-blog": "node scripts/sync-blog.mjs",
|
||||
|
|
@ -19,10 +19,12 @@
|
|||
"react-dom": "^19.2.0",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-intersection-observer": "^10.0.2",
|
||||
"react-router-dom": "^7.13.1"
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tinacms": "^3.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tinacms/cli": "^2.1.11",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -31,6 +33,8 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"puppeteer": "^24.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
|
|
|
|||
0
public/uploads/.gitkeep
Normal file
0
public/uploads/.gitkeep
Normal file
22
scripts/copy-pages.mjs
Normal file
22
scripts/copy-pages.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { readdirSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
const src = resolve(root, 'content/pages');
|
||||
const dest = resolve(root, 'public/pages');
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.log('No content/pages directory, skipping.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
|
||||
const files = readdirSync(src).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
copyFileSync(resolve(src, file), resolve(dest, file));
|
||||
console.log(`Copied: ${file}`);
|
||||
}
|
||||
console.log(`Pages copy complete: ${files.length} file(s).`);
|
||||
50
scripts/generate-sitemap.mjs
Normal file
50
scripts/generate-sitemap.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const staticRoutes = [
|
||||
{ url: '/', priority: '1.0', changefreq: 'weekly' },
|
||||
{ url: '/about', priority: '0.8', changefreq: 'monthly' },
|
||||
{ url: '/services', priority: '0.8', changefreq: 'monthly' },
|
||||
{ url: '/pricing', priority: '0.8', changefreq: 'weekly' },
|
||||
{ url: '/blog', priority: '0.9', changefreq: 'daily' },
|
||||
{ url: '/privacy-policy', priority: '0.3', changefreq: 'yearly' },
|
||||
{ url: '/terms-of-use', priority: '0.3', changefreq: 'yearly' },
|
||||
];
|
||||
|
||||
let blogRoutes = [];
|
||||
try {
|
||||
const posts = JSON.parse(readFileSync(resolve(root, 'public/blog/posts.json'), 'utf-8'));
|
||||
blogRoutes = posts.map(p => ({
|
||||
url: `/blog/${p.slug}`,
|
||||
priority: '0.7',
|
||||
changefreq: 'monthly',
|
||||
lastmod: p.date || today,
|
||||
}));
|
||||
} catch {
|
||||
console.warn('Could not read posts.json, skipping blog routes');
|
||||
}
|
||||
|
||||
const allRoutes = [
|
||||
...staticRoutes.map(r => ({ ...r, lastmod: today })),
|
||||
...blogRoutes,
|
||||
];
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${allRoutes.map(r => ` <url>
|
||||
<loc>https://ai-impress.com${r.url}</loc>
|
||||
<lastmod>${r.lastmod}</lastmod>
|
||||
<changefreq>${r.changefreq}</changefreq>
|
||||
<priority>${r.priority}</priority>
|
||||
</url>`).join('\n')}
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
writeFileSync(resolve(root, 'public/sitemap.xml'), xml, 'utf-8');
|
||||
console.log(`Sitemap generated with ${allRoutes.length} URLs`);
|
||||
107
scripts/prerender.mjs
Normal file
107
scripts/prerender.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Post-build prerendering script.
|
||||
* Run after `vite build` to generate static HTML for each route.
|
||||
* Requires: npm install -D puppeteer serve
|
||||
*
|
||||
* Usage: node scripts/prerender.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
const distDir = resolve(root, 'dist');
|
||||
|
||||
function getBlogRoutes() {
|
||||
try {
|
||||
const posts = JSON.parse(readFileSync(resolve(root, 'public/blog/posts.json'), 'utf-8'));
|
||||
return posts.map(p => `/blog/${p.slug}`);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function getPageRoutes() {
|
||||
try {
|
||||
const pagesDir = resolve(root, 'public/pages');
|
||||
const files = readdirSync(pagesDir).filter(f => f.endsWith('.json'));
|
||||
return files
|
||||
.map(f => {
|
||||
const data = JSON.parse(readFileSync(resolve(pagesDir, f), 'utf-8'));
|
||||
return data.published ? `/p/${f.replace('.json', '')}` : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
const routes = [
|
||||
'/',
|
||||
'/about',
|
||||
'/services',
|
||||
'/pricing',
|
||||
'/blog',
|
||||
'/privacy-policy',
|
||||
'/terms-of-use',
|
||||
...getBlogRoutes(),
|
||||
...getPageRoutes(),
|
||||
];
|
||||
|
||||
async function prerender() {
|
||||
// Dynamic imports to avoid breaking build if puppeteer not installed
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
const { createServer } = await import('node:http');
|
||||
const { createReadStream, existsSync } = await import('node:fs');
|
||||
const { extname, join } = await import('node:path');
|
||||
const { lookup } = await import('mime-types');
|
||||
|
||||
// Simple static file server for dist/
|
||||
const PORT = 5199;
|
||||
const server = createServer((req, res) => {
|
||||
const urlPath = req.url.split('?')[0];
|
||||
let filePath = join(distDir, urlPath === '/' ? '/index.html' : urlPath);
|
||||
// For SPA routes, serve index.html
|
||||
if (!existsSync(filePath) || !extname(filePath)) {
|
||||
filePath = join(distDir, 'index.html');
|
||||
}
|
||||
const mime = lookup(filePath) || 'text/html';
|
||||
res.setHeader('Content-Type', mime);
|
||||
createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
await new Promise(r => server.listen(PORT, r));
|
||||
console.log(`Static server running on http://localhost:${PORT}`);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
],
|
||||
});
|
||||
|
||||
for (const route of routes) {
|
||||
const url = `http://localhost:${PORT}${route}`;
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
// Wait for React to render
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
const html = await page.content();
|
||||
|
||||
const routeDir = route === '/' ? distDir : join(distDir, route);
|
||||
mkdirSync(routeDir, { recursive: true });
|
||||
writeFileSync(join(routeDir, 'index.html'), html, 'utf-8');
|
||||
console.log(`Prerendered: ${route}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to prerender ${route}:`, err.message);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
console.log('Prerendering complete!');
|
||||
}
|
||||
|
||||
prerender().catch(console.error);
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { readdir, readFile, copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { readdir, readFile, copyFile, mkdir, rm, writeFile, stat } from 'node:fs/promises';
|
||||
import { join, basename } from 'node:path';
|
||||
|
||||
const INPUT_DIR = '/Volumes/SSD/Projects/Aimpress/LinkedIn-autopost/output';
|
||||
const LINKEDIN_INPUT_DIR = '/Volumes/SSD/Projects/Aimpress/LinkedIn-autopost/output';
|
||||
const TINA_INPUT_DIR = join(import.meta.dirname, '..', 'content', 'blog');
|
||||
const OUTPUT_DIR = join(import.meta.dirname, '..', 'public', 'blog');
|
||||
|
||||
function toSlug(title) {
|
||||
|
|
@ -56,6 +57,61 @@ function parseArticle(text, date) {
|
|||
return { slug, title, date, body, excerpt, sourceTitle, sourceUrl, hashtags };
|
||||
}
|
||||
|
||||
// Parse YAML-style frontmatter from TinaCMS markdown files
|
||||
function parseFrontmatter(text) {
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
if (!normalized.startsWith('---\n')) return { meta: {}, body: normalized };
|
||||
|
||||
const endIdx = normalized.indexOf('\n---\n', 4);
|
||||
if (endIdx === -1) return { meta: {}, body: normalized };
|
||||
|
||||
const yamlBlock = normalized.slice(4, endIdx);
|
||||
const body = normalized.slice(endIdx + 5).trim();
|
||||
|
||||
const meta = {};
|
||||
let currentKey = null;
|
||||
for (const line of yamlBlock.split('\n')) {
|
||||
// Multi-line list item (e.g. " - value")
|
||||
if (currentKey && line.match(/^\s+-\s+/)) {
|
||||
const item = line.replace(/^\s+-\s+/, '').replace(/^['"]|['"]$/g, '');
|
||||
if (!Array.isArray(meta[currentKey])) meta[currentKey] = [];
|
||||
meta[currentKey].push(item);
|
||||
continue;
|
||||
}
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) { currentKey = null; continue; }
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let val = line.slice(colonIdx + 1).trim();
|
||||
currentKey = key;
|
||||
// Strip surrounding quotes
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
// Parse list values (single-line arrays: [a, b, c])
|
||||
if (val.startsWith('[') && val.endsWith(']')) {
|
||||
meta[key] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
||||
currentKey = null;
|
||||
} else if (val === '') {
|
||||
// Empty value — may be followed by multi-line list items
|
||||
meta[key] = '';
|
||||
} else {
|
||||
meta[key] = val;
|
||||
currentKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
async function dirExists(dirPath) {
|
||||
try {
|
||||
const s = await stat(dirPath);
|
||||
return s.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Clear output
|
||||
await rm(join(OUTPUT_DIR, 'posts'), { recursive: true, force: true });
|
||||
|
|
@ -63,62 +119,120 @@ async function main() {
|
|||
await mkdir(join(OUTPUT_DIR, 'posts'), { recursive: true });
|
||||
await mkdir(join(OUTPUT_DIR, 'images'), { recursive: true });
|
||||
|
||||
const entries = await readdir(INPUT_DIR, { withFileTypes: true });
|
||||
const dateDirs = entries
|
||||
.filter(e => e.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
|
||||
const allPosts = [];
|
||||
const usedSlugs = new Set();
|
||||
|
||||
for (const dateDir of dateDirs) {
|
||||
const dirPath = join(INPUT_DIR, dateDir);
|
||||
const files = await readdir(dirPath);
|
||||
// --- Source 1: LinkedIn autopost output (optional — skipped if path doesn't exist) ---
|
||||
if (await dirExists(LINKEDIN_INPUT_DIR)) {
|
||||
const entries = await readdir(LINKEDIN_INPUT_DIR, { withFileTypes: true });
|
||||
const dateDirs = entries
|
||||
.filter(e => e.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
|
||||
const articles = files.filter(f => f.startsWith('article_') && f.endsWith('.txt'));
|
||||
for (const dateDir of dateDirs) {
|
||||
const dirPath = join(LINKEDIN_INPUT_DIR, dateDir);
|
||||
const files = await readdir(dirPath);
|
||||
|
||||
for (const articleFile of articles) {
|
||||
const timestamp = articleFile.match(/article_(\d+)\.txt/)?.[1];
|
||||
if (!timestamp) continue;
|
||||
const articles = files.filter(f => f.startsWith('article_') && f.endsWith('.txt'));
|
||||
|
||||
const coverFile = `cover_${timestamp}.png`;
|
||||
const hasCover = files.includes(coverFile);
|
||||
for (const articleFile of articles) {
|
||||
const timestamp = articleFile.match(/article_(\d+)\.txt/)?.[1];
|
||||
if (!timestamp) continue;
|
||||
|
||||
const text = await readFile(join(dirPath, articleFile), 'utf-8');
|
||||
const post = parseArticle(text, dateDir);
|
||||
const coverFile = `cover_${timestamp}.png`;
|
||||
const hasCover = files.includes(coverFile);
|
||||
|
||||
// Skip duplicate slugs (multiple versions of same article)
|
||||
if (usedSlugs.has(post.slug)) continue;
|
||||
usedSlugs.add(post.slug);
|
||||
const text = await readFile(join(dirPath, articleFile), 'utf-8');
|
||||
const post = parseArticle(text, dateDir);
|
||||
|
||||
// Skip duplicate slugs
|
||||
if (usedSlugs.has(post.slug)) continue;
|
||||
usedSlugs.add(post.slug);
|
||||
|
||||
const fullPost = {
|
||||
...post,
|
||||
coverImage: hasCover ? `/blog/images/${post.slug}.png` : '',
|
||||
};
|
||||
await writeFile(
|
||||
join(OUTPUT_DIR, 'posts', `${post.slug}.json`),
|
||||
JSON.stringify(fullPost, null, 2)
|
||||
);
|
||||
|
||||
if (hasCover) {
|
||||
await copyFile(
|
||||
join(dirPath, coverFile),
|
||||
join(OUTPUT_DIR, 'images', `${post.slug}.png`)
|
||||
);
|
||||
}
|
||||
|
||||
allPosts.push({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
coverImage: fullPost.coverImage,
|
||||
hashtags: post.hashtags,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`Synced ${allPosts.length} LinkedIn posts`);
|
||||
} else {
|
||||
console.log(`LinkedIn source not found (${LINKEDIN_INPUT_DIR}), skipping`);
|
||||
}
|
||||
|
||||
// --- Source 2: TinaCMS-authored blog posts from content/blog/*.md ---
|
||||
if (await dirExists(TINA_INPUT_DIR)) {
|
||||
const mdFiles = (await readdir(TINA_INPUT_DIR)).filter(f => f.endsWith('.md'));
|
||||
|
||||
for (const mdFile of mdFiles) {
|
||||
const text = await readFile(join(TINA_INPUT_DIR, mdFile), 'utf-8');
|
||||
const { meta, body } = parseFrontmatter(text);
|
||||
|
||||
if (!meta.title || !meta.date) continue;
|
||||
|
||||
const date = meta.date.slice(0, 10); // ISO date → YYYY-MM-DD
|
||||
const slug = toSlug(meta.title);
|
||||
|
||||
// TinaCMS posts take priority over LinkedIn (override if same slug)
|
||||
if (usedSlugs.has(slug)) {
|
||||
// Remove the existing post entry (will be replaced)
|
||||
const idx = allPosts.findIndex(p => p.slug === slug);
|
||||
if (idx !== -1) allPosts.splice(idx, 1);
|
||||
}
|
||||
usedSlugs.add(slug);
|
||||
|
||||
const excerpt = meta.excerpt || makeExcerpt(body.replace(/[#*`>\[\]]/g, ''));
|
||||
const coverImage = meta.coverImage || '';
|
||||
const hashtags = Array.isArray(meta.hashtags) ? meta.hashtags : [];
|
||||
|
||||
// Write full post JSON
|
||||
const fullPost = {
|
||||
...post,
|
||||
coverImage: hasCover ? `/blog/images/${post.slug}.png` : '',
|
||||
slug,
|
||||
title: meta.title,
|
||||
date,
|
||||
body,
|
||||
excerpt,
|
||||
coverImage,
|
||||
sourceTitle: meta.sourceTitle || '',
|
||||
sourceUrl: meta.sourceUrl || '',
|
||||
hashtags,
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
join(OUTPUT_DIR, 'posts', `${post.slug}.json`),
|
||||
join(OUTPUT_DIR, 'posts', `${slug}.json`),
|
||||
JSON.stringify(fullPost, null, 2)
|
||||
);
|
||||
|
||||
// Copy cover image
|
||||
if (hasCover) {
|
||||
await copyFile(
|
||||
join(dirPath, coverFile),
|
||||
join(OUTPUT_DIR, 'images', `${post.slug}.png`)
|
||||
);
|
||||
}
|
||||
|
||||
allPosts.push({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
coverImage: fullPost.coverImage,
|
||||
hashtags: post.hashtags,
|
||||
slug,
|
||||
title: meta.title,
|
||||
date,
|
||||
excerpt,
|
||||
coverImage,
|
||||
hashtags,
|
||||
});
|
||||
}
|
||||
console.log(`Merged ${mdFiles.length} TinaCMS posts from content/blog/`);
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
|
|
@ -129,7 +243,7 @@ async function main() {
|
|||
JSON.stringify(allPosts, null, 2)
|
||||
);
|
||||
|
||||
console.log(`Synced ${allPosts.length} blog posts to ${OUTPUT_DIR}`);
|
||||
console.log(`Total: ${allPosts.length} blog posts written to ${OUTPUT_DIR}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ server {
|
|||
location /blog/ {
|
||||
expires 10m;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Chatbot API proxy
|
||||
|
|
@ -53,8 +54,8 @@ server {
|
|||
}
|
||||
|
||||
# Security headers
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://cdn.mxpnl.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://lh3.googleusercontent.com https://www.googletagmanager.com; connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://*.google-analytics.com https://*.analytics.google.com https://api-js.mixpanel.com https://api.mixpanel.com https://*.mixpanel.com https://*.mxpnl.com https://*.amplitude.com; frame-src https://www.youtube.com; media-src 'self'; worker-src 'self' blob:;" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://cdn.mxpnl.com https://us-assets.i.posthog.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://lh3.googleusercontent.com https://www.googletagmanager.com; connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://*.google-analytics.com https://*.analytics.google.com https://api-js.mixpanel.com https://api.mixpanel.com https://*.mixpanel.com https://*.mxpnl.com https://*.amplitude.com https://content.tinajs.io https://*.tinajs.io https://api.tinajs.io https://us.i.posthog.com https://us-assets.i.posthog.com; frame-src https://www.youtube.com https://ai-impress.com; media-src 'self'; worker-src 'self' blob:;" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ScrollToTop from './components/ScrollToTop';
|
|||
import CookieConsent from './components/CookieConsent';
|
||||
import React from 'react';
|
||||
const ChatWidget = React.lazy(() => import('./components/ChatWidget'));
|
||||
const DynamicPage = React.lazy(() => import('./pages/DynamicPage'));
|
||||
import HomePage from './pages/HomePage';
|
||||
import BlogPage from './pages/BlogPage';
|
||||
import BlogPostPage from './pages/BlogPostPage';
|
||||
|
|
@ -30,6 +31,7 @@ function App() {
|
|||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/terms-of-use" element={<TermsOfUsePage />} />
|
||||
<Route path="/p/:slug" element={<React.Suspense fallback={null}><DynamicPage /></React.Suspense>} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
<ScrollToTop />
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20%;
|
||||
width: 11%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
z-index: 10;
|
||||
|
|
@ -94,14 +94,14 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
animation: spin-right 20s linear infinite;
|
||||
animation: spin-text 20s linear infinite;
|
||||
will-change: transform;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.is-hovered .banner2-rotating-text {
|
||||
animation-duration: 5s;
|
||||
animation: spin-text 5s linear infinite;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 91, 4, 0.4));
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +110,18 @@
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-text {
|
||||
from { transform: scale(1.45) rotate(0deg); }
|
||||
to { transform: scale(1.45) rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@keyframes spin-text {
|
||||
from { transform: scale(1.6) rotate(0deg); }
|
||||
to { transform: scale(1.6) rotate(360deg); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Center CTA hint ── */
|
||||
.banner2-cta-hint {
|
||||
position: absolute;
|
||||
|
|
@ -157,30 +169,30 @@
|
|||
filter: drop-shadow(0 0 8px rgba(252, 168, 46, 0.7));
|
||||
}
|
||||
|
||||
/* ── Responsive sizes (unchanged) ── */
|
||||
/* ── Responsive sizes ── */
|
||||
@media (max-width: 2200px) {
|
||||
.banner2-text-overlay { width: 24%; }
|
||||
.banner2-text-overlay { width: 13%; }
|
||||
}
|
||||
@media (max-width: 1800px) {
|
||||
.banner2-text-overlay { width: 26%; }
|
||||
.banner2-text-overlay { width: 15%; }
|
||||
}
|
||||
@media (max-width: 1600px) {
|
||||
.banner2-text-overlay { width: 30%; }
|
||||
.banner2-text-overlay { width: 17%; }
|
||||
}
|
||||
@media (max-width: 1440px) {
|
||||
.banner2-text-overlay { width: 32%; }
|
||||
.banner2-text-overlay { width: 18%; }
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.banner2-text-overlay { width: 32%; }
|
||||
.banner2-text-overlay { width: 19%; }
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.banner2-text-overlay { width: 35%; }
|
||||
.banner2-text-overlay { width: 22%; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.banner2-text-overlay { width: 55%; }
|
||||
.banner2-text-overlay { width: 30%; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.banner2-section { padding: 2rem 1rem; }
|
||||
.banner2-text-overlay { width: 55%; }
|
||||
.banner2-text-overlay { width: 30%; }
|
||||
.banner2-orbit-dot { width: 5px; height: 5px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ const SEO: React.FC<SEOProps> = ({
|
|||
<title>{resolvedTitle}</title>
|
||||
<meta name="description" content={resolvedDescription} />
|
||||
<link rel="canonical" href={url} />
|
||||
<link rel="alternate" hrefLang="en" href={url} />
|
||||
<link rel="alternate" hrefLang="uk" href={url} />
|
||||
<link rel="alternate" hrefLang="x-default" href={url} />
|
||||
|
||||
<meta property="og:title" content={resolvedTitle} />
|
||||
<meta property="og:description" content={resolvedDescription} />
|
||||
|
|
|
|||
78
src/components/blocks/BlockCTABanner.css
Normal file
78
src/components/blocks/BlockCTABanner.css
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
.block-cta-banner {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-cta-banner-inner {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.block-cta-banner-headline {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.block-cta-banner-subtext {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.block-cta-banner-btn {
|
||||
display: inline-block;
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-cta-banner--orange {
|
||||
background-color: var(--orange-100);
|
||||
}
|
||||
|
||||
.block-cta-banner--orange .block-cta-banner-headline {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.block-cta-banner--orange .block-cta-banner-subtext {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.block-cta-banner--orange .block-cta-banner-btn {
|
||||
background-color: white;
|
||||
color: var(--orange-100);
|
||||
}
|
||||
|
||||
.block-cta-banner--orange .block-cta-banner-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.block-cta-banner--teal {
|
||||
background-color: var(--dark-teal-100);
|
||||
}
|
||||
|
||||
.block-cta-banner--teal .block-cta-banner-headline {
|
||||
color: var(--light-grey-100);
|
||||
}
|
||||
|
||||
.block-cta-banner--teal .block-cta-banner-subtext {
|
||||
color: var(--light-grey-100);
|
||||
}
|
||||
|
||||
.block-cta-banner--teal .block-cta-banner-btn {
|
||||
background-color: var(--orange-100);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.block-cta-banner--teal .block-cta-banner-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(255, 91, 4, 0.3);
|
||||
}
|
||||
38
src/components/blocks/BlockCTABanner.tsx
Normal file
38
src/components/blocks/BlockCTABanner.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import './BlockCTABanner.css';
|
||||
|
||||
interface Props {
|
||||
headline?: string;
|
||||
subtext?: string;
|
||||
btnText?: string;
|
||||
btnUrl?: string;
|
||||
style?: 'orange' | 'teal';
|
||||
}
|
||||
|
||||
const BlockCTABanner: React.FC<Props> = ({
|
||||
headline,
|
||||
subtext,
|
||||
btnText,
|
||||
btnUrl = '/',
|
||||
style = 'orange',
|
||||
}) => (
|
||||
<motion.section
|
||||
className={`block-cta-banner block-cta-banner--${style}`}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-cta-banner-inner">
|
||||
{headline && <h2 className="block-cta-banner-headline">{headline}</h2>}
|
||||
{subtext && <p className="block-cta-banner-subtext">{subtext}</p>}
|
||||
{btnText && (
|
||||
<a href={btnUrl} className="block-cta-banner-btn">
|
||||
{btnText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockCTABanner;
|
||||
13
src/components/blocks/BlockContactForm.tsx
Normal file
13
src/components/blocks/BlockContactForm.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import ContactSection from '../ContactSection';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const BlockContactForm: React.FC<Props> = () => {
|
||||
return <ContactSection />;
|
||||
};
|
||||
|
||||
export default BlockContactForm;
|
||||
25
src/components/blocks/BlockDivider.css
Normal file
25
src/components/blocks/BlockDivider.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.block-divider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.block-divider--space.block-divider--small {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.block-divider--space.block-divider--medium {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.block-divider--space.block-divider--large {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.block-divider--line {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.block-divider-line {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(211, 221, 222, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
14
src/components/blocks/BlockDivider.tsx
Normal file
14
src/components/blocks/BlockDivider.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import './BlockDivider.css';
|
||||
|
||||
interface Props {
|
||||
type?: 'line' | 'space';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
const BlockDivider: React.FC<Props> = ({ type = 'space', size = 'medium' }) => (
|
||||
<div className={`block-divider block-divider--${type} block-divider--${size}`}>
|
||||
{type === 'line' && <hr className="block-divider-line" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BlockDivider;
|
||||
72
src/components/blocks/BlockFAQ.css
Normal file
72
src/components/blocks/BlockFAQ.css
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
.block-faq {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-faq-inner {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-faq-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.block-faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.block-faq-item {
|
||||
background: rgba(7, 80, 86, 0.1);
|
||||
border: 1px solid rgba(211, 221, 222, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.block-faq-item[open] {
|
||||
border-color: rgba(255, 91, 4, 0.3);
|
||||
}
|
||||
|
||||
.block-faq-question {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--light-grey-100);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.block-faq-question::marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block-faq-question::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block-faq-question::after {
|
||||
content: '▶';
|
||||
color: var(--orange-100);
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
.block-faq-item[open] .block-faq-question::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.block-faq-answer {
|
||||
padding: 0 24px 20px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.8;
|
||||
line-height: 1.7;
|
||||
}
|
||||
32
src/components/blocks/BlockFAQ.tsx
Normal file
32
src/components/blocks/BlockFAQ.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockFAQ.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
items?: Array<{ question?: string; answer?: string }>;
|
||||
}
|
||||
|
||||
const BlockFAQ: React.FC<Props> = ({ title, items = [] }) => (
|
||||
<motion.section
|
||||
className="block-faq"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-faq-inner">
|
||||
{title && <h2 className="block-faq-title">{title}</h2>}
|
||||
<div className="block-faq-list">
|
||||
{items.map((item, i) => (
|
||||
<details key={i} className="block-faq-item">
|
||||
<summary className="block-faq-question">{item.question}</summary>
|
||||
<p className="block-faq-answer">{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockFAQ;
|
||||
59
src/components/blocks/BlockFeatures.css
Normal file
59
src/components/blocks/BlockFeatures.css
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
.block-features {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-features-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-features-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.block-features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.block-features-card {
|
||||
background: rgba(7, 80, 86, 0.15);
|
||||
border: 1px solid rgba(211, 221, 222, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.block-features-card:hover {
|
||||
background: rgba(7, 80, 86, 0.25);
|
||||
border-color: rgba(211, 221, 222, 0.2);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.block-features-icon {
|
||||
font-size: 2.5rem;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.block-features-card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--light-grey-100);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.block-features-card-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.75;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
39
src/components/blocks/BlockFeatures.tsx
Normal file
39
src/components/blocks/BlockFeatures.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import './BlockFeatures.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
items?: Array<{ icon?: string; title?: string; description?: string }>;
|
||||
}
|
||||
|
||||
const BlockFeatures: React.FC<Props> = ({ title, items = [] }) => (
|
||||
<motion.section
|
||||
className="block-features"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-features-inner">
|
||||
{title && <h2 className="block-features-title">{title}</h2>}
|
||||
<div className="block-features-grid">
|
||||
{items.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="block-features-card"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.08 }}
|
||||
>
|
||||
{item.icon && <span className="block-features-icon">{item.icon}</span>}
|
||||
{item.title && <h3 className="block-features-card-title">{item.title}</h3>}
|
||||
{item.description && <p className="block-features-card-desc">{item.description}</p>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockFeatures;
|
||||
38
src/components/blocks/BlockGallery.css
Normal file
38
src/components/blocks/BlockGallery.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.block-gallery {
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.block-gallery-grid {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.block-gallery-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.block-gallery-item img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.block-gallery-item:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.block-gallery-item figcaption {
|
||||
font-size: 0.8rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
29
src/components/blocks/BlockGallery.tsx
Normal file
29
src/components/blocks/BlockGallery.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import './BlockGallery.css';
|
||||
|
||||
interface Props {
|
||||
images?: Array<{ src?: string; caption?: string; alt?: string }>;
|
||||
}
|
||||
|
||||
const BlockGallery: React.FC<Props> = ({ images = [] }) => (
|
||||
<motion.section
|
||||
className="block-gallery"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-gallery-grid">
|
||||
{images.map((img, i) =>
|
||||
img.src ? (
|
||||
<figure key={i} className="block-gallery-item">
|
||||
<img src={img.src} alt={img.alt || img.caption || ''} loading="lazy" />
|
||||
{img.caption && <figcaption>{img.caption}</figcaption>}
|
||||
</figure>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockGallery;
|
||||
61
src/components/blocks/BlockHero.css
Normal file
61
src/components/blocks/BlockHero.css
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
.block-hero {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-hero--dark {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.block-hero--teal {
|
||||
background: rgba(7, 80, 86, 0.3);
|
||||
}
|
||||
|
||||
.block-hero--gradient {
|
||||
background: linear-gradient(135deg, rgba(7, 80, 86, 0.4) 0%, rgba(255, 91, 4, 0.1) 100%);
|
||||
}
|
||||
|
||||
.block-hero-inner {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.block-hero-headline {
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
font-weight: 800;
|
||||
color: var(--light-grey-100);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.block-hero-subtext {
|
||||
font-size: 1.15rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.8;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 40px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.block-hero-cta {
|
||||
display: inline-block;
|
||||
background-color: var(--orange-100);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 16px 40px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-hero-cta:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
37
src/components/blocks/BlockHero.tsx
Normal file
37
src/components/blocks/BlockHero.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import './BlockHero.css';
|
||||
|
||||
interface Props {
|
||||
headline?: string;
|
||||
subtext?: string;
|
||||
ctaText?: string;
|
||||
ctaUrl?: string;
|
||||
backgroundStyle?: 'dark' | 'teal' | 'gradient';
|
||||
}
|
||||
|
||||
const BlockHero: React.FC<Props> = ({
|
||||
headline,
|
||||
subtext,
|
||||
ctaText,
|
||||
ctaUrl = '/',
|
||||
backgroundStyle = 'dark',
|
||||
}) => (
|
||||
<section className={`block-hero block-hero--${backgroundStyle}`}>
|
||||
<motion.div
|
||||
className="block-hero-inner"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{headline && <h1 className="block-hero-headline">{headline}</h1>}
|
||||
{subtext && <p className="block-hero-subtext">{subtext}</p>}
|
||||
{ctaText && (
|
||||
<a href={ctaUrl} className="block-hero-cta">
|
||||
{ctaText}
|
||||
</a>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default BlockHero;
|
||||
102
src/components/blocks/BlockPricing.css
Normal file
102
src/components/blocks/BlockPricing.css
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
.block-pricing {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-pricing-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-pricing-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.block-pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.block-pricing-card {
|
||||
background: rgba(35, 48, 56, 0.8);
|
||||
border: 1px solid rgba(211, 221, 222, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 36px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block-pricing-card--highlighted {
|
||||
border: 2px solid var(--orange-100);
|
||||
background: rgba(255, 91, 4, 0.05);
|
||||
}
|
||||
|
||||
.block-pricing-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--orange-100);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.block-pricing-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.block-pricing-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.block-pricing-amount {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--orange-100);
|
||||
}
|
||||
|
||||
.block-pricing-period {
|
||||
font-size: 1rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.block-pricing-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.block-pricing-features li {
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.85;
|
||||
font-size: 0.9rem;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.block-pricing-features li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-weight: 700;
|
||||
color: #0a7a82;
|
||||
}
|
||||
58
src/components/blocks/BlockPricing.tsx
Normal file
58
src/components/blocks/BlockPricing.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockPricing.css';
|
||||
|
||||
interface Plan {
|
||||
name?: string;
|
||||
price?: string;
|
||||
period?: string;
|
||||
features?: string[];
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
plans?: Plan[];
|
||||
}
|
||||
|
||||
const BlockPricing: React.FC<Props> = ({ title, plans = [] }) => (
|
||||
<motion.section
|
||||
className="block-pricing"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-pricing-inner">
|
||||
{title && <h2 className="block-pricing-title">{title}</h2>}
|
||||
<div className="block-pricing-grid">
|
||||
{plans.map((plan, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`block-pricing-card${plan.highlighted ? ' block-pricing-card--highlighted' : ''}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
>
|
||||
{plan.highlighted && <span className="block-pricing-badge">Popular</span>}
|
||||
{plan.name && <h3 className="block-pricing-name">{plan.name}</h3>}
|
||||
<div className="block-pricing-price">
|
||||
<span className="block-pricing-amount">{plan.price}</span>
|
||||
{plan.period && <span className="block-pricing-period">/{plan.period}</span>}
|
||||
</div>
|
||||
{plan.features && plan.features.length > 0 && (
|
||||
<ul className="block-pricing-features">
|
||||
{plan.features.map((f, j) => (
|
||||
<li key={j}>{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockPricing;
|
||||
40
src/components/blocks/BlockRenderer.tsx
Normal file
40
src/components/blocks/BlockRenderer.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { PageBlock } from '../../types/pages';
|
||||
import BlockHero from './BlockHero';
|
||||
import BlockTextBlock from './BlockTextBlock';
|
||||
import BlockTwoColumn from './BlockTwoColumn';
|
||||
import BlockFeatures from './BlockFeatures';
|
||||
import BlockStats from './BlockStats';
|
||||
import BlockTestimonials from './BlockTestimonials';
|
||||
import BlockTeam from './BlockTeam';
|
||||
import BlockFAQ from './BlockFAQ';
|
||||
import BlockCTABanner from './BlockCTABanner';
|
||||
import BlockVideo from './BlockVideo';
|
||||
import BlockGallery from './BlockGallery';
|
||||
import BlockPricing from './BlockPricing';
|
||||
import BlockTimeline from './BlockTimeline';
|
||||
import BlockDivider from './BlockDivider';
|
||||
import BlockContactForm from './BlockContactForm';
|
||||
|
||||
interface Props { block: PageBlock; }
|
||||
|
||||
const BlockRenderer: React.FC<Props> = ({ block }) => {
|
||||
switch (block._template) {
|
||||
case 'hero': return <BlockHero {...block} />;
|
||||
case 'textBlock': return <BlockTextBlock {...block} />;
|
||||
case 'twoColumn': return <BlockTwoColumn {...block} />;
|
||||
case 'features': return <BlockFeatures {...block} />;
|
||||
case 'stats': return <BlockStats {...block} />;
|
||||
case 'testimonials':return <BlockTestimonials {...block} />;
|
||||
case 'team': return <BlockTeam {...block} />;
|
||||
case 'faq': return <BlockFAQ {...block} />;
|
||||
case 'ctaBanner': return <BlockCTABanner {...block} />;
|
||||
case 'video': return <BlockVideo {...block} />;
|
||||
case 'gallery': return <BlockGallery {...block} />;
|
||||
case 'pricing': return <BlockPricing {...block} />;
|
||||
case 'timeline': return <BlockTimeline {...block} />;
|
||||
case 'divider': return <BlockDivider {...block} />;
|
||||
case 'contactForm': return <BlockContactForm {...block} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
export default BlockRenderer;
|
||||
32
src/components/blocks/BlockStats.css
Normal file
32
src/components/blocks/BlockStats.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.block-stats {
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.block-stats-grid {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.block-stats-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.block-stats-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--orange-100);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.block-stats-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.8;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
27
src/components/blocks/BlockStats.tsx
Normal file
27
src/components/blocks/BlockStats.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import './BlockStats.css';
|
||||
|
||||
interface Props {
|
||||
items?: Array<{ value?: string; label?: string }>;
|
||||
}
|
||||
|
||||
const BlockStats: React.FC<Props> = ({ items = [] }) => (
|
||||
<motion.section
|
||||
className="block-stats"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-stats-grid">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="block-stats-item">
|
||||
<span className="block-stats-value">{item.value}</span>
|
||||
<span className="block-stats-label">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockStats;
|
||||
71
src/components/blocks/BlockTeam.css
Normal file
71
src/components/blocks/BlockTeam.css
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
.block-team {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-team-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-team-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.block-team-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.block-team-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.block-team-photo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin: 0 auto 16px;
|
||||
display: block;
|
||||
border: 3px solid var(--dark-teal-100);
|
||||
}
|
||||
|
||||
.block-team-photo-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--dark-teal-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
color: var(--light-grey-100);
|
||||
font-weight: 700;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.block-team-name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.block-team-role {
|
||||
font-size: 0.85rem;
|
||||
color: var(--orange-100);
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.block-team-bio {
|
||||
font-size: 0.85rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.7;
|
||||
line-height: 1.6;
|
||||
}
|
||||
45
src/components/blocks/BlockTeam.tsx
Normal file
45
src/components/blocks/BlockTeam.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockTeam.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
items?: Array<{ name?: string; role?: string; bio?: string; photo?: string }>;
|
||||
}
|
||||
|
||||
const BlockTeam: React.FC<Props> = ({ title, items = [] }) => (
|
||||
<motion.section
|
||||
className="block-team"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-team-inner">
|
||||
{title && <h2 className="block-team-title">{title}</h2>}
|
||||
<div className="block-team-grid">
|
||||
{items.map((member, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="block-team-card"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
>
|
||||
{member.photo ? (
|
||||
<img src={member.photo} alt={member.name || ''} className="block-team-photo" />
|
||||
) : (
|
||||
<div className="block-team-photo-placeholder">{member.name?.[0] || '?'}</div>
|
||||
)}
|
||||
{member.name && <h3 className="block-team-name">{member.name}</h3>}
|
||||
{member.role && <p className="block-team-role">{member.role}</p>}
|
||||
{member.bio && <p className="block-team-bio">{member.bio}</p>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockTeam;
|
||||
72
src/components/blocks/BlockTestimonials.css
Normal file
72
src/components/blocks/BlockTestimonials.css
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
.block-testimonials {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-testimonials-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-testimonials-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.block-testimonials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.block-testimonials-card {
|
||||
background: rgba(7, 80, 86, 0.15);
|
||||
border: 1px solid rgba(211, 221, 222, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block-testimonials-card::before {
|
||||
content: '"';
|
||||
font-size: 5rem;
|
||||
color: var(--orange-100);
|
||||
opacity: 0.3;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.block-testimonials-quote {
|
||||
font-size: 1rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.85;
|
||||
line-height: 1.7;
|
||||
font-style: italic;
|
||||
margin-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.block-testimonials-author {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.block-testimonials-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--light-grey-100);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.block-testimonials-meta {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
46
src/components/blocks/BlockTestimonials.tsx
Normal file
46
src/components/blocks/BlockTestimonials.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockTestimonials.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
items?: Array<{ quote?: string; author?: string; role?: string; company?: string }>;
|
||||
}
|
||||
|
||||
const BlockTestimonials: React.FC<Props> = ({ title, items = [] }) => (
|
||||
<motion.section
|
||||
className="block-testimonials"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-testimonials-inner">
|
||||
{title && <h2 className="block-testimonials-title">{title}</h2>}
|
||||
<div className="block-testimonials-grid">
|
||||
{items.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="block-testimonials-card"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
>
|
||||
<p className="block-testimonials-quote">"{item.quote}"</p>
|
||||
<div className="block-testimonials-author">
|
||||
<span className="block-testimonials-name">{item.author}</span>
|
||||
{(item.role || item.company) && (
|
||||
<span className="block-testimonials-meta">
|
||||
{[item.role, item.company].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockTestimonials;
|
||||
80
src/components/blocks/BlockTextBlock.css
Normal file
80
src/components/blocks/BlockTextBlock.css
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
.block-text-block {
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.block-text-block--full .block-text-block-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-text-block--narrow .block-text-block-inner {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-text-block-inner h1,
|
||||
.block-text-block-inner h2,
|
||||
.block-text-block-inner h3 {
|
||||
color: var(--light-grey-100);
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.block-text-block-inner h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.block-text-block-inner h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.block-text-block-inner h3 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.block-text-block-inner p {
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.85;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.block-text-block-inner a {
|
||||
color: var(--orange-100);
|
||||
text-decoration: underline;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.block-text-block-inner a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.block-text-block-inner strong {
|
||||
color: var(--light-grey-100);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.block-text-block-inner ul,
|
||||
.block-text-block-inner ol {
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.85;
|
||||
padding-left: 24px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.block-text-block-inner li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.block-text-block-inner blockquote {
|
||||
border-left: 3px solid var(--orange-100);
|
||||
padding-left: 20px;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
margin: 24px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
28
src/components/blocks/BlockTextBlock.tsx
Normal file
28
src/components/blocks/BlockTextBlock.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { TinaMarkdown } from 'tinacms/dist/rich-text';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockTextBlock.css';
|
||||
|
||||
interface Props {
|
||||
content?: unknown;
|
||||
width?: 'full' | 'narrow';
|
||||
}
|
||||
|
||||
const BlockTextBlock: React.FC<Props> = ({ content, width = 'full' }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
className={`block-text-block block-text-block--${width}`}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-text-block-inner">
|
||||
<TinaMarkdown content={content as any} />
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockTextBlock;
|
||||
94
src/components/blocks/BlockTimeline.css
Normal file
94
src/components/blocks/BlockTimeline.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
.block-timeline {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-timeline-inner {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-timeline-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.block-timeline-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.block-timeline-step {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.block-timeline-step::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 44px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: rgba(211, 221, 222, 0.1);
|
||||
}
|
||||
|
||||
.block-timeline-step:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block-timeline-step-number {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--orange-100);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.block-timeline-step-content {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.block-timeline-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.block-timeline-step-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--light-grey-100);
|
||||
}
|
||||
|
||||
.block-timeline-step-duration {
|
||||
font-size: 0.8rem;
|
||||
color: var(--orange-100);
|
||||
background: rgba(255, 91, 4, 0.1);
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-timeline-step-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.75;
|
||||
line-height: 1.7;
|
||||
}
|
||||
51
src/components/blocks/BlockTimeline.tsx
Normal file
51
src/components/blocks/BlockTimeline.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockTimeline.css';
|
||||
|
||||
interface Step {
|
||||
title?: string;
|
||||
description?: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
steps?: Step[];
|
||||
}
|
||||
|
||||
const BlockTimeline: React.FC<Props> = ({ title, steps = [] }) => (
|
||||
<motion.section
|
||||
className="block-timeline"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-timeline-inner">
|
||||
{title && <h2 className="block-timeline-title">{title}</h2>}
|
||||
<div className="block-timeline-steps">
|
||||
{steps.map((step, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="block-timeline-step"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
>
|
||||
<div className="block-timeline-step-number">{i + 1}</div>
|
||||
<div className="block-timeline-step-content">
|
||||
<div className="block-timeline-step-header">
|
||||
{step.title && <h3 className="block-timeline-step-title">{step.title}</h3>}
|
||||
{step.duration && <span className="block-timeline-step-duration">{step.duration}</span>}
|
||||
</div>
|
||||
{step.description && <p className="block-timeline-step-desc">{step.description}</p>}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockTimeline;
|
||||
82
src/components/blocks/BlockTwoColumn.css
Normal file
82
src/components/blocks/BlockTwoColumn.css
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
.block-two-column {
|
||||
padding: 80px 24px;
|
||||
}
|
||||
|
||||
.block-two-column-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.block-two-column-left,
|
||||
.block-two-column-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.block-two-column-img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block-two-column-left h1,
|
||||
.block-two-column-left h2,
|
||||
.block-two-column-left h3,
|
||||
.block-two-column-right h1,
|
||||
.block-two-column-right h2,
|
||||
.block-two-column-right h3 {
|
||||
color: var(--light-grey-100);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.block-two-column-left p,
|
||||
.block-two-column-right p {
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.85;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.block-two-column-left a,
|
||||
.block-two-column-right a {
|
||||
color: var(--orange-100);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.block-two-column-left strong,
|
||||
.block-two-column-right strong {
|
||||
color: var(--light-grey-100);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.block-two-column-left ul,
|
||||
.block-two-column-left ol,
|
||||
.block-two-column-right ul,
|
||||
.block-two-column-right ol {
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.85;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.block-two-column-inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.block-two-column--reverse .block-two-column-left {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.block-two-column--reverse .block-two-column-right {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
50
src/components/blocks/BlockTwoColumn.tsx
Normal file
50
src/components/blocks/BlockTwoColumn.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { TinaMarkdown } from 'tinacms/dist/rich-text';
|
||||
import { motion } from 'framer-motion';
|
||||
import './BlockTwoColumn.css';
|
||||
|
||||
interface Props {
|
||||
leftType?: 'text' | 'image';
|
||||
leftText?: unknown;
|
||||
leftImage?: string;
|
||||
rightType?: 'text' | 'image';
|
||||
rightText?: unknown;
|
||||
rightImage?: string;
|
||||
reverseOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const BlockTwoColumn: React.FC<Props> = ({
|
||||
leftType = 'text',
|
||||
leftText,
|
||||
leftImage,
|
||||
rightType = 'text',
|
||||
rightText,
|
||||
rightImage,
|
||||
reverseOnMobile = false,
|
||||
}) => (
|
||||
<motion.section
|
||||
className={`block-two-column${reverseOnMobile ? ' block-two-column--reverse' : ''}`}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-two-column-inner">
|
||||
<div className="block-two-column-left">
|
||||
{leftType === 'image' && leftImage ? (
|
||||
<img src={leftImage} alt="" className="block-two-column-img" />
|
||||
) : leftText ? (
|
||||
<TinaMarkdown content={leftText as any} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="block-two-column-right">
|
||||
{rightType === 'image' && rightImage ? (
|
||||
<img src={rightImage} alt="" className="block-two-column-img" />
|
||||
) : rightText ? (
|
||||
<TinaMarkdown content={rightText as any} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
export default BlockTwoColumn;
|
||||
36
src/components/blocks/BlockVideo.css
Normal file
36
src/components/blocks/BlockVideo.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
.block-video {
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.block-video-inner {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.block-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.block-video-wrapper iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.block-video-caption {
|
||||
text-align: center;
|
||||
color: var(--light-grey-100);
|
||||
opacity: 0.7;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
43
src/components/blocks/BlockVideo.tsx
Normal file
43
src/components/blocks/BlockVideo.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import './BlockVideo.css';
|
||||
|
||||
interface Props {
|
||||
youtubeUrl?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
function getYouTubeId(url: string): string | null {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const BlockVideo: React.FC<Props> = ({ youtubeUrl, caption }) => {
|
||||
if (!youtubeUrl) return null;
|
||||
const videoId = getYouTubeId(youtubeUrl);
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
className="block-video"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="block-video-inner">
|
||||
<div className="block-video-wrapper">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title={caption || 'Video'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{caption && <p className="block-video-caption">{caption}</p>}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockVideo;
|
||||
9
src/components/blocks/index.ts
Normal file
9
src/components/blocks/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { default as BlockDivider } from './BlockDivider';
|
||||
export { default as BlockStats } from './BlockStats';
|
||||
export { default as BlockCTABanner } from './BlockCTABanner';
|
||||
export { default as BlockVideo } from './BlockVideo';
|
||||
export { default as BlockGallery } from './BlockGallery';
|
||||
export { default as BlockFeatures } from './BlockFeatures';
|
||||
export { default as BlockHero } from './BlockHero';
|
||||
export { default as BlockTextBlock } from './BlockTextBlock';
|
||||
export { default as BlockTwoColumn } from './BlockTwoColumn';
|
||||
|
|
@ -1,9 +1,28 @@
|
|||
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useTina } from 'tinacms/dist/react';
|
||||
import client from '../../tina/__generated__/client';
|
||||
import type { Lang, TranslationKey, Translations } from './types';
|
||||
import { en } from './en';
|
||||
import { uk } from './uk';
|
||||
import enRaw from '../../content/translations/en.json';
|
||||
import ukRaw from '../../content/translations/uk.json';
|
||||
|
||||
const translations: Record<Lang, Translations> = { en, uk };
|
||||
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
||||
return Object.entries(obj).reduce((acc, [key, val]) => {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (key === '_') {
|
||||
acc[prefix] = String(val);
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
Object.assign(acc, flattenObject(val as Record<string, unknown>, fullKey));
|
||||
} else {
|
||||
acc[fullKey] = String(val);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
|
||||
const staticTranslations: Record<Lang, Translations> = {
|
||||
en: flattenObject(enRaw as Record<string, unknown>) as unknown as Translations,
|
||||
uk: flattenObject(ukRaw as Record<string, unknown>) as unknown as Translations,
|
||||
};
|
||||
|
||||
interface LanguageContextValue {
|
||||
lang: Lang;
|
||||
|
|
@ -21,31 +40,105 @@ function getInitialLang(): Lang {
|
|||
return 'en';
|
||||
}
|
||||
|
||||
type TinaQueryResult = { query: string; variables: Record<string, unknown>; data: Record<string, unknown> };
|
||||
|
||||
// Headless component — calls useTina (which requires valid queries) and syncs
|
||||
// live data back to LanguageProvider via a stable callback. Renders nothing,
|
||||
// so swapping it in/out never remounts the page children.
|
||||
function TinaLiveSync({
|
||||
enResult,
|
||||
ukResult,
|
||||
onSync,
|
||||
}: {
|
||||
enResult: TinaQueryResult;
|
||||
ukResult: TinaQueryResult;
|
||||
onSync: (enData: unknown, ukData: unknown) => void;
|
||||
}) {
|
||||
const { data: enLiveData } = useTina({
|
||||
query: enResult.query,
|
||||
variables: enResult.variables,
|
||||
data: enResult.data,
|
||||
});
|
||||
|
||||
const { data: ukLiveData } = useTina({
|
||||
query: ukResult.query,
|
||||
variables: ukResult.variables,
|
||||
data: ukResult.data,
|
||||
});
|
||||
|
||||
// Apply design tokens from EN translations
|
||||
useEffect(() => {
|
||||
const design = (enLiveData as any).translationsEn?.design;
|
||||
if (!design) return;
|
||||
const s = document.documentElement.style;
|
||||
if (design.colorPrimary) s.setProperty('--orange-100', design.colorPrimary);
|
||||
if (design.colorBackground) s.setProperty('--dark-grey-100', design.colorBackground);
|
||||
if (design.colorAccent) s.setProperty('--dark-teal-100', design.colorAccent);
|
||||
if (design.colorYellow) s.setProperty('--yellow-100', design.colorYellow);
|
||||
if (design.colorText) s.setProperty('--light-grey-100', design.colorText);
|
||||
}, [enLiveData]);
|
||||
|
||||
// Sync live translation data back to parent
|
||||
useEffect(() => {
|
||||
onSync(enLiveData, ukLiveData);
|
||||
}, [enLiveData, ukLiveData, onSync]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(getInitialLang);
|
||||
const [enResult, setEnResult] = useState<TinaQueryResult | null>(null);
|
||||
const [ukResult, setUkResult] = useState<TinaQueryResult | null>(null);
|
||||
const [liveTranslations, setLiveTranslations] = useState<Record<Lang, Translations> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
client.queries.translationsEn({ relativePath: 'en.json' })
|
||||
.then(r => setEnResult(r as unknown as TinaQueryResult))
|
||||
.catch(() => {});
|
||||
client.queries.translationsUk({ relativePath: 'uk.json' })
|
||||
.then(r => setUkResult(r as unknown as TinaQueryResult))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setLang = useCallback((newLang: Lang) => {
|
||||
setLangState(newLang);
|
||||
try {
|
||||
localStorage.setItem('aimpress_lang', newLang);
|
||||
} catch {}
|
||||
try { localStorage.setItem('aimpress_lang', newLang); } catch {}
|
||||
document.documentElement.lang = newLang;
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: TranslationKey): string => {
|
||||
return translations[lang][key] ?? translations.en[key] ?? key;
|
||||
},
|
||||
[lang]
|
||||
);
|
||||
|
||||
// Set initial html lang attribute
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
// Stable callback — uses ref to avoid re-creating TinaLiveSync's effect deps
|
||||
const setLiveRef = useRef(setLiveTranslations);
|
||||
setLiveRef.current = setLiveTranslations;
|
||||
|
||||
const handleSync = useCallback((enData: unknown, ukData: unknown) => {
|
||||
setLiveRef.current({
|
||||
en: flattenObject((enData as any).translationsEn ?? {}) as unknown as Translations,
|
||||
uk: flattenObject((ukData as any).translationsUk ?? {}) as unknown as Translations,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const translations = liveTranslations ?? staticTranslations;
|
||||
|
||||
const t = useCallback(
|
||||
(key: TranslationKey): string => translations[lang][key] ?? translations.en[key] ?? key,
|
||||
[lang, translations]
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>
|
||||
{/* TinaLiveSync is null-rendering — mounts/unmounts without affecting children */}
|
||||
{enResult && ukResult && (
|
||||
<TinaLiveSync
|
||||
enResult={enResult}
|
||||
ukResult={ukResult}
|
||||
onSync={handleSync}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -158,6 +158,21 @@ const AboutPage = () => {
|
|||
},
|
||||
})}</script>
|
||||
</Helmet>
|
||||
<Helmet>
|
||||
<script type="application/ld+json">{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: 'Vadym Samoilenko',
|
||||
jobTitle: 'CEO & Founder',
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'AImpress Ltd',
|
||||
url: 'https://ai-impress.com',
|
||||
},
|
||||
image: 'https://ai-impress.com/founder-vadym.png',
|
||||
sameAs: ['https://www.linkedin.com/in/vadym-samoilenko/'],
|
||||
})}</script>
|
||||
</Helmet>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="about-hero">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import SEO from '../components/SEO';
|
||||
import { useTranslation } from '../i18n';
|
||||
import type { BlogPostFull } from '../types/blog';
|
||||
|
|
@ -72,6 +73,33 @@ const BlogPostPage: React.FC = () => {
|
|||
image={post.coverImage || undefined}
|
||||
type="article"
|
||||
/>
|
||||
<Helmet>
|
||||
<script type="application/ld+json">{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
description: post.excerpt || post.title,
|
||||
datePublished: post.date,
|
||||
image: post.coverImage || 'https://ai-impress.com/logo/webclip-256x256.png',
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'AImpress Ltd',
|
||||
url: 'https://ai-impress.com',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'AImpress Ltd',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://ai-impress.com/logo/webclip-256x256.png',
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://ai-impress.com/blog/${slug}`,
|
||||
},
|
||||
})}</script>
|
||||
</Helmet>
|
||||
<Link to="/blog" className="blog-post-back">{t('blogPost.back')}</Link>
|
||||
|
||||
{post.coverImage && (
|
||||
|
|
|
|||
33
src/pages/DynamicPage.css
Normal file
33
src/pages/DynamicPage.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
.dynamic-page {
|
||||
min-height: 60vh;
|
||||
padding-top: 80px;
|
||||
}
|
||||
.dynamic-page-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
.dynamic-page-loading,
|
||||
.dynamic-page-not-found {
|
||||
color: var(--light-grey-100);
|
||||
font-family: var(--font-primary);
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.dynamic-page-not-found {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dynamic-page-back {
|
||||
display: inline-block;
|
||||
color: var(--orange-100);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-primary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 24px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.dynamic-page-back:hover { opacity: 0.7; }
|
||||
.dynamic-page-blocks {
|
||||
width: 100%;
|
||||
}
|
||||
58
src/pages/DynamicPage.tsx
Normal file
58
src/pages/DynamicPage.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import SEO from '../components/SEO';
|
||||
import BlockRenderer from '../components/blocks/BlockRenderer';
|
||||
import type { PageData } from '../types/pages';
|
||||
import './DynamicPage.css';
|
||||
|
||||
const DynamicPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [page, setPage] = useState<PageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
fetch(`/pages/${slug}.json`)
|
||||
.then(r => { if (!r.ok) throw new Error('not found'); return r.json(); })
|
||||
.then((data: PageData) => {
|
||||
if (!data.published) throw new Error('not published');
|
||||
setPage(data);
|
||||
})
|
||||
.catch(() => setNotFound(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
if (loading) return <main className="dynamic-page"><div className="dynamic-page-container"><p className="dynamic-page-loading">Loading...</p></div></main>;
|
||||
|
||||
if (notFound || !page) return (
|
||||
<main className="dynamic-page">
|
||||
<div className="dynamic-page-container">
|
||||
<Link to="/" className="dynamic-page-back">← Back to Home</Link>
|
||||
<h1 className="dynamic-page-not-found">Page not found</h1>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="dynamic-page">
|
||||
<SEO
|
||||
title={page.seo?.title || `${page.title} | AImpress`}
|
||||
description={page.seo?.description || page.title}
|
||||
url={`https://ai-impress.com/p/${slug}`}
|
||||
/>
|
||||
<motion.div
|
||||
className="dynamic-page-blocks"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{(page.blocks || []).map((block, i) => (
|
||||
<BlockRenderer key={i} block={block} />
|
||||
))}
|
||||
</motion.div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
export default DynamicPage;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { Helmet } from 'react-helmet-async';
|
||||
import SEO from '../components/SEO';
|
||||
import Hero from '../components/Hero';
|
||||
import Benefits from '../components/Benefits';
|
||||
|
|
@ -14,6 +15,46 @@ const HomePage: React.FC = () => {
|
|||
return (
|
||||
<main>
|
||||
<SEO />
|
||||
<Helmet>
|
||||
<script type="application/ld+json">{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: 'How AImpress Automates Your Business',
|
||||
description: 'Our proven 5-step process to implement AI and automation solutions for SMEs.',
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 1,
|
||||
name: 'Challenge Briefing',
|
||||
text: 'We start with a discovery call to understand your business challenges, goals, and current workflows.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 2,
|
||||
name: 'Process Audit',
|
||||
text: 'We map your existing processes to identify automation opportunities and calculate potential ROI.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 3,
|
||||
name: 'Solution Design',
|
||||
text: 'We design a tailored automation architecture using AI tools and platforms best suited to your needs.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 4,
|
||||
name: 'Implementation',
|
||||
text: 'We build and integrate your automation solutions, with full testing and quality assurance.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 5,
|
||||
name: 'Scaling & Optimisation',
|
||||
text: 'We monitor performance, train your team, and continuously optimise your automations for maximum impact.',
|
||||
},
|
||||
],
|
||||
})}</script>
|
||||
</Helmet>
|
||||
<Hero />
|
||||
<Benefits />
|
||||
<Banner1 />
|
||||
|
|
|
|||
|
|
@ -140,6 +140,20 @@ const PricingPage = () => {
|
|||
},
|
||||
})}</script>
|
||||
</Helmet>
|
||||
<Helmet>
|
||||
<script type="application/ld+json">{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faq.map(item => ({
|
||||
'@type': 'Question',
|
||||
name: item.q,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.a,
|
||||
},
|
||||
})),
|
||||
})}</script>
|
||||
</Helmet>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="pricing-hero">
|
||||
|
|
|
|||
96
src/types/pages.ts
Normal file
96
src/types/pages.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export interface BlockHero {
|
||||
_template: 'hero';
|
||||
headline?: string;
|
||||
subtext?: string;
|
||||
ctaText?: string;
|
||||
ctaUrl?: string;
|
||||
backgroundStyle?: 'dark' | 'teal' | 'gradient';
|
||||
}
|
||||
export interface BlockTextBlock {
|
||||
_template: 'textBlock';
|
||||
content?: unknown; // TinaCMS rich-text AST
|
||||
width?: 'full' | 'narrow';
|
||||
}
|
||||
export interface BlockTwoColumn {
|
||||
_template: 'twoColumn';
|
||||
leftType?: 'text' | 'image';
|
||||
leftText?: unknown;
|
||||
leftImage?: string;
|
||||
rightType?: 'text' | 'image';
|
||||
rightText?: unknown;
|
||||
rightImage?: string;
|
||||
reverseOnMobile?: boolean;
|
||||
}
|
||||
export interface BlockFeatures {
|
||||
_template: 'features';
|
||||
title?: string;
|
||||
items?: Array<{ icon?: string; title?: string; description?: string }>;
|
||||
}
|
||||
export interface BlockStats {
|
||||
_template: 'stats';
|
||||
items?: Array<{ value?: string; label?: string }>;
|
||||
}
|
||||
export interface BlockTestimonials {
|
||||
_template: 'testimonials';
|
||||
title?: string;
|
||||
items?: Array<{ quote?: string; author?: string; role?: string; company?: string }>;
|
||||
}
|
||||
export interface BlockTeam {
|
||||
_template: 'team';
|
||||
title?: string;
|
||||
items?: Array<{ name?: string; role?: string; bio?: string; photo?: string }>;
|
||||
}
|
||||
export interface BlockFAQ {
|
||||
_template: 'faq';
|
||||
title?: string;
|
||||
items?: Array<{ question?: string; answer?: string }>;
|
||||
}
|
||||
export interface BlockCTABanner {
|
||||
_template: 'ctaBanner';
|
||||
headline?: string;
|
||||
subtext?: string;
|
||||
btnText?: string;
|
||||
btnUrl?: string;
|
||||
style?: 'orange' | 'teal';
|
||||
}
|
||||
export interface BlockVideo {
|
||||
_template: 'video';
|
||||
youtubeUrl?: string;
|
||||
caption?: string;
|
||||
}
|
||||
export interface BlockGallery {
|
||||
_template: 'gallery';
|
||||
images?: Array<{ src?: string; caption?: string; alt?: string }>;
|
||||
}
|
||||
export interface BlockPricing {
|
||||
_template: 'pricing';
|
||||
title?: string;
|
||||
plans?: Array<{ name?: string; price?: string; period?: string; features?: string[]; highlighted?: boolean }>;
|
||||
}
|
||||
export interface BlockTimeline {
|
||||
_template: 'timeline';
|
||||
title?: string;
|
||||
steps?: Array<{ title?: string; description?: string; duration?: string }>;
|
||||
}
|
||||
export interface BlockDivider {
|
||||
_template: 'divider';
|
||||
type?: 'line' | 'space';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
export interface BlockContactForm {
|
||||
_template: 'contactForm';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export type PageBlock =
|
||||
| BlockHero | BlockTextBlock | BlockTwoColumn | BlockFeatures | BlockStats
|
||||
| BlockTestimonials | BlockTeam | BlockFAQ | BlockCTABanner | BlockVideo
|
||||
| BlockGallery | BlockPricing | BlockTimeline | BlockDivider | BlockContactForm;
|
||||
|
||||
export interface PageData {
|
||||
title: string;
|
||||
published: boolean;
|
||||
seo?: { title?: string; description?: string };
|
||||
blocks?: PageBlock[];
|
||||
}
|
||||
1
tina/__generated__/_graphql.json
generated
Normal file
1
tina/__generated__/_graphql.json
generated
Normal file
File diff suppressed because one or more lines are too long
1
tina/__generated__/_lookup.json
generated
Normal file
1
tina/__generated__/_lookup.json
generated
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"DocumentConnection":{"type":"DocumentConnection","resolveType":"multiCollectionDocumentList","collections":["translationsEn","translationsUk","blogPost","pages"]},"Node":{"type":"Node","resolveType":"nodeDocument"},"DocumentNode":{"type":"DocumentNode","resolveType":"multiCollectionDocument","createDocument":"create","updateDocument":"update"},"TranslationsEn":{"type":"TranslationsEn","resolveType":"collectionDocument","collection":"translationsEn","createTranslationsEn":"create","updateTranslationsEn":"update"},"TranslationsEnConnection":{"type":"TranslationsEnConnection","resolveType":"collectionDocumentList","collection":"translationsEn"},"TranslationsUk":{"type":"TranslationsUk","resolveType":"collectionDocument","collection":"translationsUk","createTranslationsUk":"create","updateTranslationsUk":"update"},"TranslationsUkConnection":{"type":"TranslationsUkConnection","resolveType":"collectionDocumentList","collection":"translationsUk"},"BlogPost":{"type":"BlogPost","resolveType":"collectionDocument","collection":"blogPost","createBlogPost":"create","updateBlogPost":"update"},"BlogPostConnection":{"type":"BlogPostConnection","resolveType":"collectionDocumentList","collection":"blogPost"},"PagesBlocks":{"type":"PagesBlocks","resolveType":"unionData","typeMap":{"hero":"PagesBlocksHero","textBlock":"PagesBlocksTextBlock","twoColumn":"PagesBlocksTwoColumn","features":"PagesBlocksFeatures","stats":"PagesBlocksStats","testimonials":"PagesBlocksTestimonials","team":"PagesBlocksTeam","faq":"PagesBlocksFaq","ctaBanner":"PagesBlocksCtaBanner","video":"PagesBlocksVideo","gallery":"PagesBlocksGallery","pricing":"PagesBlocksPricing","timeline":"PagesBlocksTimeline","divider":"PagesBlocksDivider","contactForm":"PagesBlocksContactForm"}},"Pages":{"type":"Pages","resolveType":"collectionDocument","collection":"pages","createPages":"create","updatePages":"update"},"PagesConnection":{"type":"PagesConnection","resolveType":"collectionDocumentList","collection":"pages"}}
|
||||
1
tina/__generated__/_schema.json
generated
Normal file
1
tina/__generated__/_schema.json
generated
Normal file
File diff suppressed because one or more lines are too long
834
tina/__generated__/config.prebuild.jsx
generated
Normal file
834
tina/__generated__/config.prebuild.jsx
generated
Normal file
|
|
@ -0,0 +1,834 @@
|
|||
// tina/config.ts
|
||||
import { defineConfig } from "tinacms";
|
||||
function stringField(name, label) {
|
||||
return { name, label, type: "string" };
|
||||
}
|
||||
function textareaField(name, label) {
|
||||
return { name, label, type: "string", ui: { component: "textarea" } };
|
||||
}
|
||||
function objectField(name, label, fields) {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: "object",
|
||||
ui: { allowedActions: { create: false, delete: false } },
|
||||
fields
|
||||
};
|
||||
}
|
||||
function pricingItemField(name, label, extraFields = []) {
|
||||
return objectField(name, label, [
|
||||
stringField("_", "Name"),
|
||||
...extraFields
|
||||
]);
|
||||
}
|
||||
function colorField(name, label) {
|
||||
return { name, label, type: "string", ui: { component: "color", colorFormat: "hex" } };
|
||||
}
|
||||
function designFields() {
|
||||
return [
|
||||
{
|
||||
name: "design",
|
||||
label: "Design Tokens",
|
||||
type: "object",
|
||||
ui: { allowedActions: { create: false, delete: false } },
|
||||
fields: [
|
||||
colorField("colorPrimary", "Primary Color (Orange)"),
|
||||
colorField("colorBackground", "Background Color (Dark)"),
|
||||
colorField("colorAccent", "Accent Color (Teal)"),
|
||||
colorField("colorYellow", "Yellow Accent"),
|
||||
colorField("colorText", "Text Color")
|
||||
]
|
||||
},
|
||||
...translationFields()
|
||||
];
|
||||
}
|
||||
function translationFields() {
|
||||
return [
|
||||
objectField("header", "Header", [
|
||||
objectField("nav", "Navigation", [
|
||||
stringField("home", "Home"),
|
||||
stringField("about", "About"),
|
||||
stringField("services", "Services"),
|
||||
stringField("pricing", "Pricing"),
|
||||
stringField("blog", "Blog"),
|
||||
stringField("contacts", "Contacts")
|
||||
]),
|
||||
objectField("lang", "Language Labels", [
|
||||
stringField("en", "English"),
|
||||
stringField("uk", "Ukrainian")
|
||||
]),
|
||||
stringField("login", "Login Button"),
|
||||
objectField("loginModal", "Login Modal", [
|
||||
stringField("title", "Title"),
|
||||
stringField("emailLabel", "Email Label"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("passwordLabel", "Password Label"),
|
||||
stringField("passwordPlaceholder", "Password Placeholder"),
|
||||
stringField("submit", "Submit Button"),
|
||||
stringField("signupPrompt", "Signup Prompt"),
|
||||
stringField("signupLink", "Signup Link")
|
||||
])
|
||||
]),
|
||||
objectField("hero", "Hero Section", [
|
||||
stringField("circle1", "Rotating Text 1"),
|
||||
stringField("circle2", "Rotating Text 2"),
|
||||
stringField("circle3", "Rotating Text 3"),
|
||||
textareaField("title", "Headline"),
|
||||
stringField("cta", "CTA Button")
|
||||
]),
|
||||
objectField("benefits", "Benefits Section", [
|
||||
objectField("card1", "Card 1", [
|
||||
stringField("front", "Front"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("back", "Back")
|
||||
]),
|
||||
objectField("card2", "Card 2", [
|
||||
stringField("front", "Front"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("back", "Back")
|
||||
]),
|
||||
objectField("card3", "Card 3", [
|
||||
stringField("front", "Front"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("back", "Back")
|
||||
]),
|
||||
stringField("builtTitle", "Built Title"),
|
||||
textareaField("builtDesc", "Built Description"),
|
||||
objectField("static1", "Static 1", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
objectField("static2", "Static 2", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
objectField("static3", "Static 3", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
objectField("static4", "Static 4", [stringField("title", "Title"), stringField("desc", "Desc")])
|
||||
]),
|
||||
objectField("banner1", "Banner 1 (Quiz)", [
|
||||
stringField("q1", "Question 1"),
|
||||
stringField("q2", "Question 2"),
|
||||
stringField("q3", "Question 3"),
|
||||
stringField("cta", "CTA Button")
|
||||
]),
|
||||
objectField("realResults", "Real Results Section", [
|
||||
stringField("title", "Title"),
|
||||
objectField("card1", "Card 1 \u2014 AutoBrat Garage", [
|
||||
stringField("title", "Title"),
|
||||
stringField("resultsLabel", "Results Label"),
|
||||
textareaField("desc", "Description"),
|
||||
stringField("stat1", "Stat 1"),
|
||||
stringField("stat2", "Stat 2"),
|
||||
stringField("stat3", "Stat 3")
|
||||
]),
|
||||
objectField("card2", "Card 2 \u2014 Cotswolld Honey", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("desc", "Description"),
|
||||
stringField("stat1", "Stat 1"),
|
||||
stringField("stat2", "Stat 2")
|
||||
]),
|
||||
objectField("card3", "Card 3 \u2014 Wcounting", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("desc", "Description"),
|
||||
stringField("stat1", "Stat 1"),
|
||||
stringField("stat2", "Stat 2")
|
||||
])
|
||||
]),
|
||||
objectField("timeline", "Timeline Section", [
|
||||
stringField("title", "Title"),
|
||||
objectField("step1", "Step 1", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step2", "Step 2", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step3", "Step 3", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step4", "Step 4", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step5", "Step 5", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")])
|
||||
]),
|
||||
objectField("banner2", "Banner 2", [
|
||||
stringField("cta", "CTA Button")
|
||||
]),
|
||||
objectField("comparison", "Comparison Table", [
|
||||
stringField("title", "Title"),
|
||||
stringField("aiLabel", "AI Label"),
|
||||
objectField("metric1", "Metric 1 \u2014 Cost", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("metric2", "Metric 2 \u2014 Speed", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("metric3", "Metric 3 \u2014 Availability", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("metric4", "Metric 4 \u2014 Scalability", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
stringField("altHeading", "Alternatives Heading"),
|
||||
stringField("alt1", "Alternative 1"),
|
||||
stringField("alt2", "Alternative 2"),
|
||||
textareaField("footer", "Footer Text"),
|
||||
stringField("cta", "CTA Button")
|
||||
]),
|
||||
objectField("blogSection", "Blog Section (Homepage)", [
|
||||
stringField("title", "Title"),
|
||||
stringField("readMore", "Read More Link"),
|
||||
stringField("viewAll", "View All Link")
|
||||
]),
|
||||
objectField("resources", "Resources Section", [
|
||||
stringField("title", "Title")
|
||||
]),
|
||||
objectField("contactSection", "Contact Section", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("subtitle", "Subtitle")
|
||||
]),
|
||||
objectField("contactForm", "Contact Form", [
|
||||
stringField("title", "Title"),
|
||||
stringField("fullName", "Full Name Label"),
|
||||
stringField("fullNamePlaceholder", "Full Name Placeholder"),
|
||||
stringField("jobTitle", "Job Title Label"),
|
||||
stringField("jobTitlePlaceholder", "Job Title Placeholder"),
|
||||
stringField("email", "Email Label"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("need", "Need Label"),
|
||||
stringField("needPlaceholder", "Need Placeholder"),
|
||||
stringField("company", "Company Label"),
|
||||
stringField("companyPlaceholder", "Company Placeholder"),
|
||||
stringField("phone", "Phone Label"),
|
||||
stringField("phonePlaceholder", "Phone Placeholder"),
|
||||
stringField("submit", "Submit Button"),
|
||||
stringField("sending", "Sending State"),
|
||||
stringField("error", "Error Message"),
|
||||
stringField("successTitle", "Success Title"),
|
||||
textareaField("successText", "Success Text"),
|
||||
stringField("sendAnother", "Send Another Button")
|
||||
]),
|
||||
objectField("footer", "Footer", [
|
||||
stringField("privacy", "Privacy Link"),
|
||||
stringField("terms", "Terms Link"),
|
||||
stringField("copyright", "Copyright")
|
||||
]),
|
||||
objectField("cookie", "Cookie Consent", [
|
||||
textareaField("text", "Cookie Text"),
|
||||
stringField("privacyLink", "Privacy Link Text"),
|
||||
stringField("reject", "Reject Button"),
|
||||
stringField("accept", "Accept Button")
|
||||
]),
|
||||
objectField("chat", "Chat Widget", [
|
||||
stringField("greeting", "Greeting"),
|
||||
stringField("openChat", "Open Chat"),
|
||||
stringField("headerTitle", "Header Title"),
|
||||
stringField("status", "Status"),
|
||||
stringField("clearChat", "Clear Chat"),
|
||||
stringField("closeChat", "Close Chat"),
|
||||
textareaField("welcome", "Welcome Message"),
|
||||
objectField("lead", "Lead Form", [
|
||||
stringField("title", "Title"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("intro", "Intro"),
|
||||
stringField("namePlaceholder", "Name Placeholder"),
|
||||
stringField("nameError", "Name Error"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("emailError", "Email Error"),
|
||||
stringField("emailInvalid", "Email Invalid"),
|
||||
stringField("companyPlaceholder", "Company Placeholder"),
|
||||
textareaField("consent", "Consent Text"),
|
||||
stringField("privacyLink", "Privacy Link Text"),
|
||||
stringField("consentError", "Consent Error"),
|
||||
stringField("submit", "Submit Button")
|
||||
]),
|
||||
stringField("inputPlaceholder", "Input Placeholder"),
|
||||
stringField("send", "Send Button")
|
||||
]),
|
||||
objectField("quoteForm", "Quote Form", [
|
||||
stringField("title", "Title"),
|
||||
stringField("fullName", "Full Name Label"),
|
||||
stringField("fullNamePlaceholder", "Full Name Placeholder"),
|
||||
stringField("jobTitle", "Job Title Label"),
|
||||
stringField("jobTitlePlaceholder", "Job Title Placeholder"),
|
||||
stringField("email", "Email Label"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("phone", "Phone Label"),
|
||||
stringField("phonePlaceholder", "Phone Placeholder"),
|
||||
stringField("company", "Company Label"),
|
||||
stringField("companyPlaceholder", "Company Placeholder"),
|
||||
stringField("service", "Service Label"),
|
||||
stringField("serviceDefault", "Service Default"),
|
||||
stringField("service1", "Service 1"),
|
||||
stringField("service2", "Service 2"),
|
||||
stringField("service3", "Service 3"),
|
||||
stringField("service4", "Service 4"),
|
||||
stringField("service5", "Service 5"),
|
||||
stringField("service6", "Service 6"),
|
||||
stringField("service7", "Service 7"),
|
||||
stringField("service8", "Service 8"),
|
||||
stringField("service9", "Service 9"),
|
||||
stringField("description", "Description Label"),
|
||||
textareaField("descriptionPlaceholder", "Description Placeholder"),
|
||||
stringField("submit", "Submit Button"),
|
||||
stringField("sending", "Sending State"),
|
||||
stringField("error", "Error Message"),
|
||||
stringField("successTitle", "Success Title"),
|
||||
textareaField("successText", "Success Text"),
|
||||
stringField("sendAnother", "Send Another Button")
|
||||
]),
|
||||
objectField("about", "About Page", [
|
||||
objectField("hero", "Hero", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
objectField("story", "Our Story", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("p1", "Paragraph 1"),
|
||||
textareaField("p2", "Paragraph 2"),
|
||||
textareaField("p3", "Paragraph 3")
|
||||
]),
|
||||
objectField("diff", "What Makes Us Different", [stringField("title", "Title")]),
|
||||
objectField("diff1", "Differentiator 1", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("diff2", "Differentiator 2", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("diff3", "Differentiator 3", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("diff4", "Differentiator 4", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("values", "Values", [stringField("title", "Title")]),
|
||||
objectField("val1", "Value 1", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val2", "Value 2", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val3", "Value 3", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val4", "Value 4", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val5", "Value 5", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("founder", "Founder", [
|
||||
stringField("title", "Section Title"),
|
||||
stringField("name", "Name"),
|
||||
stringField("role", "Role"),
|
||||
stringField("bgLabel", "Background Label"),
|
||||
textareaField("bgText", "Background Text"),
|
||||
stringField("certLabel", "Certifications Label"),
|
||||
textareaField("certText", "Certifications Text"),
|
||||
stringField("analyticsLabel", "Analytics Label"),
|
||||
textareaField("analyticsText", "Analytics Text"),
|
||||
stringField("eduLabel", "Education Label"),
|
||||
textareaField("eduText", "Education Text"),
|
||||
stringField("visionLabel", "Vision Label"),
|
||||
textareaField("visionText", "Vision Text")
|
||||
]),
|
||||
objectField("industries", "Industries", [stringField("title", "Title")]),
|
||||
objectField("ind1", "Industry 1", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind2", "Industry 2", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind3", "Industry 3", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind4", "Industry 4", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind5", "Industry 5", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind6", "Industry 6", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind7", "Industry 7", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("cta", "CTA", [stringField("title", "Title"), textareaField("subtitle", "Subtitle"), stringField("button", "Button")])
|
||||
]),
|
||||
objectField("services", "Services Page", [
|
||||
objectField("hero", "Hero", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
objectField("s1", "Service 1 \u2014 AI Chatbots", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"), stringField("f6", "F6")]),
|
||||
objectField("s2", "Service 2 \u2014 Website Dev", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"), stringField("f6", "F6")]),
|
||||
objectField("s3", "Service 3 \u2014 Workflow Automation", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s4", "Service 4 \u2014 System Integration", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s5", "Service 5 \u2014 CRM", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s6", "Service 6 \u2014 Marketing Automation", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s7", "Service 7 \u2014 AI Integration", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s8", "Service 8 \u2014 Infrastructure", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
stringField("popular", "Popular Badge"),
|
||||
stringField("whatsIncluded", "What's Included"),
|
||||
stringField("showLess", "Show Less"),
|
||||
objectField("assurance", "Assurance Pack", [
|
||||
stringField("title", "Title"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
stringField("i1", "Item 1"),
|
||||
stringField("i2", "Item 2"),
|
||||
stringField("i3", "Item 3"),
|
||||
stringField("i4", "Item 4"),
|
||||
stringField("i5", "Item 5"),
|
||||
stringField("i6", "Item 6"),
|
||||
stringField("i7", "Item 7"),
|
||||
stringField("i8", "Item 8"),
|
||||
stringField("i9", "Item 9")
|
||||
]),
|
||||
objectField("metrics", "Metrics", [
|
||||
stringField("title", "Title"),
|
||||
stringField("v1", "Value 1"),
|
||||
stringField("l1", "Label 1"),
|
||||
stringField("v2", "Value 2"),
|
||||
stringField("l2", "Label 2"),
|
||||
stringField("v3", "Value 3"),
|
||||
stringField("l3", "Label 3"),
|
||||
stringField("v4", "Value 4"),
|
||||
stringField("l4", "Label 4")
|
||||
]),
|
||||
objectField("selector", "Service Selector", [
|
||||
stringField("title", "Title"),
|
||||
stringField("step1", "Step 1"),
|
||||
stringField("step2", "Step 2"),
|
||||
stringField("step3", "Step 3"),
|
||||
stringField("goalQ", "Goal Question"),
|
||||
stringField("goal1", "Goal 1"),
|
||||
stringField("goal2", "Goal 2"),
|
||||
stringField("goal3", "Goal 3"),
|
||||
stringField("goal4", "Goal 4"),
|
||||
stringField("goal5", "Goal 5"),
|
||||
stringField("budgetQ", "Budget Question"),
|
||||
stringField("budget1", "Budget 1"),
|
||||
stringField("budget2", "Budget 2"),
|
||||
stringField("budget3", "Budget 3"),
|
||||
stringField("resultsHeading", "Results Heading"),
|
||||
textareaField("noMatch", "No Match"),
|
||||
stringField("viewButton", "View Button"),
|
||||
stringField("resetButton", "Reset Button"),
|
||||
stringField("backButton", "Back Button")
|
||||
]),
|
||||
objectField("bundles", "Bundles", [stringField("title", "Title")]),
|
||||
objectField("bundle1", "Bundle 1 \u2014 Starter", [stringField("name", "Name"), stringField("tagline", "Tagline"), stringField("price", "Price")]),
|
||||
objectField("bundle2", "Bundle 2 \u2014 Growth", [stringField("name", "Name"), stringField("tagline", "Tagline"), stringField("price", "Price"), stringField("badge", "Badge")]),
|
||||
objectField("bundle3", "Bundle 3 \u2014 Full Stack", [stringField("name", "Name"), stringField("tagline", "Tagline"), stringField("price", "Price")]),
|
||||
objectField("bundle", "Bundle CTA", [stringField("cta", "CTA Button")]),
|
||||
objectField("cta", "Bottom CTA", [stringField("title", "Title"), textareaField("subtitle", "Subtitle"), stringField("button", "Button")])
|
||||
]),
|
||||
objectField("pricing", "Pricing Page", [
|
||||
objectField("hero", "Hero", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
objectField("impl", "Implementation Pricing", [
|
||||
stringField("title", "Section Title"),
|
||||
pricingItemField("s1", "Service 1", [stringField("price", "Price")]),
|
||||
pricingItemField("s2", "Service 2", [stringField("price", "Price")]),
|
||||
pricingItemField("s3", "Service 3", [stringField("price", "Price")]),
|
||||
pricingItemField("s4", "Service 4", [stringField("price", "Price")]),
|
||||
pricingItemField("s5", "Service 5", [stringField("price", "Price")]),
|
||||
pricingItemField("s6", "Service 6", [stringField("price", "Price")]),
|
||||
pricingItemField("s7", "Service 7", [stringField("price", "Price")]),
|
||||
pricingItemField("s8", "Service 8", [stringField("price", "Price")])
|
||||
]),
|
||||
stringField("popular", "Popular Badge"),
|
||||
objectField("retainers", "Retainers", [stringField("title", "Title")]),
|
||||
objectField("ret1", "Retainer \u2014 Essential", [
|
||||
stringField("name", "Name"),
|
||||
stringField("price", "Price"),
|
||||
stringField("period", "Period"),
|
||||
stringField("hours", "Hours"),
|
||||
stringField("sla", "SLA"),
|
||||
stringField("f1", "F1"),
|
||||
stringField("f2", "F2"),
|
||||
stringField("f3", "F3"),
|
||||
stringField("f4", "F4"),
|
||||
stringField("f5", "F5")
|
||||
]),
|
||||
objectField("ret2", "Retainer \u2014 Professional", [
|
||||
stringField("name", "Name"),
|
||||
stringField("price", "Price"),
|
||||
stringField("hours", "Hours"),
|
||||
stringField("sla", "SLA"),
|
||||
stringField("badge", "Badge"),
|
||||
stringField("f1", "F1"),
|
||||
stringField("f2", "F2"),
|
||||
stringField("f3", "F3"),
|
||||
stringField("f4", "F4"),
|
||||
stringField("f5", "F5"),
|
||||
stringField("f6", "F6")
|
||||
]),
|
||||
objectField("ret3", "Retainer \u2014 Enterprise", [
|
||||
stringField("name", "Name"),
|
||||
stringField("price", "Price"),
|
||||
stringField("hours", "Hours"),
|
||||
stringField("sla", "SLA"),
|
||||
stringField("f1", "F1"),
|
||||
stringField("f2", "F2"),
|
||||
stringField("f3", "F3"),
|
||||
stringField("f4", "F4"),
|
||||
stringField("f5", "F5"),
|
||||
stringField("f6", "F6"),
|
||||
stringField("f7", "F7")
|
||||
]),
|
||||
objectField("training", "Training & Workshops", [
|
||||
stringField("title", "Title"),
|
||||
pricingItemField("t1", "Training 1", [stringField("price", "Price"), stringField("desc", "Desc")]),
|
||||
pricingItemField("t2", "Training 2", [stringField("price", "Price")]),
|
||||
pricingItemField("t3", "Training 3", [stringField("price", "Price"), stringField("desc", "Desc")]),
|
||||
pricingItemField("t4", "Training 4", [stringField("price", "Price"), stringField("desc", "Desc")])
|
||||
]),
|
||||
objectField("payment", "Payment Terms", [
|
||||
stringField("title", "Title"),
|
||||
pricingItemField("r1", "Range 1", [stringField("split", "Split")]),
|
||||
pricingItemField("r2", "Range 2", [stringField("split", "Split")]),
|
||||
pricingItemField("r3", "Range 3", [stringField("split", "Split")]),
|
||||
pricingItemField("r4", "Range 4 (Public Sector)", [stringField("split", "Split")])
|
||||
]),
|
||||
objectField("discounts", "Discounts / Impact Grant", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("intro", "Intro"),
|
||||
pricingItemField("g1", "Group 1", [stringField("disc", "Discount")]),
|
||||
stringField("g2", "Group 2"),
|
||||
stringField("g3", "Group 3"),
|
||||
pricingItemField("g4", "Group 4", [stringField("disc", "Discount")]),
|
||||
stringField("g5", "Group 5")
|
||||
]),
|
||||
objectField("compare", "Comparison Table", [
|
||||
stringField("title", "Title"),
|
||||
stringField("aimpress", "AImpress Label"),
|
||||
stringField("agency", "Agency Label"),
|
||||
stringField("inhouse", "In-House Label"),
|
||||
objectField("r1", "Row 1", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r2", "Row 2", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r3", "Row 3", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r4", "Row 4", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r5", "Row 5", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency")]),
|
||||
objectField("r6", "Row 6", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")])
|
||||
]),
|
||||
objectField("faq", "FAQ", [
|
||||
stringField("title", "Title"),
|
||||
stringField("q1", "Q1"),
|
||||
textareaField("a1", "A1"),
|
||||
stringField("q2", "Q2"),
|
||||
textareaField("a2", "A2"),
|
||||
stringField("q3", "Q3"),
|
||||
textareaField("a3", "A3"),
|
||||
stringField("q4", "Q4"),
|
||||
textareaField("a4", "A4"),
|
||||
stringField("q5", "Q5"),
|
||||
textareaField("a5", "A5"),
|
||||
stringField("q6", "Q6"),
|
||||
textareaField("a6", "A6")
|
||||
]),
|
||||
objectField("cta", "Bottom CTA", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")])
|
||||
]),
|
||||
objectField("blog", "Blog Page", [
|
||||
stringField("title", "Title"),
|
||||
stringField("loading", "Loading"),
|
||||
stringField("noPosts", "No Posts"),
|
||||
stringField("readMore", "Read More")
|
||||
]),
|
||||
objectField("blogPost", "Blog Post Page", [
|
||||
stringField("back", "Back Link"),
|
||||
stringField("notFound", "Not Found"),
|
||||
stringField("loading", "Loading"),
|
||||
stringField("source", "Source Label")
|
||||
]),
|
||||
objectField("seo", "SEO Meta Tags", [
|
||||
objectField("home", "Home Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("about", "About Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("services", "Services Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("pricing", "Pricing Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("blog", "Blog Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
stringField("siteName", "Site Name")
|
||||
])
|
||||
];
|
||||
}
|
||||
var config_default = defineConfig({
|
||||
branch: process.env.GITHUB_REF_NAME ?? "main",
|
||||
clientId: process.env.TINA_PUBLIC_CLIENT_ID,
|
||||
token: process.env.TINA_TOKEN,
|
||||
build: {
|
||||
outputFolder: "admin",
|
||||
publicFolder: "public"
|
||||
},
|
||||
media: {
|
||||
tina: {
|
||||
mediaRoot: "",
|
||||
publicFolder: "public"
|
||||
}
|
||||
},
|
||||
search: {
|
||||
tina: {
|
||||
indexerToken: "49c8a879a1b8028c7f720c5d497c0bbab9ea69f6",
|
||||
stopwordLanguages: ["eng"]
|
||||
},
|
||||
indexBatchSize: 100,
|
||||
maxSearchIndexFieldLength: 100
|
||||
},
|
||||
schema: {
|
||||
collections: [
|
||||
{
|
||||
name: "translationsEn",
|
||||
label: "Site Content (English)",
|
||||
path: "content/translations",
|
||||
match: { include: "en" },
|
||||
format: "json",
|
||||
ui: {
|
||||
allowedActions: { create: false, delete: false },
|
||||
global: true,
|
||||
router: () => "/"
|
||||
},
|
||||
fields: designFields()
|
||||
},
|
||||
{
|
||||
name: "translationsUk",
|
||||
label: "Site Content (Ukrainian)",
|
||||
path: "content/translations",
|
||||
match: { include: "uk" },
|
||||
format: "json",
|
||||
ui: {
|
||||
allowedActions: { create: false, delete: false },
|
||||
global: true,
|
||||
router: () => "/"
|
||||
},
|
||||
fields: translationFields()
|
||||
},
|
||||
{
|
||||
name: "blogPost",
|
||||
label: "Blog Posts",
|
||||
path: "content/blog",
|
||||
format: "md",
|
||||
ui: {
|
||||
router: ({ document }) => `/blog/${document._sys.filename}`,
|
||||
filename: {
|
||||
readonly: false,
|
||||
slugify: (values) => {
|
||||
const title = values.title;
|
||||
return title ? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) : "new-post";
|
||||
}
|
||||
},
|
||||
beforeSubmit: async ({ values }) => ({
|
||||
...values,
|
||||
date: values.date ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
})
|
||||
},
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Title", required: true, isTitle: true },
|
||||
{ name: "date", type: "datetime", label: "Date", required: true },
|
||||
{ name: "excerpt", type: "string", label: "Excerpt", ui: { component: "textarea" } },
|
||||
{ name: "coverImage", type: "image", label: "Cover Image" },
|
||||
{ name: "hashtags", type: "string", label: "Tags", list: true },
|
||||
{ name: "sourceTitle", type: "string", label: "Source Name" },
|
||||
{ name: "sourceUrl", type: "string", label: "Source URL" },
|
||||
{ name: "body", type: "rich-text", label: "Body", isBody: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "pages",
|
||||
label: "Pages",
|
||||
path: "content/pages",
|
||||
format: "json",
|
||||
ui: {
|
||||
router: ({ document }) => `/p/${document._sys.filename}`,
|
||||
filename: {
|
||||
readonly: false,
|
||||
slugify: (values) => {
|
||||
const title = values.title;
|
||||
return title ? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) : "new-page";
|
||||
}
|
||||
}
|
||||
},
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Page Title", required: true, isTitle: true },
|
||||
{ name: "published", type: "boolean", label: "Published (visible on site)" },
|
||||
{
|
||||
name: "seo",
|
||||
type: "object",
|
||||
label: "SEO",
|
||||
ui: { allowedActions: { create: false, delete: false } },
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "SEO Title" },
|
||||
{ name: "description", type: "string", label: "SEO Description", ui: { component: "textarea" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "blocks",
|
||||
type: "object",
|
||||
label: "Page Blocks",
|
||||
list: true,
|
||||
templates: [
|
||||
{
|
||||
name: "hero",
|
||||
label: "Hero",
|
||||
fields: [
|
||||
{ name: "headline", type: "string", label: "Headline" },
|
||||
{ name: "subtext", type: "string", label: "Subtext", ui: { component: "textarea" } },
|
||||
{ name: "ctaText", type: "string", label: "Button Text" },
|
||||
{ name: "ctaUrl", type: "string", label: "Button URL" },
|
||||
{ name: "backgroundStyle", type: "string", label: "Background", options: ["dark", "teal", "gradient"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "textBlock",
|
||||
label: "Text Block",
|
||||
fields: [
|
||||
{ name: "content", type: "rich-text", label: "Content" },
|
||||
{ name: "width", type: "string", label: "Width", options: ["full", "narrow"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "twoColumn",
|
||||
label: "Two Column",
|
||||
fields: [
|
||||
{ name: "leftType", type: "string", label: "Left Side Type", options: ["text", "image"] },
|
||||
{ name: "leftText", type: "rich-text", label: "Left Text" },
|
||||
{ name: "leftImage", type: "image", label: "Left Image" },
|
||||
{ name: "rightType", type: "string", label: "Right Side Type", options: ["text", "image"] },
|
||||
{ name: "rightText", type: "rich-text", label: "Right Text" },
|
||||
{ name: "rightImage", type: "image", label: "Right Image" },
|
||||
{ name: "reverseOnMobile", type: "boolean", label: "Reverse on Mobile" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "features",
|
||||
label: "Features Grid",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object",
|
||||
label: "Features",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.title || "Feature" }) },
|
||||
fields: [
|
||||
{ name: "icon", type: "string", label: "Icon (emoji)" },
|
||||
{ name: "title", type: "string", label: "Title" },
|
||||
{ name: "description", type: "string", label: "Description", ui: { component: "textarea" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "stats",
|
||||
label: "Stats",
|
||||
fields: [
|
||||
{
|
||||
name: "items",
|
||||
type: "object",
|
||||
label: "Stats",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.value || "Stat" }) },
|
||||
fields: [
|
||||
{ name: "value", type: "string", label: "Value (e.g. 95%)" },
|
||||
{ name: "label", type: "string", label: "Label" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "testimonials",
|
||||
label: "Testimonials",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object",
|
||||
label: "Testimonials",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.author || "Testimonial" }) },
|
||||
fields: [
|
||||
{ name: "quote", type: "string", label: "Quote", ui: { component: "textarea" } },
|
||||
{ name: "author", type: "string", label: "Author Name" },
|
||||
{ name: "role", type: "string", label: "Role" },
|
||||
{ name: "company", type: "string", label: "Company" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "team",
|
||||
label: "Team",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object",
|
||||
label: "Team Members",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.name || "Member" }) },
|
||||
fields: [
|
||||
{ name: "name", type: "string", label: "Name" },
|
||||
{ name: "role", type: "string", label: "Role" },
|
||||
{ name: "bio", type: "string", label: "Bio", ui: { component: "textarea" } },
|
||||
{ name: "photo", type: "image", label: "Photo" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "faq",
|
||||
label: "FAQ",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object",
|
||||
label: "Questions",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.question || "Question" }) },
|
||||
fields: [
|
||||
{ name: "question", type: "string", label: "Question" },
|
||||
{ name: "answer", type: "string", label: "Answer", ui: { component: "textarea" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "ctaBanner",
|
||||
label: "CTA Banner",
|
||||
fields: [
|
||||
{ name: "headline", type: "string", label: "Headline" },
|
||||
{ name: "subtext", type: "string", label: "Subtext", ui: { component: "textarea" } },
|
||||
{ name: "btnText", type: "string", label: "Button Text" },
|
||||
{ name: "btnUrl", type: "string", label: "Button URL" },
|
||||
{ name: "style", type: "string", label: "Style", options: ["orange", "teal"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "video",
|
||||
label: "Video",
|
||||
fields: [
|
||||
{ name: "youtubeUrl", type: "string", label: "YouTube URL" },
|
||||
{ name: "caption", type: "string", label: "Caption" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "gallery",
|
||||
label: "Image Gallery",
|
||||
fields: [
|
||||
{
|
||||
name: "images",
|
||||
type: "object",
|
||||
label: "Images",
|
||||
list: true,
|
||||
fields: [
|
||||
{ name: "src", type: "image", label: "Image" },
|
||||
{ name: "caption", type: "string", label: "Caption" },
|
||||
{ name: "alt", type: "string", label: "Alt Text" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "pricing",
|
||||
label: "Pricing",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Section Title" },
|
||||
{
|
||||
name: "plans",
|
||||
type: "object",
|
||||
label: "Plans",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.name || "Plan" }) },
|
||||
fields: [
|
||||
{ name: "name", type: "string", label: "Plan Name" },
|
||||
{ name: "price", type: "string", label: "Price (e.g. \xA3499)" },
|
||||
{ name: "period", type: "string", label: "Period (e.g. month)" },
|
||||
{ name: "features", type: "string", label: "Features", list: true },
|
||||
{ name: "highlighted", type: "boolean", label: "Highlighted (Popular)" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "timeline",
|
||||
label: "Timeline",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Section Title" },
|
||||
{
|
||||
name: "steps",
|
||||
type: "object",
|
||||
label: "Steps",
|
||||
list: true,
|
||||
ui: { itemProps: (item) => ({ label: item.title || "Step" }) },
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Step Title" },
|
||||
{ name: "description", type: "string", label: "Description", ui: { component: "textarea" } },
|
||||
{ name: "duration", type: "string", label: "Duration (e.g. Week 1-2)" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "divider",
|
||||
label: "Divider / Spacer",
|
||||
fields: [
|
||||
{ name: "type", type: "string", label: "Type", options: ["line", "space"] },
|
||||
{ name: "size", type: "string", label: "Size", options: ["small", "medium", "large"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "contactForm",
|
||||
label: "Contact Form",
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Title (optional)" },
|
||||
{ name: "subtitle", type: "string", label: "Subtitle (optional)", ui: { component: "textarea" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
export {
|
||||
config_default as default
|
||||
};
|
||||
797
tina/config.ts
Normal file
797
tina/config.ts
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
import { defineConfig, type TinaField } from "tinacms";
|
||||
|
||||
function stringField(name: string, label: string): TinaField {
|
||||
return { name, label, type: "string" };
|
||||
}
|
||||
|
||||
function textareaField(name: string, label: string): TinaField {
|
||||
return { name, label, type: "string", ui: { component: "textarea" } };
|
||||
}
|
||||
|
||||
function objectField(name: string, label: string, fields: TinaField[]): TinaField {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: "object",
|
||||
ui: { allowedActions: { create: false, delete: false } },
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
// For pricing entries that have both a value (_) and sub-keys (price, split, desc, disc)
|
||||
function pricingItemField(name: string, label: string, extraFields: TinaField[] = []): TinaField {
|
||||
return objectField(name, label, [
|
||||
stringField("_", "Name"),
|
||||
...extraFields,
|
||||
]);
|
||||
}
|
||||
|
||||
function colorField(name: string, label: string): TinaField {
|
||||
return { name, label, type: "string", ui: { component: "color", colorFormat: "hex" } as any };
|
||||
}
|
||||
|
||||
function designFields(): TinaField[] {
|
||||
return [
|
||||
{
|
||||
name: "design",
|
||||
label: "Design Tokens",
|
||||
type: "object",
|
||||
ui: { allowedActions: { create: false, delete: false } } as any,
|
||||
fields: [
|
||||
colorField("colorPrimary", "Primary Color (Orange)"),
|
||||
colorField("colorBackground", "Background Color (Dark)"),
|
||||
colorField("colorAccent", "Accent Color (Teal)"),
|
||||
colorField("colorYellow", "Yellow Accent"),
|
||||
colorField("colorText", "Text Color"),
|
||||
],
|
||||
} as TinaField,
|
||||
...translationFields(),
|
||||
];
|
||||
}
|
||||
|
||||
function translationFields(): TinaField[] {
|
||||
return [
|
||||
objectField("header", "Header", [
|
||||
objectField("nav", "Navigation", [
|
||||
stringField("home", "Home"),
|
||||
stringField("about", "About"),
|
||||
stringField("services", "Services"),
|
||||
stringField("pricing", "Pricing"),
|
||||
stringField("blog", "Blog"),
|
||||
stringField("contacts", "Contacts"),
|
||||
]),
|
||||
objectField("lang", "Language Labels", [
|
||||
stringField("en", "English"),
|
||||
stringField("uk", "Ukrainian"),
|
||||
]),
|
||||
stringField("login", "Login Button"),
|
||||
objectField("loginModal", "Login Modal", [
|
||||
stringField("title", "Title"),
|
||||
stringField("emailLabel", "Email Label"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("passwordLabel", "Password Label"),
|
||||
stringField("passwordPlaceholder", "Password Placeholder"),
|
||||
stringField("submit", "Submit Button"),
|
||||
stringField("signupPrompt", "Signup Prompt"),
|
||||
stringField("signupLink", "Signup Link"),
|
||||
]),
|
||||
]),
|
||||
objectField("hero", "Hero Section", [
|
||||
stringField("circle1", "Rotating Text 1"),
|
||||
stringField("circle2", "Rotating Text 2"),
|
||||
stringField("circle3", "Rotating Text 3"),
|
||||
textareaField("title", "Headline"),
|
||||
stringField("cta", "CTA Button"),
|
||||
]),
|
||||
objectField("benefits", "Benefits Section", [
|
||||
objectField("card1", "Card 1", [
|
||||
stringField("front", "Front"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("back", "Back"),
|
||||
]),
|
||||
objectField("card2", "Card 2", [
|
||||
stringField("front", "Front"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("back", "Back"),
|
||||
]),
|
||||
objectField("card3", "Card 3", [
|
||||
stringField("front", "Front"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("back", "Back"),
|
||||
]),
|
||||
stringField("builtTitle", "Built Title"),
|
||||
textareaField("builtDesc", "Built Description"),
|
||||
objectField("static1", "Static 1", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
objectField("static2", "Static 2", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
objectField("static3", "Static 3", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
objectField("static4", "Static 4", [stringField("title", "Title"), stringField("desc", "Desc")]),
|
||||
]),
|
||||
objectField("banner1", "Banner 1 (Quiz)", [
|
||||
stringField("q1", "Question 1"),
|
||||
stringField("q2", "Question 2"),
|
||||
stringField("q3", "Question 3"),
|
||||
stringField("cta", "CTA Button"),
|
||||
]),
|
||||
objectField("realResults", "Real Results Section", [
|
||||
stringField("title", "Title"),
|
||||
objectField("card1", "Card 1 — AutoBrat Garage", [
|
||||
stringField("title", "Title"),
|
||||
stringField("resultsLabel", "Results Label"),
|
||||
textareaField("desc", "Description"),
|
||||
stringField("stat1", "Stat 1"),
|
||||
stringField("stat2", "Stat 2"),
|
||||
stringField("stat3", "Stat 3"),
|
||||
]),
|
||||
objectField("card2", "Card 2 — Cotswolld Honey", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("desc", "Description"),
|
||||
stringField("stat1", "Stat 1"),
|
||||
stringField("stat2", "Stat 2"),
|
||||
]),
|
||||
objectField("card3", "Card 3 — Wcounting", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("desc", "Description"),
|
||||
stringField("stat1", "Stat 1"),
|
||||
stringField("stat2", "Stat 2"),
|
||||
]),
|
||||
]),
|
||||
objectField("timeline", "Timeline Section", [
|
||||
stringField("title", "Title"),
|
||||
objectField("step1", "Step 1", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step2", "Step 2", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step3", "Step 3", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step4", "Step 4", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
objectField("step5", "Step 5", [stringField("title", "Title"), stringField("duration", "Duration"), textareaField("short", "Short"), textareaField("detail", "Detail")]),
|
||||
]),
|
||||
objectField("banner2", "Banner 2", [
|
||||
stringField("cta", "CTA Button"),
|
||||
]),
|
||||
objectField("comparison", "Comparison Table", [
|
||||
stringField("title", "Title"),
|
||||
stringField("aiLabel", "AI Label"),
|
||||
objectField("metric1", "Metric 1 — Cost", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("metric2", "Metric 2 — Speed", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("metric3", "Metric 3 — Availability", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("metric4", "Metric 4 — Scalability", [stringField("label", "Label"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
stringField("altHeading", "Alternatives Heading"),
|
||||
stringField("alt1", "Alternative 1"),
|
||||
stringField("alt2", "Alternative 2"),
|
||||
textareaField("footer", "Footer Text"),
|
||||
stringField("cta", "CTA Button"),
|
||||
]),
|
||||
objectField("blogSection", "Blog Section (Homepage)", [
|
||||
stringField("title", "Title"),
|
||||
stringField("readMore", "Read More Link"),
|
||||
stringField("viewAll", "View All Link"),
|
||||
]),
|
||||
objectField("resources", "Resources Section", [
|
||||
stringField("title", "Title"),
|
||||
]),
|
||||
objectField("contactSection", "Contact Section", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("subtitle", "Subtitle"),
|
||||
]),
|
||||
objectField("contactForm", "Contact Form", [
|
||||
stringField("title", "Title"),
|
||||
stringField("fullName", "Full Name Label"),
|
||||
stringField("fullNamePlaceholder", "Full Name Placeholder"),
|
||||
stringField("jobTitle", "Job Title Label"),
|
||||
stringField("jobTitlePlaceholder", "Job Title Placeholder"),
|
||||
stringField("email", "Email Label"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("need", "Need Label"),
|
||||
stringField("needPlaceholder", "Need Placeholder"),
|
||||
stringField("company", "Company Label"),
|
||||
stringField("companyPlaceholder", "Company Placeholder"),
|
||||
stringField("phone", "Phone Label"),
|
||||
stringField("phonePlaceholder", "Phone Placeholder"),
|
||||
stringField("submit", "Submit Button"),
|
||||
stringField("sending", "Sending State"),
|
||||
stringField("error", "Error Message"),
|
||||
stringField("successTitle", "Success Title"),
|
||||
textareaField("successText", "Success Text"),
|
||||
stringField("sendAnother", "Send Another Button"),
|
||||
]),
|
||||
objectField("footer", "Footer", [
|
||||
stringField("privacy", "Privacy Link"),
|
||||
stringField("terms", "Terms Link"),
|
||||
stringField("copyright", "Copyright"),
|
||||
]),
|
||||
objectField("cookie", "Cookie Consent", [
|
||||
textareaField("text", "Cookie Text"),
|
||||
stringField("privacyLink", "Privacy Link Text"),
|
||||
stringField("reject", "Reject Button"),
|
||||
stringField("accept", "Accept Button"),
|
||||
]),
|
||||
objectField("chat", "Chat Widget", [
|
||||
stringField("greeting", "Greeting"),
|
||||
stringField("openChat", "Open Chat"),
|
||||
stringField("headerTitle", "Header Title"),
|
||||
stringField("status", "Status"),
|
||||
stringField("clearChat", "Clear Chat"),
|
||||
stringField("closeChat", "Close Chat"),
|
||||
textareaField("welcome", "Welcome Message"),
|
||||
objectField("lead", "Lead Form", [
|
||||
stringField("title", "Title"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
textareaField("intro", "Intro"),
|
||||
stringField("namePlaceholder", "Name Placeholder"),
|
||||
stringField("nameError", "Name Error"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("emailError", "Email Error"),
|
||||
stringField("emailInvalid", "Email Invalid"),
|
||||
stringField("companyPlaceholder", "Company Placeholder"),
|
||||
textareaField("consent", "Consent Text"),
|
||||
stringField("privacyLink", "Privacy Link Text"),
|
||||
stringField("consentError", "Consent Error"),
|
||||
stringField("submit", "Submit Button"),
|
||||
]),
|
||||
stringField("inputPlaceholder", "Input Placeholder"),
|
||||
stringField("send", "Send Button"),
|
||||
]),
|
||||
objectField("quoteForm", "Quote Form", [
|
||||
stringField("title", "Title"),
|
||||
stringField("fullName", "Full Name Label"),
|
||||
stringField("fullNamePlaceholder", "Full Name Placeholder"),
|
||||
stringField("jobTitle", "Job Title Label"),
|
||||
stringField("jobTitlePlaceholder", "Job Title Placeholder"),
|
||||
stringField("email", "Email Label"),
|
||||
stringField("emailPlaceholder", "Email Placeholder"),
|
||||
stringField("phone", "Phone Label"),
|
||||
stringField("phonePlaceholder", "Phone Placeholder"),
|
||||
stringField("company", "Company Label"),
|
||||
stringField("companyPlaceholder", "Company Placeholder"),
|
||||
stringField("service", "Service Label"),
|
||||
stringField("serviceDefault", "Service Default"),
|
||||
stringField("service1", "Service 1"),
|
||||
stringField("service2", "Service 2"),
|
||||
stringField("service3", "Service 3"),
|
||||
stringField("service4", "Service 4"),
|
||||
stringField("service5", "Service 5"),
|
||||
stringField("service6", "Service 6"),
|
||||
stringField("service7", "Service 7"),
|
||||
stringField("service8", "Service 8"),
|
||||
stringField("service9", "Service 9"),
|
||||
stringField("description", "Description Label"),
|
||||
textareaField("descriptionPlaceholder", "Description Placeholder"),
|
||||
stringField("submit", "Submit Button"),
|
||||
stringField("sending", "Sending State"),
|
||||
stringField("error", "Error Message"),
|
||||
stringField("successTitle", "Success Title"),
|
||||
textareaField("successText", "Success Text"),
|
||||
stringField("sendAnother", "Send Another Button"),
|
||||
]),
|
||||
objectField("about", "About Page", [
|
||||
objectField("hero", "Hero", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
objectField("story", "Our Story", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("p1", "Paragraph 1"),
|
||||
textareaField("p2", "Paragraph 2"),
|
||||
textareaField("p3", "Paragraph 3"),
|
||||
]),
|
||||
objectField("diff", "What Makes Us Different", [stringField("title", "Title")]),
|
||||
objectField("diff1", "Differentiator 1", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("diff2", "Differentiator 2", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("diff3", "Differentiator 3", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("diff4", "Differentiator 4", [stringField("title", "Title"), textareaField("desc", "Description")]),
|
||||
objectField("values", "Values", [stringField("title", "Title")]),
|
||||
objectField("val1", "Value 1", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val2", "Value 2", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val3", "Value 3", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val4", "Value 4", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("val5", "Value 5", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("founder", "Founder", [
|
||||
stringField("title", "Section Title"),
|
||||
stringField("name", "Name"),
|
||||
stringField("role", "Role"),
|
||||
stringField("bgLabel", "Background Label"),
|
||||
textareaField("bgText", "Background Text"),
|
||||
stringField("certLabel", "Certifications Label"),
|
||||
textareaField("certText", "Certifications Text"),
|
||||
stringField("analyticsLabel", "Analytics Label"),
|
||||
textareaField("analyticsText", "Analytics Text"),
|
||||
stringField("eduLabel", "Education Label"),
|
||||
textareaField("eduText", "Education Text"),
|
||||
stringField("visionLabel", "Vision Label"),
|
||||
textareaField("visionText", "Vision Text"),
|
||||
]),
|
||||
objectField("industries", "Industries", [stringField("title", "Title")]),
|
||||
objectField("ind1", "Industry 1", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind2", "Industry 2", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind3", "Industry 3", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind4", "Industry 4", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind5", "Industry 5", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind6", "Industry 6", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("ind7", "Industry 7", [stringField("name", "Name"), stringField("desc", "Description")]),
|
||||
objectField("cta", "CTA", [stringField("title", "Title"), textareaField("subtitle", "Subtitle"), stringField("button", "Button")]),
|
||||
]),
|
||||
objectField("services", "Services Page", [
|
||||
objectField("hero", "Hero", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
objectField("s1", "Service 1 — AI Chatbots", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"), stringField("f6", "F6")]),
|
||||
objectField("s2", "Service 2 — Website Dev", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"), stringField("f6", "F6")]),
|
||||
objectField("s3", "Service 3 — Workflow Automation", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s4", "Service 4 — System Integration", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s5", "Service 5 — CRM", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s6", "Service 6 — Marketing Automation", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s7", "Service 7 — AI Integration", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
objectField("s8", "Service 8 — Infrastructure", [stringField("title", "Title"), stringField("price", "Price"), textareaField("purpose", "Purpose"), stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5")]),
|
||||
stringField("popular", "Popular Badge"),
|
||||
stringField("whatsIncluded", "What's Included"),
|
||||
stringField("showLess", "Show Less"),
|
||||
objectField("assurance", "Assurance Pack", [
|
||||
stringField("title", "Title"),
|
||||
stringField("subtitle", "Subtitle"),
|
||||
stringField("i1", "Item 1"), stringField("i2", "Item 2"), stringField("i3", "Item 3"),
|
||||
stringField("i4", "Item 4"), stringField("i5", "Item 5"), stringField("i6", "Item 6"),
|
||||
stringField("i7", "Item 7"), stringField("i8", "Item 8"), stringField("i9", "Item 9"),
|
||||
]),
|
||||
objectField("metrics", "Metrics", [
|
||||
stringField("title", "Title"),
|
||||
stringField("v1", "Value 1"), stringField("l1", "Label 1"),
|
||||
stringField("v2", "Value 2"), stringField("l2", "Label 2"),
|
||||
stringField("v3", "Value 3"), stringField("l3", "Label 3"),
|
||||
stringField("v4", "Value 4"), stringField("l4", "Label 4"),
|
||||
]),
|
||||
objectField("selector", "Service Selector", [
|
||||
stringField("title", "Title"),
|
||||
stringField("step1", "Step 1"), stringField("step2", "Step 2"), stringField("step3", "Step 3"),
|
||||
stringField("goalQ", "Goal Question"),
|
||||
stringField("goal1", "Goal 1"), stringField("goal2", "Goal 2"), stringField("goal3", "Goal 3"),
|
||||
stringField("goal4", "Goal 4"), stringField("goal5", "Goal 5"),
|
||||
stringField("budgetQ", "Budget Question"),
|
||||
stringField("budget1", "Budget 1"), stringField("budget2", "Budget 2"), stringField("budget3", "Budget 3"),
|
||||
stringField("resultsHeading", "Results Heading"),
|
||||
textareaField("noMatch", "No Match"),
|
||||
stringField("viewButton", "View Button"), stringField("resetButton", "Reset Button"), stringField("backButton", "Back Button"),
|
||||
]),
|
||||
objectField("bundles", "Bundles", [stringField("title", "Title")]),
|
||||
objectField("bundle1", "Bundle 1 — Starter", [stringField("name", "Name"), stringField("tagline", "Tagline"), stringField("price", "Price")]),
|
||||
objectField("bundle2", "Bundle 2 — Growth", [stringField("name", "Name"), stringField("tagline", "Tagline"), stringField("price", "Price"), stringField("badge", "Badge")]),
|
||||
objectField("bundle3", "Bundle 3 — Full Stack", [stringField("name", "Name"), stringField("tagline", "Tagline"), stringField("price", "Price")]),
|
||||
objectField("bundle", "Bundle CTA", [stringField("cta", "CTA Button")]),
|
||||
objectField("cta", "Bottom CTA", [stringField("title", "Title"), textareaField("subtitle", "Subtitle"), stringField("button", "Button")]),
|
||||
]),
|
||||
objectField("pricing", "Pricing Page", [
|
||||
objectField("hero", "Hero", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
objectField("impl", "Implementation Pricing", [
|
||||
stringField("title", "Section Title"),
|
||||
pricingItemField("s1", "Service 1", [stringField("price", "Price")]),
|
||||
pricingItemField("s2", "Service 2", [stringField("price", "Price")]),
|
||||
pricingItemField("s3", "Service 3", [stringField("price", "Price")]),
|
||||
pricingItemField("s4", "Service 4", [stringField("price", "Price")]),
|
||||
pricingItemField("s5", "Service 5", [stringField("price", "Price")]),
|
||||
pricingItemField("s6", "Service 6", [stringField("price", "Price")]),
|
||||
pricingItemField("s7", "Service 7", [stringField("price", "Price")]),
|
||||
pricingItemField("s8", "Service 8", [stringField("price", "Price")]),
|
||||
]),
|
||||
stringField("popular", "Popular Badge"),
|
||||
objectField("retainers", "Retainers", [stringField("title", "Title")]),
|
||||
objectField("ret1", "Retainer — Essential", [
|
||||
stringField("name", "Name"), stringField("price", "Price"), stringField("period", "Period"),
|
||||
stringField("hours", "Hours"), stringField("sla", "SLA"),
|
||||
stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"),
|
||||
]),
|
||||
objectField("ret2", "Retainer — Professional", [
|
||||
stringField("name", "Name"), stringField("price", "Price"), stringField("hours", "Hours"), stringField("sla", "SLA"), stringField("badge", "Badge"),
|
||||
stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"), stringField("f6", "F6"),
|
||||
]),
|
||||
objectField("ret3", "Retainer — Enterprise", [
|
||||
stringField("name", "Name"), stringField("price", "Price"), stringField("hours", "Hours"), stringField("sla", "SLA"),
|
||||
stringField("f1", "F1"), stringField("f2", "F2"), stringField("f3", "F3"), stringField("f4", "F4"), stringField("f5", "F5"), stringField("f6", "F6"), stringField("f7", "F7"),
|
||||
]),
|
||||
objectField("training", "Training & Workshops", [
|
||||
stringField("title", "Title"),
|
||||
pricingItemField("t1", "Training 1", [stringField("price", "Price"), stringField("desc", "Desc")]),
|
||||
pricingItemField("t2", "Training 2", [stringField("price", "Price")]),
|
||||
pricingItemField("t3", "Training 3", [stringField("price", "Price"), stringField("desc", "Desc")]),
|
||||
pricingItemField("t4", "Training 4", [stringField("price", "Price"), stringField("desc", "Desc")]),
|
||||
]),
|
||||
objectField("payment", "Payment Terms", [
|
||||
stringField("title", "Title"),
|
||||
pricingItemField("r1", "Range 1", [stringField("split", "Split")]),
|
||||
pricingItemField("r2", "Range 2", [stringField("split", "Split")]),
|
||||
pricingItemField("r3", "Range 3", [stringField("split", "Split")]),
|
||||
pricingItemField("r4", "Range 4 (Public Sector)", [stringField("split", "Split")]),
|
||||
]),
|
||||
objectField("discounts", "Discounts / Impact Grant", [
|
||||
stringField("title", "Title"),
|
||||
textareaField("intro", "Intro"),
|
||||
pricingItemField("g1", "Group 1", [stringField("disc", "Discount")]),
|
||||
stringField("g2", "Group 2"),
|
||||
stringField("g3", "Group 3"),
|
||||
pricingItemField("g4", "Group 4", [stringField("disc", "Discount")]),
|
||||
stringField("g5", "Group 5"),
|
||||
]),
|
||||
objectField("compare", "Comparison Table", [
|
||||
stringField("title", "Title"),
|
||||
stringField("aimpress", "AImpress Label"), stringField("agency", "Agency Label"), stringField("inhouse", "In-House Label"),
|
||||
objectField("r1", "Row 1", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r2", "Row 2", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r3", "Row 3", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r4", "Row 4", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
objectField("r5", "Row 5", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency")]),
|
||||
objectField("r6", "Row 6", [stringField("metric", "Metric"), stringField("ai", "AI"), stringField("agency", "Agency"), stringField("inhouse", "In-House")]),
|
||||
]),
|
||||
objectField("faq", "FAQ", [
|
||||
stringField("title", "Title"),
|
||||
stringField("q1", "Q1"), textareaField("a1", "A1"),
|
||||
stringField("q2", "Q2"), textareaField("a2", "A2"),
|
||||
stringField("q3", "Q3"), textareaField("a3", "A3"),
|
||||
stringField("q4", "Q4"), textareaField("a4", "A4"),
|
||||
stringField("q5", "Q5"), textareaField("a5", "A5"),
|
||||
stringField("q6", "Q6"), textareaField("a6", "A6"),
|
||||
]),
|
||||
objectField("cta", "Bottom CTA", [stringField("title", "Title"), textareaField("subtitle", "Subtitle")]),
|
||||
]),
|
||||
objectField("blog", "Blog Page", [
|
||||
stringField("title", "Title"),
|
||||
stringField("loading", "Loading"),
|
||||
stringField("noPosts", "No Posts"),
|
||||
stringField("readMore", "Read More"),
|
||||
]),
|
||||
objectField("blogPost", "Blog Post Page", [
|
||||
stringField("back", "Back Link"),
|
||||
stringField("notFound", "Not Found"),
|
||||
stringField("loading", "Loading"),
|
||||
stringField("source", "Source Label"),
|
||||
]),
|
||||
objectField("seo", "SEO Meta Tags", [
|
||||
objectField("home", "Home Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("about", "About Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("services", "Services Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("pricing", "Pricing Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
objectField("blog", "Blog Page", [stringField("title", "Title"), textareaField("description", "Description")]),
|
||||
stringField("siteName", "Site Name"),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
branch: process.env.GITHUB_REF_NAME ?? "main",
|
||||
clientId: process.env.TINA_PUBLIC_CLIENT_ID!,
|
||||
token: process.env.TINA_TOKEN!,
|
||||
|
||||
build: {
|
||||
outputFolder: "admin",
|
||||
publicFolder: "public",
|
||||
},
|
||||
|
||||
media: {
|
||||
tina: {
|
||||
mediaRoot: "",
|
||||
publicFolder: "public",
|
||||
},
|
||||
},
|
||||
|
||||
search: {
|
||||
tina: {
|
||||
indexerToken: "49c8a879a1b8028c7f720c5d497c0bbab9ea69f6",
|
||||
stopwordLanguages: ["eng"],
|
||||
},
|
||||
indexBatchSize: 100,
|
||||
maxSearchIndexFieldLength: 100,
|
||||
},
|
||||
|
||||
schema: {
|
||||
collections: [
|
||||
{
|
||||
name: "translationsEn",
|
||||
label: "Site Content (English)",
|
||||
path: "content/translations",
|
||||
match: { include: "en" },
|
||||
format: "json",
|
||||
ui: {
|
||||
allowedActions: { create: false, delete: false },
|
||||
global: true,
|
||||
router: () => "/",
|
||||
},
|
||||
fields: designFields(),
|
||||
},
|
||||
{
|
||||
name: "translationsUk",
|
||||
label: "Site Content (Ukrainian)",
|
||||
path: "content/translations",
|
||||
match: { include: "uk" },
|
||||
format: "json",
|
||||
ui: {
|
||||
allowedActions: { create: false, delete: false },
|
||||
global: true,
|
||||
router: () => "/",
|
||||
},
|
||||
fields: translationFields(),
|
||||
},
|
||||
{
|
||||
name: "blogPost",
|
||||
label: "Blog Posts",
|
||||
path: "content/blog",
|
||||
format: "md",
|
||||
ui: {
|
||||
router: ({ document }: { document: { _sys: { filename: string } } }) =>
|
||||
`/blog/${document._sys.filename}`,
|
||||
filename: {
|
||||
readonly: false,
|
||||
slugify: (values: Record<string, unknown>) => {
|
||||
const title = values.title as string | undefined;
|
||||
return title
|
||||
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)
|
||||
: 'new-post';
|
||||
},
|
||||
},
|
||||
beforeSubmit: async ({ values }: { values: Record<string, unknown> }) => ({
|
||||
...values,
|
||||
date: (values.date as string | undefined) ?? new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
fields: [
|
||||
{ name: "title", type: "string", label: "Title", required: true, isTitle: true },
|
||||
{ name: "date", type: "datetime", label: "Date", required: true },
|
||||
{ name: "excerpt", type: "string", label: "Excerpt", ui: { component: "textarea" } },
|
||||
{ name: "coverImage", type: "image", label: "Cover Image" },
|
||||
{ name: "hashtags", type: "string", label: "Tags", list: true },
|
||||
{ name: "sourceTitle", type: "string", label: "Source Name" },
|
||||
{ name: "sourceUrl", type: "string", label: "Source URL" },
|
||||
{ name: "body", type: "rich-text", label: "Body", isBody: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "pages",
|
||||
label: "Pages",
|
||||
path: "content/pages",
|
||||
format: "json",
|
||||
ui: {
|
||||
router: ({ document }: { document: { _sys: { filename: string } } }) =>
|
||||
`/p/${document._sys.filename}`,
|
||||
filename: {
|
||||
readonly: false,
|
||||
slugify: (values: Record<string, unknown>) => {
|
||||
const title = values.title as string | undefined;
|
||||
return title
|
||||
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)
|
||||
: 'new-page';
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Page Title", required: true, isTitle: true },
|
||||
{ name: "published", type: "boolean" as const, label: "Published (visible on site)" },
|
||||
{
|
||||
name: "seo",
|
||||
type: "object" as const,
|
||||
label: "SEO",
|
||||
ui: { allowedActions: { create: false, delete: false } } as any,
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "SEO Title" },
|
||||
{ name: "description", type: "string" as const, label: "SEO Description", ui: { component: "textarea" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "blocks",
|
||||
type: "object" as const,
|
||||
label: "Page Blocks",
|
||||
list: true,
|
||||
templates: [
|
||||
{
|
||||
name: "hero",
|
||||
label: "Hero",
|
||||
fields: [
|
||||
{ name: "headline", type: "string" as const, label: "Headline" },
|
||||
{ name: "subtext", type: "string" as const, label: "Subtext", ui: { component: "textarea" } },
|
||||
{ name: "ctaText", type: "string" as const, label: "Button Text" },
|
||||
{ name: "ctaUrl", type: "string" as const, label: "Button URL" },
|
||||
{ name: "backgroundStyle", type: "string" as const, label: "Background", options: ["dark", "teal", "gradient"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "textBlock",
|
||||
label: "Text Block",
|
||||
fields: [
|
||||
{ name: "content", type: "rich-text" as const, label: "Content" },
|
||||
{ name: "width", type: "string" as const, label: "Width", options: ["full", "narrow"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "twoColumn",
|
||||
label: "Two Column",
|
||||
fields: [
|
||||
{ name: "leftType", type: "string" as const, label: "Left Side Type", options: ["text", "image"] },
|
||||
{ name: "leftText", type: "rich-text" as const, label: "Left Text" },
|
||||
{ name: "leftImage", type: "image" as const, label: "Left Image" },
|
||||
{ name: "rightType", type: "string" as const, label: "Right Side Type", options: ["text", "image"] },
|
||||
{ name: "rightText", type: "rich-text" as const, label: "Right Text" },
|
||||
{ name: "rightImage", type: "image" as const, label: "Right Image" },
|
||||
{ name: "reverseOnMobile",type: "boolean" as const, label: "Reverse on Mobile" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "features",
|
||||
label: "Features Grid",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object" as const,
|
||||
label: "Features",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.title || 'Feature' }) },
|
||||
fields: [
|
||||
{ name: "icon", type: "string" as const, label: "Icon (emoji)" },
|
||||
{ name: "title", type: "string" as const, label: "Title" },
|
||||
{ name: "description", type: "string" as const, label: "Description", ui: { component: "textarea" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "stats",
|
||||
label: "Stats",
|
||||
fields: [
|
||||
{
|
||||
name: "items",
|
||||
type: "object" as const,
|
||||
label: "Stats",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.value || 'Stat' }) },
|
||||
fields: [
|
||||
{ name: "value", type: "string" as const, label: "Value (e.g. 95%)" },
|
||||
{ name: "label", type: "string" as const, label: "Label" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "testimonials",
|
||||
label: "Testimonials",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object" as const,
|
||||
label: "Testimonials",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.author || 'Testimonial' }) },
|
||||
fields: [
|
||||
{ name: "quote", type: "string" as const, label: "Quote", ui: { component: "textarea" } },
|
||||
{ name: "author", type: "string" as const, label: "Author Name" },
|
||||
{ name: "role", type: "string" as const, label: "Role" },
|
||||
{ name: "company", type: "string" as const, label: "Company" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "team",
|
||||
label: "Team",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object" as const,
|
||||
label: "Team Members",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.name || 'Member' }) },
|
||||
fields: [
|
||||
{ name: "name", type: "string" as const, label: "Name" },
|
||||
{ name: "role", type: "string" as const, label: "Role" },
|
||||
{ name: "bio", type: "string" as const, label: "Bio", ui: { component: "textarea" } },
|
||||
{ name: "photo", type: "image" as const, label: "Photo" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "faq",
|
||||
label: "FAQ",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Section Title" },
|
||||
{
|
||||
name: "items",
|
||||
type: "object" as const,
|
||||
label: "Questions",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.question || 'Question' }) },
|
||||
fields: [
|
||||
{ name: "question", type: "string" as const, label: "Question" },
|
||||
{ name: "answer", type: "string" as const, label: "Answer", ui: { component: "textarea" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ctaBanner",
|
||||
label: "CTA Banner",
|
||||
fields: [
|
||||
{ name: "headline", type: "string" as const, label: "Headline" },
|
||||
{ name: "subtext", type: "string" as const, label: "Subtext", ui: { component: "textarea" } },
|
||||
{ name: "btnText", type: "string" as const, label: "Button Text" },
|
||||
{ name: "btnUrl", type: "string" as const, label: "Button URL" },
|
||||
{ name: "style", type: "string" as const, label: "Style", options: ["orange", "teal"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "video",
|
||||
label: "Video",
|
||||
fields: [
|
||||
{ name: "youtubeUrl", type: "string" as const, label: "YouTube URL" },
|
||||
{ name: "caption", type: "string" as const, label: "Caption" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "gallery",
|
||||
label: "Image Gallery",
|
||||
fields: [
|
||||
{
|
||||
name: "images",
|
||||
type: "object" as const,
|
||||
label: "Images",
|
||||
list: true,
|
||||
fields: [
|
||||
{ name: "src", type: "image" as const, label: "Image" },
|
||||
{ name: "caption", type: "string" as const, label: "Caption" },
|
||||
{ name: "alt", type: "string" as const, label: "Alt Text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "pricing",
|
||||
label: "Pricing",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Section Title" },
|
||||
{
|
||||
name: "plans",
|
||||
type: "object" as const,
|
||||
label: "Plans",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.name || 'Plan' }) },
|
||||
fields: [
|
||||
{ name: "name", type: "string" as const, label: "Plan Name" },
|
||||
{ name: "price", type: "string" as const, label: "Price (e.g. £499)" },
|
||||
{ name: "period", type: "string" as const, label: "Period (e.g. month)" },
|
||||
{ name: "features", type: "string" as const, label: "Features", list: true },
|
||||
{ name: "highlighted", type: "boolean" as const, label: "Highlighted (Popular)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "timeline",
|
||||
label: "Timeline",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Section Title" },
|
||||
{
|
||||
name: "steps",
|
||||
type: "object" as const,
|
||||
label: "Steps",
|
||||
list: true,
|
||||
ui: { itemProps: (item: any) => ({ label: item.title || 'Step' }) },
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Step Title" },
|
||||
{ name: "description", type: "string" as const, label: "Description", ui: { component: "textarea" } },
|
||||
{ name: "duration", type: "string" as const, label: "Duration (e.g. Week 1-2)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "divider",
|
||||
label: "Divider / Spacer",
|
||||
fields: [
|
||||
{ name: "type", type: "string" as const, label: "Type", options: ["line", "space"] },
|
||||
{ name: "size", type: "string" as const, label: "Size", options: ["small", "medium", "large"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "contactForm",
|
||||
label: "Contact Form",
|
||||
fields: [
|
||||
{ name: "title", type: "string" as const, label: "Title (optional)" },
|
||||
{ name: "subtitle", type: "string" as const, label: "Subtitle (optional)", ui: { component: "textarea" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
1
tina/tina-lock.json
Normal file
1
tina/tina-lock.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -22,7 +22,8 @@
|
|||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue