chore: initialize project
Some checks failed
Deploy monie-landing (kaniko) / build-and-deploy (push) Failing after 1m46s
Some checks failed
Deploy monie-landing (kaniko) / build-and-deploy (push) Failing after 1m46s
This commit is contained in:
22
.cta.json
Normal file
22
.cta.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"projectName": "monie-landing",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"packageManager": "npm",
|
||||
"includeExamples": false,
|
||||
"tailwind": true,
|
||||
"addOnOptions": {},
|
||||
"envVarValues": {},
|
||||
"git": true,
|
||||
"routerOnly": false,
|
||||
"version": 1,
|
||||
"framework": "react",
|
||||
"chosenAddOns": [
|
||||
"biome",
|
||||
"posthog",
|
||||
"form",
|
||||
"t3env",
|
||||
"compiler",
|
||||
"sentry"
|
||||
]
|
||||
}
|
||||
22
.cursorrules
Normal file
22
.cursorrules
Normal file
@@ -0,0 +1,22 @@
|
||||
We use Sentry for watching for errors in our deployed application, as well as for instrumentation of our application.
|
||||
|
||||
## Error collection
|
||||
|
||||
Error collection is automatic and configured in `src/router.tsx`.
|
||||
|
||||
## Instrumentation
|
||||
|
||||
We want our server functions instrumented. So if you see a function name like `createServerFn`, you can instrument it with Sentry. You'll need to import `Sentry`:
|
||||
|
||||
```tsx
|
||||
import * as Sentry from '@sentry/tanstackstart-react'
|
||||
```
|
||||
|
||||
And then wrap the implementation of the server function with `Sentry.startSpan`, like so:
|
||||
|
||||
```tsx
|
||||
Sentry.startSpan({ name: 'Requesting all the pokemon' }, async () => {
|
||||
// Some lengthy operation here
|
||||
await fetch('https://api.pokemon.com/data/')
|
||||
})
|
||||
```
|
||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitea
|
||||
node_modules
|
||||
.output
|
||||
dist
|
||||
coverage
|
||||
.npm
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
README.md
|
||||
.codex
|
||||
185
.gitea/workflows/deploy-dev.yml
Normal file
185
.gitea/workflows/deploy-dev.yml
Normal file
@@ -0,0 +1,185 @@
|
||||
name: Deploy monie-landing to dev (kaniko)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: [self-hosted, linux, k8s]
|
||||
|
||||
env:
|
||||
CI_NS: ci
|
||||
APP_NS: dev
|
||||
|
||||
# Kaniko job runs inside cluster pods and can reach registry via node IP.
|
||||
PUSH_REGISTRY: 192.168.1.250:32000
|
||||
# Runtime pull should use the endpoint configured in MicroK8s containerd.
|
||||
DEPLOY_REGISTRY: localhost:32000
|
||||
IMAGE: monie-landing
|
||||
|
||||
DEPLOYMENT: monie-landing
|
||||
CONTAINER: monie-landing
|
||||
|
||||
# repo без кредов (креды берём из secret внутри Kaniko Job)
|
||||
REPO_HOST: git.denjs.ru
|
||||
REPO_PATH: monie/monie-landing.git
|
||||
|
||||
steps:
|
||||
- name: Debug
|
||||
run: |
|
||||
set -eu
|
||||
echo "sha=${{ github.sha }}"
|
||||
echo "ref=${{ github.ref_name }}"
|
||||
echo "repo=git://${REPO_HOST}/${REPO_PATH}"
|
||||
microk8s kubectl version --client=true
|
||||
|
||||
- name: Build & push with Kaniko (K8s Job)
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
JOB="kaniko-${SHA}"
|
||||
DEST="${PUSH_REGISTRY}/${IMAGE}:${SHA}"
|
||||
|
||||
echo "JOB=${JOB}"
|
||||
echo "DEST=${DEST}"
|
||||
echo "REF=${REF}"
|
||||
echo "REPO=git://${REPO_HOST}/${REPO_PATH}"
|
||||
|
||||
microk8s kubectl -n "${CI_NS}" delete job "${JOB}" --ignore-not-found=true
|
||||
|
||||
cat <<EOF_JOB | microk8s kubectl -n "${CI_NS}" apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: ${JOB}
|
||||
labels:
|
||||
app: kaniko
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: kaniko
|
||||
image: gcr.io/kaniko-project/executor:latest
|
||||
env:
|
||||
- name: GIT_USERNAME
|
||||
value: denis
|
||||
- name: GIT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-git-token
|
||||
key: token
|
||||
args:
|
||||
- --context=git://${REPO_HOST}/${REPO_PATH}#refs/heads/${REF}
|
||||
- --dockerfile=Dockerfile
|
||||
- --destination=${DEST}
|
||||
- --verbosity=debug
|
||||
- --cache=true
|
||||
- --cache-repo=${PUSH_REGISTRY}/${IMAGE}-cache
|
||||
- --insecure-registry=${PUSH_REGISTRY}
|
||||
- --skip-tls-verify-registry=${PUSH_REGISTRY}
|
||||
ttlSecondsAfterFinished: 3600
|
||||
EOF_JOB
|
||||
|
||||
DEADLINE_SECONDS=1800
|
||||
START_TS="$(date +%s)"
|
||||
OK=1
|
||||
|
||||
while true; do
|
||||
SUCCEEDED="$(microk8s kubectl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.succeeded}' 2>/dev/null || true)"
|
||||
FAILED="$(microk8s kubectl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.failed}' 2>/dev/null || true)"
|
||||
|
||||
SUCCEEDED="${SUCCEEDED:-0}"
|
||||
FAILED="${FAILED:-0}"
|
||||
|
||||
if [ "${SUCCEEDED}" -ge 1 ]; then
|
||||
OK=0
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "${FAILED}" -ge 1 ]; then
|
||||
OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
NOW_TS="$(date +%s)"
|
||||
if [ $((NOW_TS - START_TS)) -ge "${DEADLINE_SECONDS}" ]; then
|
||||
OK=2
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[ci] job status:"
|
||||
microk8s kubectl -n "${CI_NS}" get job "${JOB}" -o wide || true
|
||||
|
||||
echo "[ci] job logs (tail):"
|
||||
microk8s kubectl -n "${CI_NS}" logs "job/${JOB}" --tail=300 || true
|
||||
|
||||
if [ "${OK}" -ne 0 ]; then
|
||||
echo "[ci] job did not reach Complete; describing job/pods for debug"
|
||||
microk8s kubectl -n "${CI_NS}" describe job "${JOB}" || true
|
||||
microk8s kubectl -n "${CI_NS}" get pods -l job-name="${JOB}" -o wide || true
|
||||
microk8s kubectl -n "${CI_NS}" describe pod -l job-name="${JOB}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy to dev
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_IMAGE="${DEPLOY_REGISTRY}/${IMAGE}:${SHA}"
|
||||
|
||||
microk8s kubectl -n "${APP_NS}" set image "deployment/${DEPLOYMENT}" \
|
||||
"${CONTAINER}=${TARGET_IMAGE}"
|
||||
|
||||
set +e
|
||||
microk8s kubectl -n "${APP_NS}" rollout status "deployment/${DEPLOYMENT}" --timeout=15m
|
||||
ROLLOUT_RC=$?
|
||||
set -e
|
||||
|
||||
if [ "${ROLLOUT_RC}" -ne 0 ]; then
|
||||
echo "[deploy] rollout did not complete in time; collecting diagnostics"
|
||||
|
||||
SELECTOR="$(microk8s kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{range $k,$v := .spec.selector.matchLabels}{$k}={$v},{end}' 2>/dev/null || true)"
|
||||
SELECTOR="${SELECTOR%,}"
|
||||
|
||||
microk8s kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" -o wide || true
|
||||
microk8s kubectl -n "${APP_NS}" describe deployment "${DEPLOYMENT}" || true
|
||||
|
||||
if [ -n "${SELECTOR}" ]; then
|
||||
microk8s kubectl -n "${APP_NS}" get rs -l "${SELECTOR}" -o wide || true
|
||||
microk8s kubectl -n "${APP_NS}" get pods -l "${SELECTOR}" -o wide || true
|
||||
microk8s kubectl -n "${APP_NS}" describe pods -l "${SELECTOR}" || true
|
||||
fi
|
||||
|
||||
microk8s kubectl -n "${APP_NS}" get events --sort-by=.lastTimestamp | tail -n 100 || true
|
||||
|
||||
DESIRED="$(microk8s kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{.spec.replicas}' 2>/dev/null || true)"
|
||||
UPDATED="$(microk8s kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{.status.updatedReplicas}' 2>/dev/null || true)"
|
||||
AVAILABLE="$(microk8s kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{.status.availableReplicas}' 2>/dev/null || true)"
|
||||
|
||||
DESIRED="${DESIRED:-0}"
|
||||
UPDATED="${UPDATED:-0}"
|
||||
AVAILABLE="${AVAILABLE:-0}"
|
||||
|
||||
echo "[deploy] desired=${DESIRED} updated=${UPDATED} available=${AVAILABLE}"
|
||||
|
||||
if [ "${UPDATED}" -ge "${DESIRED}" ] && [ "${AVAILABLE}" -ge "${DESIRED}" ]; then
|
||||
echo "[deploy] New replica is healthy; old replica termination is delayed. Continuing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit "${ROLLOUT_RC}"
|
||||
fi
|
||||
174
.gitea/workflows/deploy-prod.yml
Normal file
174
.gitea/workflows/deploy-prod.yml
Normal file
@@ -0,0 +1,174 @@
|
||||
# .gitea/workflows/deploy-prod.yml
|
||||
name: Deploy monie-landing (kaniko)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: [self-hosted, linux, k8s]
|
||||
|
||||
env:
|
||||
CI_NS: ci
|
||||
APP_NS: prod
|
||||
|
||||
# Kaniko job runs inside cluster pods and can reach registry via node IP.
|
||||
PUSH_REGISTRY: 192.168.1.250:32000
|
||||
# Runtime pull should use the endpoint configured in MicroK8s containerd.
|
||||
DEPLOY_REGISTRY: localhost:32000
|
||||
IMAGE: monie-landing
|
||||
|
||||
DEPLOYMENT: monie-landing
|
||||
CONTAINER: monie-landing
|
||||
|
||||
# repo без кредов (креды берём из secret внутри Kaniko Job)
|
||||
REPO_HOST: git.denjs.ru
|
||||
REPO_PATH: monie/monie-landing.git
|
||||
|
||||
steps:
|
||||
- name: Build & push image with Kaniko (K8s Job)
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
JOB="kaniko-${SHA}"
|
||||
DEST="${PUSH_REGISTRY}/${IMAGE}:${SHA}"
|
||||
|
||||
kubectl -n "${CI_NS}" delete job "${JOB}" --ignore-not-found=true
|
||||
|
||||
cat <<EOF_JOB | kubectl -n "${CI_NS}" apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: ${JOB}
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: kaniko
|
||||
image: gcr.io/kaniko-project/executor:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: GIT_USERNAME
|
||||
value: denis
|
||||
- name: GIT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-git-token
|
||||
key: token
|
||||
args:
|
||||
- --dockerfile=Dockerfile
|
||||
- --context=git://${REPO_HOST}/${REPO_PATH}#refs/heads/${REF}
|
||||
- --destination=${DEST}
|
||||
- --verbosity=debug
|
||||
- --cache=true
|
||||
- --cache-repo=${PUSH_REGISTRY}/${IMAGE}-cache
|
||||
- --insecure-registry=${PUSH_REGISTRY}
|
||||
- --skip-tls-verify-registry=${PUSH_REGISTRY}
|
||||
EOF_JOB
|
||||
|
||||
# ждём terminal state job и не висим 30 минут при явном Failed
|
||||
DEADLINE_SECONDS=1800
|
||||
START_TS="$(date +%s)"
|
||||
OK=1
|
||||
|
||||
while true; do
|
||||
SUCCEEDED="$(kubectl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.succeeded}' 2>/dev/null || true)"
|
||||
FAILED="$(kubectl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.failed}' 2>/dev/null || true)"
|
||||
|
||||
SUCCEEDED="${SUCCEEDED:-0}"
|
||||
FAILED="${FAILED:-0}"
|
||||
|
||||
if [ "${SUCCEEDED}" -ge 1 ]; then
|
||||
OK=0
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "${FAILED}" -ge 1 ]; then
|
||||
OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
NOW_TS="$(date +%s)"
|
||||
if [ $((NOW_TS - START_TS)) -ge "${DEADLINE_SECONDS}" ]; then
|
||||
OK=2
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[ci] job status:"
|
||||
kubectl -n "${CI_NS}" get job "${JOB}" -o wide || true
|
||||
|
||||
echo "[ci] job logs (tail):"
|
||||
kubectl -n "${CI_NS}" logs "job/${JOB}" --tail=300 || true
|
||||
|
||||
if [ "${OK}" -ne 0 ]; then
|
||||
echo "[ci] job did not reach Complete; describing job/pods for debug"
|
||||
kubectl -n "${CI_NS}" describe job "${JOB}" || true
|
||||
kubectl -n "${CI_NS}" get pods -l job-name="${JOB}" -o wide || true
|
||||
kubectl -n "${CI_NS}" describe pod -l job-name="${JOB}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy to prod
|
||||
env:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_IMAGE="${DEPLOY_REGISTRY}/${IMAGE}:${SHA}"
|
||||
|
||||
kubectl -n "${APP_NS}" set image "deployment/${DEPLOYMENT}" \
|
||||
"${CONTAINER}=${TARGET_IMAGE}"
|
||||
|
||||
set +e
|
||||
kubectl -n "${APP_NS}" rollout status "deployment/${DEPLOYMENT}" --timeout=15m
|
||||
ROLLOUT_RC=$?
|
||||
set -e
|
||||
|
||||
if [ "${ROLLOUT_RC}" -ne 0 ]; then
|
||||
echo "[deploy] rollout did not complete in time; collecting diagnostics"
|
||||
|
||||
SELECTOR="$(kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{range $k,$v := .spec.selector.matchLabels}{$k}={$v},{end}' 2>/dev/null || true)"
|
||||
SELECTOR="${SELECTOR%,}"
|
||||
|
||||
kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" -o wide || true
|
||||
kubectl -n "${APP_NS}" describe deployment "${DEPLOYMENT}" || true
|
||||
|
||||
if [ -n "${SELECTOR}" ]; then
|
||||
kubectl -n "${APP_NS}" get rs -l "${SELECTOR}" -o wide || true
|
||||
kubectl -n "${APP_NS}" get pods -l "${SELECTOR}" -o wide || true
|
||||
kubectl -n "${APP_NS}" describe pods -l "${SELECTOR}" || true
|
||||
fi
|
||||
|
||||
kubectl -n "${APP_NS}" get events --sort-by=.lastTimestamp | tail -n 100 || true
|
||||
|
||||
DESIRED="$(kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{.spec.replicas}' 2>/dev/null || true)"
|
||||
UPDATED="$(kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{.status.updatedReplicas}' 2>/dev/null || true)"
|
||||
AVAILABLE="$(kubectl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||
-o jsonpath='{.status.availableReplicas}' 2>/dev/null || true)"
|
||||
|
||||
DESIRED="${DESIRED:-0}"
|
||||
UPDATED="${UPDATED:-0}"
|
||||
AVAILABLE="${AVAILABLE:-0}"
|
||||
|
||||
echo "[deploy] desired=${DESIRED} updated=${UPDATED} available=${AVAILABLE}"
|
||||
|
||||
if [ "${UPDATED}" -ge "${DESIRED}" ] && [ "${AVAILABLE}" -ge "${DESIRED}" ]; then
|
||||
echo "[deploy] New replica is healthy; old replica termination is delayed. Continuing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit "${ROLLOUT_RC}"
|
||||
fi
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
.codex
|
||||
35
.vscode/settings.json
vendored
Normal file
35
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit"
|
||||
}
|
||||
}
|
||||
499
AGENTS.md
Normal file
499
AGENTS.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project
|
||||
Monie Landing — marketing website for a product for individual service masters.
|
||||
|
||||
This is not a salon CRM.
|
||||
This is not an enterprise business automation system.
|
||||
This is not a dashboard-first product.
|
||||
|
||||
The product is built for independent masters:
|
||||
- nail artists
|
||||
- barbers
|
||||
- brow artists
|
||||
- lash makers
|
||||
- cosmetologists
|
||||
- massage specialists
|
||||
- other solo beauty/service professionals
|
||||
|
||||
The landing page must present Monie as a simple, modern tool that helps one master manage bookings, clients, and income without complexity.
|
||||
|
||||
---
|
||||
|
||||
## Core product idea
|
||||
Monie is a personal business page for a master.
|
||||
|
||||
It combines:
|
||||
- personal profile
|
||||
- services and prices
|
||||
- online booking
|
||||
- simple calendar
|
||||
- client list
|
||||
- income visibility
|
||||
|
||||
The product should feel like:
|
||||
- a personal brand page
|
||||
- a booking tool
|
||||
- a lightweight work assistant
|
||||
|
||||
It should NOT feel like:
|
||||
- a salon admin panel
|
||||
- a CRM for teams
|
||||
- an overloaded ERP-like system
|
||||
- a back office for managers
|
||||
|
||||
---
|
||||
|
||||
## Strategic positioning
|
||||
YCLIENTS is positioned around business automation: online booking, customer base automation, notifications, analytics, finance, payroll, stock control, and management of larger operations.
|
||||
|
||||
Monie must be positioned differently:
|
||||
- not for business owners managing staff
|
||||
- not for networks and franchises
|
||||
- not for administrators
|
||||
- for the master personally
|
||||
|
||||
The key shift is:
|
||||
from "manage a business"
|
||||
to "run your own practice simply"
|
||||
|
||||
---
|
||||
|
||||
## Main message
|
||||
The user is not a company.
|
||||
The user is a master.
|
||||
|
||||
The landing should communicate this feeling immediately:
|
||||
|
||||
"I am a master. I want a beautiful page, easy booking, and clear control over my work and income."
|
||||
|
||||
---
|
||||
|
||||
## Design direction
|
||||
The visual style must be:
|
||||
- modern
|
||||
- premium
|
||||
- clean
|
||||
- soft
|
||||
- confident
|
||||
- minimal
|
||||
- mobile-first
|
||||
- conversion-focused
|
||||
|
||||
The emotional tone must be:
|
||||
- personal
|
||||
- calm
|
||||
- aesthetic
|
||||
- trustworthy
|
||||
- not corporate
|
||||
- not aggressive
|
||||
- not noisy
|
||||
|
||||
Avoid:
|
||||
- enterprise SaaS look
|
||||
- complex tables on first screens
|
||||
- admin-dashboard feel
|
||||
- too much dense UI
|
||||
- “business automation” aesthetics
|
||||
- generic startup gradients everywhere
|
||||
- crypto-like visual language
|
||||
- ugly CRM blocks
|
||||
- too many borders and widgets
|
||||
|
||||
---
|
||||
|
||||
## Product metaphor
|
||||
Think of the product as closer to:
|
||||
- a personal booking page
|
||||
- a professional profile
|
||||
- a master’s mini-site
|
||||
- a simple scheduling assistant
|
||||
|
||||
Not as:
|
||||
- a back office
|
||||
- a team management system
|
||||
- a reporting center
|
||||
|
||||
---
|
||||
|
||||
## UX principles
|
||||
Every UI decision must optimize for:
|
||||
1. clarity
|
||||
2. trust
|
||||
3. speed of understanding
|
||||
4. emotional comfort
|
||||
5. conversion
|
||||
|
||||
The landing must explain the product in a few seconds.
|
||||
|
||||
The visitor should instantly understand:
|
||||
- this is for masters
|
||||
- this helps with bookings
|
||||
- this helps avoid chaos in messages
|
||||
- this helps track work and income
|
||||
- this gives them a professional online presence
|
||||
|
||||
---
|
||||
|
||||
## Hero section rules
|
||||
The hero must communicate 3 things immediately:
|
||||
1. what the product is
|
||||
2. who it is for
|
||||
3. what action to take next
|
||||
|
||||
Hero should feel personal, not corporate.
|
||||
|
||||
Bad direction:
|
||||
- “Business automation platform”
|
||||
- “CRM and analytics for service companies”
|
||||
- “Optimize operational efficiency”
|
||||
|
||||
Good direction:
|
||||
- “Your personal booking page”
|
||||
- “All your clients and appointments in one place”
|
||||
- “For masters who want less chaos and more income”
|
||||
- “Accept bookings without chats, spreadsheets, and confusion”
|
||||
|
||||
---
|
||||
|
||||
## Landing page structure
|
||||
Preferred structure:
|
||||
|
||||
1. Hero
|
||||
2. Problem
|
||||
3. Solution
|
||||
4. Product preview
|
||||
5. Benefits
|
||||
6. How it works
|
||||
7. Personal page / profile feature
|
||||
8. Social proof or trust block
|
||||
9. FAQ
|
||||
10. Final CTA
|
||||
11. Footer
|
||||
|
||||
---
|
||||
|
||||
## Section intent
|
||||
### 1. Hero
|
||||
Make the value instantly clear.
|
||||
Focus on one master, not a team.
|
||||
|
||||
### 2. Problem
|
||||
Show current pain:
|
||||
- bookings in messengers
|
||||
- scattered notes
|
||||
- forgotten appointments
|
||||
- no clear client history
|
||||
- no clear income view
|
||||
|
||||
### 3. Solution
|
||||
Monie gives the master one clean place for:
|
||||
- profile
|
||||
- services
|
||||
- booking
|
||||
- calendar
|
||||
- clients
|
||||
- earnings
|
||||
|
||||
### 4. Product preview
|
||||
Show interface as elegant and simple.
|
||||
The UI should look closer to a polished consumer product than to CRM software.
|
||||
|
||||
### 5. Benefits
|
||||
Explain outcomes, not enterprise functions.
|
||||
|
||||
Prefer:
|
||||
- “clients can book you anytime”
|
||||
- “all appointments in one calendar”
|
||||
- “see how much you earned”
|
||||
- “keep your services and prices in order”
|
||||
- “look more professional online”
|
||||
|
||||
Avoid:
|
||||
- “automation of business processes”
|
||||
- “deep operational analytics”
|
||||
- “staff access rights”
|
||||
- “warehouse management”
|
||||
- “franchise scaling”
|
||||
|
||||
### 6. How it works
|
||||
Keep it simple and linear:
|
||||
- create profile
|
||||
- add services
|
||||
- share link
|
||||
- get bookings
|
||||
|
||||
### 7. Personal page feature
|
||||
This is a key product idea.
|
||||
The master should get a page like:
|
||||
- monie.app/anna-nails
|
||||
- monie.app/david-barber
|
||||
|
||||
This page should feel like a major product advantage.
|
||||
|
||||
### 8. Trust block
|
||||
Use trust elements that fit solo specialists:
|
||||
- looks professional
|
||||
- simple to start
|
||||
- saves time
|
||||
- helps avoid missed bookings
|
||||
|
||||
### 9. FAQ
|
||||
Answer practical concerns:
|
||||
- do I need a website?
|
||||
- can I set my own services and prices?
|
||||
- can clients book online?
|
||||
- is it good for a solo master?
|
||||
- can I use it from phone?
|
||||
|
||||
### 10. Final CTA
|
||||
Clear, direct, simple.
|
||||
Bring the user back to one primary action.
|
||||
|
||||
---
|
||||
|
||||
## Visual system
|
||||
### Layout
|
||||
- Use a strong central container
|
||||
- Keep generous whitespace
|
||||
- Prefer large clean sections
|
||||
- Build obvious visual rhythm
|
||||
- Make scanning effortless
|
||||
|
||||
### Spacing
|
||||
- Use a consistent spacing system
|
||||
- Prefer breathing room over density
|
||||
- Never make sections feel cramped
|
||||
|
||||
### Typography
|
||||
- Strong, short headings
|
||||
- Short supporting text
|
||||
- High contrast and clear hierarchy
|
||||
- No long walls of text
|
||||
|
||||
### Cards
|
||||
- Use cards consistently
|
||||
- Same radius language across sections
|
||||
- Same shadow logic across sections
|
||||
- Same padding logic across sections
|
||||
|
||||
Do not invent a new card style in every block.
|
||||
|
||||
### Buttons
|
||||
Primary CTA must be obvious.
|
||||
Secondary CTA must be quieter.
|
||||
|
||||
Prefer CTA labels like:
|
||||
- Start free
|
||||
- Create your page
|
||||
- Get started
|
||||
- Accept bookings online
|
||||
|
||||
Avoid vague labels like:
|
||||
- Learn more
|
||||
- Explore platform
|
||||
- Discover solution
|
||||
|
||||
---
|
||||
|
||||
## UI tone
|
||||
The interface should feel like a beautiful assistant for a master.
|
||||
|
||||
It should feel:
|
||||
- feminine or neutral depending on styling direction
|
||||
- elegant
|
||||
- clean
|
||||
- easy
|
||||
- pleasant to use
|
||||
- human
|
||||
|
||||
Never make it feel:
|
||||
- bureaucratic
|
||||
- operationally heavy
|
||||
- manager-oriented
|
||||
- back-office first
|
||||
|
||||
---
|
||||
|
||||
## Image and mockup direction
|
||||
When creating product visuals or mockups:
|
||||
- show a personal profile
|
||||
- show services and prices
|
||||
- show available booking times
|
||||
- show simple schedule/calendar
|
||||
- show compact client info
|
||||
- show income summary in a lightweight way
|
||||
|
||||
Do not center the experience around:
|
||||
- employees
|
||||
- branches
|
||||
- warehouse
|
||||
- finance admin
|
||||
- payroll management
|
||||
- huge analytics dashboards
|
||||
|
||||
---
|
||||
|
||||
## Copywriting rules
|
||||
Copy must be:
|
||||
- short
|
||||
- clear
|
||||
- benefit-driven
|
||||
- emotional but concrete
|
||||
- easy to understand instantly
|
||||
|
||||
Prefer:
|
||||
- plain language
|
||||
- direct promises
|
||||
- practical outcomes
|
||||
|
||||
Avoid:
|
||||
- buzzwords
|
||||
- abstract innovation language
|
||||
- B2B corporate phrasing
|
||||
- words that sound like enterprise software
|
||||
|
||||
Bad examples:
|
||||
- revolutionary service business ecosystem
|
||||
- end-to-end operational efficiency
|
||||
- scalable management infrastructure
|
||||
|
||||
Good examples:
|
||||
- all your bookings in one place
|
||||
- your personal booking page
|
||||
- simpler scheduling for masters
|
||||
- clients can book you online anytime
|
||||
- work with less chaos
|
||||
|
||||
---
|
||||
|
||||
## Mobile-first rule
|
||||
This product is very likely to be discovered and used on phone.
|
||||
|
||||
All design decisions must work beautifully on mobile first:
|
||||
- compact hero
|
||||
- short copy
|
||||
- clear CTA
|
||||
- easy card scanning
|
||||
- elegant stacked sections
|
||||
- no tiny dense UI
|
||||
|
||||
Desktop should feel premium.
|
||||
Mobile should feel natural.
|
||||
|
||||
---
|
||||
|
||||
## Conversion rule
|
||||
This is a landing page, not a documentation page.
|
||||
|
||||
Every section must help the user move toward action.
|
||||
If a section does not improve:
|
||||
- clarity
|
||||
- trust
|
||||
- desire
|
||||
- conversion
|
||||
|
||||
then remove it.
|
||||
|
||||
---
|
||||
|
||||
## What Codex should prioritize
|
||||
When improving the landing, always prioritize in this order:
|
||||
1. clarity
|
||||
2. positioning for masters
|
||||
3. emotional appeal
|
||||
4. trust
|
||||
5. conversion
|
||||
6. visual consistency
|
||||
7. polish
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
Do not create:
|
||||
- generic SaaS landing pages
|
||||
- enterprise dashboard visuals
|
||||
- complex CRM-like layouts
|
||||
- feature grids with meaningless buzzwords
|
||||
- too many UI styles in one page
|
||||
- dark aggressive fintech aesthetics unless explicitly requested
|
||||
- overloaded hero sections
|
||||
- giant comparison tables unless specifically needed
|
||||
- business-owner messaging instead of master messaging
|
||||
|
||||
---
|
||||
|
||||
## Component guidance
|
||||
Preferred section/component set:
|
||||
- HeroSection
|
||||
- ProblemSection
|
||||
- SolutionSection
|
||||
- ProductPreviewSection
|
||||
- BenefitsSection
|
||||
- HowItWorksSection
|
||||
- PersonalPageSection
|
||||
- TrustSection
|
||||
- FAQSection
|
||||
- FinalCTASection
|
||||
- Footer
|
||||
|
||||
Keep components modular and readable.
|
||||
Prefer explicit names over abstract names.
|
||||
|
||||
---
|
||||
|
||||
## denjs-ui usage rule
|
||||
Always check and use components from `denjs-ui` first.
|
||||
|
||||
Before creating any custom UI component:
|
||||
1. Search in `denjs-ui` (`list_components`, `search_components`, `get_component`).
|
||||
2. If a suitable component exists, use it.
|
||||
3. Create a custom component only when no suitable `denjs-ui` option exists.
|
||||
|
||||
---
|
||||
|
||||
## FSD architecture rule
|
||||
All new frontend work must follow Feature-Sliced Design (FSD).
|
||||
|
||||
Required layers:
|
||||
- `src/app` — app init, providers, global styles, routing setup
|
||||
- `src/pages` — route-level page composition only
|
||||
- `src/widgets` — large page blocks/sections (Hero, FAQ, CTA, etc.)
|
||||
- `src/features` — user scenarios and interactions (forms, actions, flows)
|
||||
- `src/entities` — business entities (`master`, `booking`, `service`, `client`)
|
||||
- `src/shared` — reusable UI, lib, config, api, assets
|
||||
|
||||
Rules:
|
||||
- Keep strict dependency direction: upper layers can use lower layers only.
|
||||
- Do not import across slices directly; use each slice public API (`index.ts`).
|
||||
- Do not keep business UI logic directly in route files when it can live in `widgets/features/entities`.
|
||||
- New sections/components for the landing should be created as FSD slices, not as flat files.
|
||||
|
||||
---
|
||||
|
||||
## Coding guidance
|
||||
When generating frontend code:
|
||||
- keep components small and reusable
|
||||
- keep responsive behavior clean
|
||||
- avoid unnecessary dependencies
|
||||
- avoid overengineering
|
||||
- keep styling tokens consistent
|
||||
- use one visual language across the whole landing
|
||||
- prefer simple structure over clever abstractions
|
||||
|
||||
---
|
||||
|
||||
## Decision filter
|
||||
Before adding any element, ask:
|
||||
- Does this make the product clearer?
|
||||
- Does this make Monie feel more personal for a master?
|
||||
- Does this increase trust?
|
||||
- Does this improve conversion?
|
||||
- Does this still avoid CRM/business-automation aesthetics?
|
||||
|
||||
If not, do not add it.
|
||||
|
||||
---
|
||||
|
||||
## One-sentence product definition
|
||||
Monie is a simple, beautiful booking and client page for independent masters.
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:24-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:24-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/.output ./.output
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Monie Landing
|
||||
|
||||
Marketing landing page for **Monie** — a personal booking page for independent masters.
|
||||
|
||||
## Stack
|
||||
|
||||
- TanStack Start + React
|
||||
- TypeScript
|
||||
- Tailwind CSS v4 + denjs-ui
|
||||
- Paraglide JS (`messages/ru.json`, `messages/en.json`)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build / Preview
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
`start` runs `vite preview` on `0.0.0.0:3000`.
|
||||
|
||||
## Quality checks
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run test
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Localization
|
||||
|
||||
- Project config: `project.inlang/settings.json`
|
||||
- Source messages: `messages/*.json`
|
||||
- Generated runtime: `src/paraglide/*`
|
||||
|
||||
Paraglide compile runs automatically via Vite plugin.
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep generated files in `src/paraglide` out of manual edits.
|
||||
- Keep landing sections aligned with `AGENTS.md` positioning and tone.
|
||||
11
TODO
Normal file
11
TODO
Normal file
@@ -0,0 +1,11 @@
|
||||
# denjs-ui gaps for monie-landing
|
||||
|
||||
- [ ] `LandingHero` — секция hero для лендинга с `eyebrow`, заголовком, подзаголовком, двумя CTA (как ссылки), слотом под метрики/preview.
|
||||
- [ ] `StatsGrid` / `StatCard` — компактные карточки метрик для маркетинговых экранов (`value`, `label`, `hint`).
|
||||
- [ ] `FeatureCard` (marketing variant) — единый стиль карточек преимуществ/проблем для лендингов (согласованные padding/radius/shadow).
|
||||
- [ ] `StepsTimeline` — линейный блок «как это работает» с шагами, номерами и адаптивным режимом mobile/desktop.
|
||||
- [ ] `PersonalPagePreview` — готовый блок превью профиля мастера (URL, услуги, слоты, CTA) без ручной верстки.
|
||||
- [ ] `TrustBlock` / `TestimonialCard` — секция доверия для одного специалиста: отзыв, маркеры надежности, микро-метрики.
|
||||
- [ ] `LeadFormSection` — готовая секция заявки/демо для лендинга с двухколоночным layout и мобильным стеком.
|
||||
- [ ] `LandingHeader` — шапка с поддержкой якорных ссылок, активного состояния роутов и CTA-кнопки (без enterprise dropdown-меню).
|
||||
- [ ] `LandingFooter` — настраиваемый футер под продуктовый лендинг (группы ссылок + legal + бренд), без жестко зашитых demo-данных.
|
||||
41
biome.json
Normal file
41
biome.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**/src/**/*",
|
||||
"**/.vscode/**/*",
|
||||
"**/index.html",
|
||||
"**/vite.config.ts",
|
||||
"!**/src/routeTree.gen.ts",
|
||||
"!**/src/paraglide/**/*",
|
||||
"!**/src/styles.css"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
17
instrument.server.mjs
Normal file
17
instrument.server.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as Sentry from '@sentry/tanstackstart-react'
|
||||
|
||||
const sentryDsn = import.meta.env?.VITE_SENTRY_DSN ?? process.env.VITE_SENTRY_DSN
|
||||
|
||||
if (!sentryDsn) {
|
||||
console.warn('VITE_SENTRY_DSN is not defined. Sentry is not running.')
|
||||
} else {
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
// Adds request headers and IP for users, for more info visit:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/tanstackstart-react/configuration/options/#sendDefaultPii
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 1.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
})
|
||||
}
|
||||
168
messages/en.json
Normal file
168
messages/en.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"switch_language_aria": "Switch language",
|
||||
"header_email_aria": "Email Monie",
|
||||
"theme_toggle_mode_auto": "Auto",
|
||||
"theme_toggle_mode_dark": "Dark",
|
||||
"theme_toggle_mode_light": "Light",
|
||||
"theme_toggle_aria_auto": "Theme mode: auto (system). Click to switch to light mode.",
|
||||
"theme_toggle_aria_dark": "Theme mode: dark. Click to switch to auto mode.",
|
||||
"theme_toggle_aria_light": "Theme mode: light. Click to switch to dark mode.",
|
||||
"seo_home_title": "Monie — personal master page with online booking",
|
||||
"seo_home_description": "Monie is a simple service for independent masters: personal page, online booking, calendar, clients, and income visibility in one place.",
|
||||
"seo_about_title": "About Monie — product for independent masters",
|
||||
"seo_about_description": "Monie helps independent masters keep client flow and accept online bookings through a personal page.",
|
||||
|
||||
"header_nav_problem": "Problem",
|
||||
"header_nav_solution": "Solution",
|
||||
"header_nav_how": "How it works",
|
||||
"header_nav_faq": "FAQ",
|
||||
"header_create_page": "Create page",
|
||||
"header_about": "About",
|
||||
|
||||
"footer_nav_benefits": "Benefits",
|
||||
"footer_nav_faq": "FAQ",
|
||||
"footer_description": "A simple system for independent masters: personal page, online booking, calendar, clients, and clear income visibility.",
|
||||
"footer_rights": "All rights reserved.",
|
||||
"footer_about": "About product",
|
||||
"footer_demo": "Demo",
|
||||
|
||||
"about_badge": "About Monie",
|
||||
"about_title": "We build around the master, not around the salon",
|
||||
"about_subtitle": "Monie started from a simple problem: strong masters lose client flow when they change workplace. We provide a stable digital base where profile, services, schedule, and client history belong to the master.",
|
||||
"about_focus_title": "Product focus",
|
||||
"about_focus_point_1": "Stable client base owned by the master",
|
||||
"about_focus_point_2": "Transparent online booking and calendar",
|
||||
"about_focus_point_3": "Professional personal page",
|
||||
"about_focus_point_4": "Lightweight income visibility without overload",
|
||||
"about_principle_title": "Architecture principle",
|
||||
"about_principle_text": "Critical business logic and validation live in the backend. Web and mobile clients sync with API as the single source of truth.",
|
||||
"about_cta": "Talk implementation",
|
||||
|
||||
"form_name_label": "Name",
|
||||
"form_name_placeholder": "Anna",
|
||||
"form_phone_label": "Phone",
|
||||
"form_specialization_label": "Specialization",
|
||||
"form_city_label": "City",
|
||||
"form_city_placeholder": "New York",
|
||||
"form_comment_label": "Comment",
|
||||
"form_comment_placeholder": "What booking task is most important for you right now?",
|
||||
"form_privacy_text": "By clicking the button, you agree to the processing of personal data.",
|
||||
"form_submit": "Send request",
|
||||
"form_submit_busy": "Opening your email app...",
|
||||
"form_submit_success": "Draft is ready. Please confirm sending in your email app.",
|
||||
"form_submit_error": "Could not open your email app. Please email hello@monie.app.",
|
||||
|
||||
"spec_nails": "Nail artist",
|
||||
"spec_brows": "Brow artist",
|
||||
"spec_lashes": "Lash maker",
|
||||
"spec_barber": "Barber",
|
||||
"spec_massage": "Massage specialist",
|
||||
"spec_other": "Other specialization",
|
||||
|
||||
"hero_badge": "Monie For Masters",
|
||||
"hero_title": "Your personal master page with online booking, clients, and income in one place.",
|
||||
"hero_subtitle": "Monie helps independent masters run bookings without chat chaos: profile, services, calendar, and client base in one clear interface.",
|
||||
"hero_cta_primary": "Create page",
|
||||
"hero_cta_secondary": "How it works",
|
||||
"hero_metric_label_1": "clients can book anytime",
|
||||
"hero_metric_label_2": "fewer no-shows after switching to Monie",
|
||||
"hero_metric_label_3": "repeat visits growth from your client base",
|
||||
|
||||
"problem_eyebrow": "Problem",
|
||||
"problem_title": "When bookings live in chats, daily work turns into chaos",
|
||||
"problem_subtitle": "Common tools for masters often add complexity and miss the main goal: getting clients booked quickly and calmly.",
|
||||
"problem_card_1_title": "Bookings get lost in chats",
|
||||
"problem_card_1_text": "Messenger and social inboxes get mixed up, time slots overlap, and clients struggle to pick a time.",
|
||||
"problem_card_2_title": "No unified client history",
|
||||
"problem_card_2_text": "Notes are scattered across apps and notebooks, making it hard to recall past visits and preferences quickly.",
|
||||
"problem_card_3_title": "Income feels unclear",
|
||||
"problem_card_3_text": "Without a clear view of visits and services, it is difficult to understand what your practice really earns.",
|
||||
|
||||
"solution_eyebrow": "Solution",
|
||||
"solution_title": "Monie brings your entire solo practice into one place",
|
||||
"solution_subtitle": "Personal profile, services, booking, and calendar without overloaded enterprise features.",
|
||||
"solution_item_1": "Personal master page with your style and a link like monie.app/your-name",
|
||||
"solution_item_2": "Online booking without endless chats and manual spreadsheets",
|
||||
"solution_item_3": "Simple calendar with all appointments in one place",
|
||||
"solution_item_4": "Client base with history and contacts",
|
||||
"solution_item_5": "Clear earnings overview by day and service",
|
||||
|
||||
"preview_eyebrow": "Product",
|
||||
"preview_title": "An interface that feels like a personal assistant",
|
||||
"preview_subtitle": "Monie looks like a polished personal page for a master, not a heavy CRM dashboard.",
|
||||
"preview_profile_title": "Master profile",
|
||||
"preview_profile_description": "Services, prices, available slots, and booking button are all available to clients right from your link.",
|
||||
"preview_service_1_title": "Manicure",
|
||||
"preview_service_1_description": "from $25",
|
||||
"preview_service_2_title": "Removal + gel polish",
|
||||
"preview_service_2_description": "2 h 15 min",
|
||||
"preview_schedule_title": "Calendar and clients",
|
||||
"preview_appt_1_title": "10:30 — Marina",
|
||||
"preview_appt_1_description": "Brow correction",
|
||||
"preview_appt_1_badge": "Confirmed",
|
||||
"preview_appt_2_title": "13:00 — Olga",
|
||||
"preview_appt_2_description": "Lash lamination",
|
||||
"preview_appt_2_badge": "New booking",
|
||||
"preview_income_title": "Weekly income",
|
||||
"preview_income_description": "$680",
|
||||
|
||||
"benefits_eyebrow": "Benefits",
|
||||
"benefits_title": "Monie helps you work calmer and earn more consistently",
|
||||
"benefit_card_1_title": "Professional online presence",
|
||||
"benefit_card_1_text": "Instead of scattered profiles and links, you get one polished page for your clients.",
|
||||
"benefit_card_2_title": "Less messaging routine",
|
||||
"benefit_card_2_text": "Clients book on their own, and you spend your time on services instead of chat coordination.",
|
||||
"benefit_card_3_title": "A clear working day",
|
||||
"benefit_card_3_text": "All appointments are in one calendar, without duplicates and missed visits.",
|
||||
"benefit_card_4_title": "Income visibility without reports",
|
||||
"benefit_card_4_text": "You instantly see your results and workload dynamics without complex analytics.",
|
||||
|
||||
"how_eyebrow": "How it works",
|
||||
"how_title": "Four steps from chat chaos to a clear booking flow",
|
||||
"how_step_label": "Step",
|
||||
"how_step_1_title": "Create your profile",
|
||||
"how_step_1_text": "Add photos, bio, services, and prices.",
|
||||
"how_step_2_title": "Open booking slots",
|
||||
"how_step_2_text": "Set your schedule and booking rules.",
|
||||
"how_step_3_title": "Share your link",
|
||||
"how_step_3_text": "Send your personal page in Instagram, Telegram, and direct messages.",
|
||||
"how_step_4_title": "Get bookings",
|
||||
"how_step_4_text": "Clients book online while you manage your day in one app.",
|
||||
|
||||
"personal_eyebrow": "Personal page",
|
||||
"personal_title": "A single link that strengthens your personal brand",
|
||||
"personal_subtitle": "Your Monie personal page looks professional and helps clients book you faster.",
|
||||
"personal_example": "Example: monie.app/anna-nails",
|
||||
"personal_card_title": "What you get",
|
||||
"personal_point_1": "One link for clients and social media",
|
||||
"personal_point_2": "Up-to-date services and prices without manual chat edits",
|
||||
"personal_point_3": "Booking in a couple of clicks without long messages",
|
||||
"personal_cta": "Create your page",
|
||||
|
||||
"trust_eyebrow": "Trust",
|
||||
"trust_title": "Built for masters who work for themselves",
|
||||
"trust_card_1_title": "Built for solo practice",
|
||||
"trust_card_1_text": "Monie is designed for one master, not for salon admin workflows.",
|
||||
"trust_card_2_title": "Works from your phone",
|
||||
"trust_card_2_text": "Manage bookings and clients throughout the day without a laptop.",
|
||||
"trust_card_3_title": "Fast setup, no onboarding maze",
|
||||
"trust_card_3_text": "You can configure your profile, services, and booking in one evening.",
|
||||
|
||||
"faq_eyebrow": "FAQ",
|
||||
"faq_title": "Frequently asked questions before you start",
|
||||
"faq_item_1_title": "Do I need a separate website?",
|
||||
"faq_item_1_text": "No. Monie gives you a personal master page. Share one link and clients can book online right away.",
|
||||
"faq_item_2_title": "Can I set my own services and prices?",
|
||||
"faq_item_2_text": "Yes. You manage services, duration, and pricing without a developer or technical team.",
|
||||
"faq_item_3_title": "Can clients really book on their own?",
|
||||
"faq_item_3_text": "Yes. The client picks a service and time slot, and the booking appears in your calendar instantly.",
|
||||
"faq_item_4_title": "Is this really for independent masters?",
|
||||
"faq_item_4_text": "Yes. Monie is positioned as a personal tool for masters, not a CRM for salons and multi-staff teams.",
|
||||
"faq_item_5_title": "Can I use it only from my phone?",
|
||||
"faq_item_5_text": "Yes. Core booking and daily workflow scenarios are fully available on mobile.",
|
||||
|
||||
"final_eyebrow": "Final step",
|
||||
"final_title": "Launch Monie and accept bookings without chaos",
|
||||
"final_subtitle": "Leave your contacts. We will show you how to set up your master profile and open online booking for your exact workflow.",
|
||||
"final_hint": "We reply within 1 business day"
|
||||
}
|
||||
168
messages/ru.json
Normal file
168
messages/ru.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"switch_language_aria": "Переключить язык",
|
||||
"header_email_aria": "Написать в Monie",
|
||||
"theme_toggle_mode_auto": "Авто",
|
||||
"theme_toggle_mode_dark": "Темная",
|
||||
"theme_toggle_mode_light": "Светлая",
|
||||
"theme_toggle_aria_auto": "Режим темы: авто (системный). Нажмите, чтобы переключить на светлую тему.",
|
||||
"theme_toggle_aria_dark": "Режим темы: темная. Нажмите, чтобы переключить на авто-режим.",
|
||||
"theme_toggle_aria_light": "Режим темы: светлая. Нажмите, чтобы переключить на темную тему.",
|
||||
"seo_home_title": "Monie — личная страница мастера с онлайн-записью",
|
||||
"seo_home_description": "Monie — простой сервис для частных мастеров: личная страница, онлайн-запись, календарь, клиенты и доход в одном месте.",
|
||||
"seo_about_title": "О Monie — продукт для частных мастеров",
|
||||
"seo_about_description": "Monie помогает частным мастерам сохранить клиентскую базу и принимать онлайн-запись через личную страницу.",
|
||||
|
||||
"header_nav_problem": "Проблема",
|
||||
"header_nav_solution": "Решение",
|
||||
"header_nav_how": "Как это работает",
|
||||
"header_nav_faq": "FAQ",
|
||||
"header_create_page": "Создать страницу",
|
||||
"header_about": "О проекте",
|
||||
|
||||
"footer_nav_benefits": "Преимущества",
|
||||
"footer_nav_faq": "FAQ",
|
||||
"footer_description": "Простая система для мастера: личная страница, онлайн-запись, календарь, клиенты и понятный обзор дохода.",
|
||||
"footer_rights": "All rights reserved.",
|
||||
"footer_about": "О продукте",
|
||||
"footer_demo": "Демо",
|
||||
|
||||
"about_badge": "О Monie",
|
||||
"about_title": "Мы строим продукт вокруг мастера, а не вокруг салона",
|
||||
"about_subtitle": "Monie появился из простой проблемы: сильные мастера теряют клиентский поток при смене места работы. Мы даем устойчивую цифровую основу, где профиль, услуги, расписание и клиентская история принадлежат мастеру.",
|
||||
"about_focus_title": "Продуктовый фокус",
|
||||
"about_focus_point_1": "Стабильная клиентская база у мастера",
|
||||
"about_focus_point_2": "Прозрачные онлайн-записи и календарь",
|
||||
"about_focus_point_3": "Профессиональная личная страница",
|
||||
"about_focus_point_4": "Легкий контроль дохода без перегруза",
|
||||
"about_principle_title": "Архитектурный принцип",
|
||||
"about_principle_text": "Критическая логика и валидация находятся в backend. Web и mobile клиенты синхронизируются с API как с единым источником истины.",
|
||||
"about_cta": "Обсудить внедрение",
|
||||
|
||||
"form_name_label": "Имя",
|
||||
"form_name_placeholder": "Анна",
|
||||
"form_phone_label": "Телефон",
|
||||
"form_specialization_label": "Специализация",
|
||||
"form_city_label": "Город",
|
||||
"form_city_placeholder": "Москва",
|
||||
"form_comment_label": "Комментарий",
|
||||
"form_comment_placeholder": "Какая задача с записью сейчас самая важная?",
|
||||
"form_privacy_text": "Нажимая кнопку, вы соглашаетесь на обработку персональных данных.",
|
||||
"form_submit": "Отправить заявку",
|
||||
"form_submit_busy": "Открываем почтовый клиент...",
|
||||
"form_submit_success": "Черновик письма открыт. Подтвердите отправку в почтовом приложении.",
|
||||
"form_submit_error": "Не удалось открыть почтовое приложение. Напишите на hello@monie.app.",
|
||||
|
||||
"spec_nails": "Нейл-мастер",
|
||||
"spec_brows": "Бровист",
|
||||
"spec_lashes": "Лэшмейкер",
|
||||
"spec_barber": "Барбер",
|
||||
"spec_massage": "Массажист",
|
||||
"spec_other": "Другая специализация",
|
||||
|
||||
"hero_badge": "Monie For Masters",
|
||||
"hero_title": "Личная страница мастера с онлайн-записью, клиентами и доходом в одном месте.",
|
||||
"hero_subtitle": "Monie помогает частному мастеру вести запись без хаоса в чатах: профиль, услуги, календарь и база клиентов собраны в одном понятном интерфейсе.",
|
||||
"hero_cta_primary": "Создать страницу",
|
||||
"hero_cta_secondary": "Как это работает",
|
||||
"hero_metric_label_1": "клиенты могут записаться в любое время",
|
||||
"hero_metric_label_2": "меньше пропусков после перехода в Monie",
|
||||
"hero_metric_label_3": "рост повторных визитов по базе клиентов",
|
||||
|
||||
"problem_eyebrow": "Проблема",
|
||||
"problem_title": "Когда запись живет в чатах, работа превращается в хаос",
|
||||
"problem_subtitle": "Обычные инструменты для мастера часто дают много лишнего и не решают главную задачу: быстро и спокойно принимать клиентов.",
|
||||
"problem_card_1_title": "Записи теряются в чатах",
|
||||
"problem_card_1_text": "Переписки в мессенджерах и соцсетях смешиваются, слоты пересекаются, клиентам сложно выбрать время.",
|
||||
"problem_card_2_title": "Нет единой истории клиентов",
|
||||
"problem_card_2_text": "Заметки разбросаны по телефонам и блокнотам, сложно быстро вспомнить предыдущий визит и предпочтения.",
|
||||
"problem_card_3_title": "Доход не под контролем",
|
||||
"problem_card_3_text": "Без понятной картины по визитам и услугам трудно видеть, сколько реально приносит ваша практика.",
|
||||
|
||||
"solution_eyebrow": "Решение",
|
||||
"solution_title": "Monie собирает всю практику мастера в одном месте",
|
||||
"solution_subtitle": "Личный профиль, услуги, запись и календарь без перегруза корпоративными функциями.",
|
||||
"solution_item_1": "Личная страница мастера с вашим стилем и ссылкой вида monie.app/ваш-ник",
|
||||
"solution_item_2": "Онлайн-запись без переписок и ручных таблиц",
|
||||
"solution_item_3": "Простой календарь со всеми визитами в одном месте",
|
||||
"solution_item_4": "База клиентов с историей и контактами",
|
||||
"solution_item_5": "Легкий обзор дохода по дням и услугам",
|
||||
|
||||
"preview_eyebrow": "Продукт",
|
||||
"preview_title": "Интерфейс, который ощущается как личный помощник",
|
||||
"preview_subtitle": "Monie выглядит как аккуратная персональная страница мастера, а не как тяжелая CRM-панель.",
|
||||
"preview_profile_title": "Профиль мастера",
|
||||
"preview_profile_description": "Услуги, цены, свободные окна и кнопка записи доступны клиенту сразу по вашей ссылке.",
|
||||
"preview_service_1_title": "Маникюр",
|
||||
"preview_service_1_description": "от 1 800 ₽",
|
||||
"preview_service_2_title": "Снятие + покрытие",
|
||||
"preview_service_2_description": "2 ч 15 мин",
|
||||
"preview_schedule_title": "Календарь и клиенты",
|
||||
"preview_appt_1_title": "10:30 — Марина",
|
||||
"preview_appt_1_description": "Коррекция бровей",
|
||||
"preview_appt_1_badge": "Подтверждено",
|
||||
"preview_appt_2_title": "13:00 — Ольга",
|
||||
"preview_appt_2_description": "Ламинирование ресниц",
|
||||
"preview_appt_2_badge": "Новая запись",
|
||||
"preview_income_title": "Доход за неделю",
|
||||
"preview_income_description": "47 800 ₽",
|
||||
|
||||
"benefits_eyebrow": "Преимущества",
|
||||
"benefits_title": "Monie помогает работать спокойнее и зарабатывать стабильнее",
|
||||
"benefit_card_1_title": "Профессиональный образ онлайн",
|
||||
"benefit_card_1_text": "Вместо хаоса в профилях и ссылках у вас одна аккуратная страница для клиентов.",
|
||||
"benefit_card_2_title": "Меньше рутины в переписках",
|
||||
"benefit_card_2_text": "Клиенты бронируют сами, а вы тратите время на работу, а не на координацию в сообщениях.",
|
||||
"benefit_card_3_title": "Понятный рабочий день",
|
||||
"benefit_card_3_text": "Все записи в одном календаре, без дублирования и пропущенных визитов.",
|
||||
"benefit_card_4_title": "Контроль дохода без отчетов",
|
||||
"benefit_card_4_text": "Вы сразу видите результат работы и динамику загрузки без сложной аналитики.",
|
||||
|
||||
"how_eyebrow": "Как это работает",
|
||||
"how_title": "Четыре шага от хаоса в чатах к понятной системе записи",
|
||||
"how_step_label": "Шаг",
|
||||
"how_step_1_title": "Создаете профиль",
|
||||
"how_step_1_text": "Добавляете фото, описание, услуги и цены.",
|
||||
"how_step_2_title": "Открываете слоты",
|
||||
"how_step_2_text": "Настраиваете удобное расписание и правила записи.",
|
||||
"how_step_3_title": "Делитесь ссылкой",
|
||||
"how_step_3_text": "Отправляете личную страницу в Instagram, Telegram и клиентам.",
|
||||
"how_step_4_title": "Получаете записи",
|
||||
"how_step_4_text": "Клиенты бронируют онлайн, а вы ведете день в одном приложении.",
|
||||
|
||||
"personal_eyebrow": "Личная страница",
|
||||
"personal_title": "Ссылка, которая работает на ваш личный бренд",
|
||||
"personal_subtitle": "Ваша персональная страница в Monie выглядит профессионально и помогает клиентам быстрее записываться.",
|
||||
"personal_example": "Пример: monie.app/anna-nails",
|
||||
"personal_card_title": "Что получает мастер",
|
||||
"personal_point_1": "Единая ссылка для всех клиентов и соцсетей",
|
||||
"personal_point_2": "Актуальные услуги и цены без ручных правок в чатах",
|
||||
"personal_point_3": "Запись в пару кликов без переписки",
|
||||
"personal_cta": "Создать свою страницу",
|
||||
|
||||
"trust_eyebrow": "Доверие",
|
||||
"trust_title": "Сделано для мастеров, которые работают сами на себя",
|
||||
"trust_card_1_title": "Подходит для соло-практики",
|
||||
"trust_card_1_text": "Monie сделан для одного мастера, а не для админ-панели салона.",
|
||||
"trust_card_2_title": "Работает с телефона",
|
||||
"trust_card_2_text": "Можно управлять записью и клиентами в течение дня без ноутбука.",
|
||||
"trust_card_3_title": "Быстрый старт без внедрения",
|
||||
"trust_card_3_text": "Профиль, услуги и запись можно настроить за один вечер.",
|
||||
|
||||
"faq_eyebrow": "FAQ",
|
||||
"faq_title": "Частые вопросы перед стартом",
|
||||
"faq_item_1_title": "Нужен ли мне отдельный сайт?",
|
||||
"faq_item_1_text": "Нет. Monie дает вам личную страницу мастера. Вы просто делитесь ссылкой, и клиенты записываются онлайн.",
|
||||
"faq_item_2_title": "Могу ли я сама настроить услуги и цены?",
|
||||
"faq_item_2_text": "Да. Вы управляете услугами, длительностью и стоимостью без разработчика и технической команды.",
|
||||
"faq_item_3_title": "Клиенты действительно смогут записываться сами?",
|
||||
"faq_item_3_text": "Да. Клиент выбирает услугу и время, а запись сразу появляется в вашем календаре.",
|
||||
"faq_item_4_title": "Это точно продукт для частного мастера?",
|
||||
"faq_item_4_text": "Да. Monie позиционируется как личный инструмент мастера, а не как CRM для салонов и сетей.",
|
||||
"faq_item_5_title": "Можно пользоваться только с телефона?",
|
||||
"faq_item_5_text": "Да. Основные сценарии работы и записи доступны с мобильного устройства.",
|
||||
|
||||
"final_eyebrow": "Финальный шаг",
|
||||
"final_title": "Запустите Monie и принимайте записи без хаоса",
|
||||
"final_subtitle": "Оставьте контакты. Покажем, как быстро собрать профиль мастера и открыть онлайн-запись именно под вашу практику.",
|
||||
"final_hint": "Ответим в течение 1 рабочего дня"
|
||||
}
|
||||
8431
package-lock.json
generated
Normal file
8431
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "monie-landing",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.local -- sh -c \"NODE_OPTIONS='--import ./instrument.server.mjs' vite dev --port 3000\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"format": "biome format",
|
||||
"lint": "biome lint",
|
||||
"check": "biome check",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@posthog/react": "^1.7.0",
|
||||
"@sentry/tanstackstart-react": "^10.42.0",
|
||||
"@t3-oss/env-core": "^0.13.10",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "latest",
|
||||
"@tanstack/react-form": "latest",
|
||||
"@tanstack/react-router": "latest",
|
||||
"@tanstack/react-router-devtools": "latest",
|
||||
"@tanstack/react-router-ssr-query": "latest",
|
||||
"@tanstack/react-start": "latest",
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"denjs-ui": "file:../denjs-ui-0.0.0.tgz",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"posthog-js": "^1.358.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.5",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/devtools-vite": "latest",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"lightningcss"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
project.inlang/settings.json
Normal file
12
project.inlang/settings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "ru",
|
||||
"locales": ["ru", "en"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
20
public/Logo.svg
Normal file
20
public/Logo.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="222.000000pt" height="132.000000pt" viewBox="0 0 222.000000 132.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,132.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1480 1297 c-19 -6 -53 -29 -75 -52 -139 -139 -40 -375 157 -375 85
|
||||
0 154 45 193 125 70 142 -23 300 -182 311 -32 2 -74 -2 -93 -9z"/>
|
||||
<path d="M643 1115 c-104 -29 -183 -94 -266 -220 -145 -221 -241 -488 -301
|
||||
-837 l-7 -38 168 0 168 0 23 107 c73 334 260 783 327 783 21 0 28 -22 65 -196
|
||||
85 -397 166 -574 299 -652 41 -24 56 -27 136 -27 74 0 98 4 132 22 69 38 160
|
||||
131 228 235 36 54 94 143 131 198 62 95 86 116 97 87 10 -25 28 -234 35 -392
|
||||
l7 -160 143 -3 142 -3 0 63 c0 133 -45 440 -77 522 -27 68 -85 139 -139 166
|
||||
-39 20 -64 25 -124 25 -136 0 -206 -53 -386 -295 -113 -151 -146 -185 -168
|
||||
-177 -30 12 -56 73 -87 207 -82 356 -130 456 -254 535 -89 57 -200 76 -292 50z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
20
public/favicon-dark.svg
Normal file
20
public/favicon-dark.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="222.000000pt" height="132.000000pt" viewBox="0 0 222.000000 132.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,132.000000) scale(0.100000,-0.100000)"
|
||||
fill="#FFFFFF" stroke="none">
|
||||
<path d="M1480 1297 c-19 -6 -53 -29 -75 -52 -139 -139 -40 -375 157 -375 85
|
||||
0 154 45 193 125 70 142 -23 300 -182 311 -32 2 -74 -2 -93 -9z"/>
|
||||
<path d="M643 1115 c-104 -29 -183 -94 -266 -220 -145 -221 -241 -488 -301
|
||||
-837 l-7 -38 168 0 168 0 23 107 c73 334 260 783 327 783 21 0 28 -22 65 -196
|
||||
85 -397 166 -574 299 -652 41 -24 56 -27 136 -27 74 0 98 4 132 22 69 38 160
|
||||
131 228 235 36 54 94 143 131 198 62 95 86 116 97 87 10 -25 28 -234 35 -392
|
||||
l7 -160 143 -3 142 -3 0 63 c0 133 -45 440 -77 522 -27 68 -85 139 -139 166
|
||||
-39 20 -64 25 -124 25 -136 0 -206 -53 -386 -295 -113 -151 -146 -185 -168
|
||||
-177 -30 12 -56 73 -87 207 -82 356 -130 456 -254 535 -89 57 -200 76 -292 50z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
20
public/favicon-light.svg
Normal file
20
public/favicon-light.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="222.000000pt" height="132.000000pt" viewBox="0 0 222.000000 132.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,132.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1480 1297 c-19 -6 -53 -29 -75 -52 -139 -139 -40 -375 157 -375 85
|
||||
0 154 45 193 125 70 142 -23 300 -182 311 -32 2 -74 -2 -93 -9z"/>
|
||||
<path d="M643 1115 c-104 -29 -183 -94 -266 -220 -145 -221 -241 -488 -301
|
||||
-837 l-7 -38 168 0 168 0 23 107 c73 334 260 783 327 783 21 0 28 -22 65 -196
|
||||
85 -397 166 -574 299 -652 41 -24 56 -27 136 -27 74 0 98 4 132 22 69 38 160
|
||||
131 228 235 36 54 94 143 131 198 62 95 86 116 97 87 10 -25 28 -234 35 -392
|
||||
l7 -160 143 -3 142 -3 0 63 c0 133 -45 440 -77 522 -27 68 -85 139 -139 166
|
||||
-39 20 -64 25 -124 25 -136 0 -206 -53 -386 -295 -113 -151 -146 -185 -168
|
||||
-177 -30 12 -56 73 -87 207 -82 356 -130 456 -254 535 -89 57 -200 76 -292 50z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
9
public/lower_left.svg
Normal file
9
public/lower_left.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 218 78" width="218" height="78">
|
||||
<g fill="#000000" fill-rule="evenodd">
|
||||
<path d="M 167 39 L 166 40 L 166 47 L 165 48 L 165 53 L 164 54 L 164 59 L 163 60 L 163 61 L 164 62 L 164 63 L 166 65 L 171 65 L 172 64 L 174 64 L 175 63 L 174 62 L 172 62 L 171 61 L 171 56 L 172 55 L 172 49 L 173 48 L 173 43 L 174 42 L 174 41 L 173 40 L 173 39 Z"/>
|
||||
<path d="M 130 39 L 129 40 L 129 48 L 128 49 L 128 54 L 127 55 L 127 60 L 126 61 L 126 64 L 133 64 L 133 63 L 134 62 L 134 56 L 135 55 L 135 51 L 142 44 L 143 44 L 144 43 L 148 43 L 149 44 L 149 45 L 150 46 L 150 48 L 149 49 L 149 53 L 148 54 L 148 58 L 147 59 L 147 63 L 149 65 L 154 65 L 155 64 L 157 64 L 158 63 L 157 63 L 155 61 L 155 55 L 156 54 L 156 49 L 157 48 L 157 43 L 156 42 L 156 41 L 154 39 L 148 39 L 147 40 L 145 40 L 143 42 L 142 42 L 141 43 L 140 43 L 138 45 L 138 46 L 137 47 L 136 46 L 136 40 L 135 39 Z"/>
|
||||
<path d="M 196 38 L 195 39 L 191 39 L 190 40 L 189 40 L 188 41 L 187 41 L 181 47 L 181 48 L 180 49 L 180 51 L 179 52 L 179 58 L 180 59 L 180 60 L 183 63 L 184 63 L 185 64 L 187 64 L 188 65 L 195 65 L 196 64 L 199 64 L 200 63 L 201 63 L 202 62 L 203 62 L 205 60 L 206 60 L 206 59 L 205 59 L 204 60 L 203 60 L 202 61 L 201 61 L 200 62 L 198 62 L 197 63 L 192 63 L 191 62 L 190 62 L 187 59 L 187 55 L 186 54 L 188 52 L 196 52 L 197 51 L 200 51 L 201 50 L 203 50 L 207 46 L 207 43 L 206 42 L 206 41 L 205 40 L 204 40 L 203 39 L 200 39 L 199 38 Z M 194 40 L 199 40 L 201 42 L 201 46 L 197 50 L 196 50 L 195 51 L 188 51 L 187 50 L 187 49 L 188 48 L 188 47 L 189 46 L 189 45 Z"/>
|
||||
<path d="M 108 38 L 107 39 L 103 39 L 102 40 L 101 40 L 100 41 L 99 41 L 98 42 L 97 42 L 96 43 L 96 44 L 94 46 L 94 47 L 93 48 L 93 49 L 92 50 L 92 57 L 93 58 L 93 59 L 94 60 L 94 61 L 95 62 L 96 62 L 98 64 L 101 64 L 102 65 L 109 65 L 110 64 L 113 64 L 114 63 L 115 63 L 120 58 L 120 57 L 121 56 L 121 55 L 122 54 L 122 47 L 121 46 L 121 44 L 117 40 L 116 40 L 115 39 L 112 39 L 111 38 Z M 106 41 L 107 40 L 111 40 L 114 43 L 114 44 L 115 45 L 115 53 L 114 54 L 114 56 L 113 57 L 113 58 L 112 59 L 112 60 L 109 63 L 108 63 L 107 64 L 105 64 L 104 63 L 103 63 L 100 60 L 100 49 L 101 48 L 101 46 L 102 45 L 102 44 L 105 41 Z"/>
|
||||
<path d="M 173 23 L 172 24 L 170 24 L 169 25 L 169 26 L 168 27 L 168 30 L 170 32 L 173 32 L 174 31 L 175 31 L 177 29 L 177 25 L 176 24 L 175 24 L 174 23 Z"/>
|
||||
<path d="M 52 10 L 51 11 L 47 11 L 46 12 L 45 12 L 44 13 L 43 13 L 41 15 L 40 15 L 30 25 L 30 26 L 27 29 L 27 30 L 25 32 L 25 33 L 23 35 L 23 36 L 22 37 L 22 38 L 21 39 L 21 40 L 19 42 L 19 43 L 18 44 L 18 45 L 17 46 L 17 47 L 16 48 L 16 49 L 15 50 L 15 51 L 14 52 L 14 54 L 13 55 L 13 56 L 12 57 L 12 58 L 11 59 L 11 61 L 10 62 L 10 65 L 11 66 L 13 66 L 14 67 L 18 67 L 19 66 L 21 66 L 22 65 L 23 65 L 25 63 L 25 61 L 26 60 L 26 59 L 27 58 L 27 56 L 28 55 L 28 53 L 29 52 L 29 50 L 30 49 L 30 47 L 31 46 L 31 45 L 32 44 L 32 43 L 33 42 L 33 41 L 34 40 L 34 39 L 35 38 L 35 37 L 36 36 L 36 35 L 37 34 L 37 33 L 39 31 L 39 30 L 41 28 L 41 27 L 47 21 L 48 22 L 48 25 L 47 26 L 47 29 L 46 30 L 46 32 L 45 33 L 45 36 L 44 37 L 44 40 L 43 41 L 43 45 L 42 46 L 42 54 L 43 55 L 43 57 L 46 60 L 47 60 L 48 61 L 53 61 L 54 60 L 55 60 L 57 58 L 58 58 L 59 57 L 59 56 L 62 53 L 62 52 L 63 51 L 63 50 L 65 48 L 65 47 L 66 46 L 66 45 L 68 43 L 68 42 L 69 41 L 69 40 L 71 38 L 71 37 L 73 35 L 73 34 L 76 31 L 76 30 L 79 27 L 80 27 L 81 28 L 80 29 L 80 31 L 79 32 L 79 34 L 78 35 L 78 37 L 77 38 L 77 40 L 76 41 L 76 44 L 75 45 L 75 48 L 74 49 L 74 54 L 73 55 L 73 57 L 74 58 L 74 61 L 75 62 L 75 63 L 77 65 L 78 65 L 79 66 L 81 66 L 82 67 L 90 67 L 91 66 L 89 66 L 88 65 L 87 65 L 85 63 L 85 62 L 84 61 L 84 50 L 85 49 L 85 46 L 86 45 L 86 42 L 87 41 L 87 38 L 88 37 L 88 34 L 89 33 L 89 31 L 90 30 L 90 27 L 91 26 L 91 23 L 92 22 L 92 19 L 90 17 L 84 17 L 83 18 L 82 18 L 80 20 L 79 20 L 73 26 L 73 27 L 69 31 L 69 32 L 66 35 L 66 36 L 64 38 L 64 39 L 62 41 L 62 42 L 59 45 L 59 46 L 56 49 L 55 49 L 54 48 L 54 47 L 53 46 L 53 44 L 54 43 L 54 38 L 55 37 L 55 34 L 56 33 L 56 31 L 57 30 L 57 27 L 58 26 L 58 24 L 59 23 L 59 19 L 60 18 L 60 16 L 59 15 L 59 13 L 58 12 L 57 12 L 56 11 L 53 11 Z"/>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
28
public/manifest.json
Normal file
28
public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"short_name": "Monie",
|
||||
"name": "Monie — booking page for independent masters",
|
||||
"icons": [
|
||||
{
|
||||
"src": "Logo.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "favicon-light.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "favicon-dark.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#4fb8b2",
|
||||
"background_color": "#e7f3ec"
|
||||
}
|
||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
23
public/theme-init.js
Normal file
23
public/theme-init.js
Normal file
@@ -0,0 +1,23 @@
|
||||
(function () {
|
||||
try {
|
||||
var stored = window.localStorage.getItem("theme");
|
||||
var mode =
|
||||
stored === "light" || stored === "dark" || stored === "auto"
|
||||
? stored
|
||||
: "auto";
|
||||
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var resolved = mode === "auto" ? (prefersDark ? "dark" : "light") : mode;
|
||||
var root = document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolved);
|
||||
|
||||
if (mode === "auto") {
|
||||
root.removeAttribute("data-theme");
|
||||
} else {
|
||||
root.setAttribute("data-theme", mode);
|
||||
}
|
||||
|
||||
root.style.colorScheme = resolved;
|
||||
} catch {}
|
||||
})();
|
||||
2
src/app/providers/index.ts
Normal file
2
src/app/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LocaleProvider } from "#/shared/lib/i18n";
|
||||
export { PostHogProvider } from "./posthog-provider";
|
||||
20
src/app/providers/posthog-provider.tsx
Normal file
20
src/app/providers/posthog-provider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PostHogProvider as BasePostHogProvider } from "@posthog/react";
|
||||
import posthog from "posthog-js";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
if (typeof window !== "undefined" && import.meta.env.VITE_POSTHOG_KEY) {
|
||||
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
|
||||
api_host: import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
capture_pageview: false,
|
||||
defaults: "2025-11-30",
|
||||
});
|
||||
}
|
||||
|
||||
interface PostHogProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PostHogProvider({ children }: PostHogProviderProps) {
|
||||
return <BasePostHogProvider client={posthog}>{children}</BasePostHogProvider>;
|
||||
}
|
||||
269
src/app/styles/index.css
Normal file
269
src/app/styles/index.css
Normal file
@@ -0,0 +1,269 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||
@import "denjs-ui/styles.css";
|
||||
@import "tailwindcss";
|
||||
@import "../../styles/tokens.css";
|
||||
@import "../../styles/light.css";
|
||||
@import "../../styles/dark.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sea-ink: #173a40;
|
||||
--sea-ink-soft: #416166;
|
||||
--lagoon: #4fb8b2;
|
||||
--lagoon-deep: #328f97;
|
||||
--coral: #e68061;
|
||||
--palm: #2f6a4a;
|
||||
--sand: #e7f0e8;
|
||||
--foam: #f3faf5;
|
||||
--glass-surface: rgba(255, 255, 255, 0.74);
|
||||
--surface-strong: rgba(255, 255, 255, 0.9);
|
||||
--line: rgba(23, 58, 64, 0.14);
|
||||
--inset-glint: rgba(255, 255, 255, 0.82);
|
||||
--kicker: rgba(47, 106, 74, 0.9);
|
||||
--bg-base: #e7f3ec;
|
||||
--header-bg: rgba(251, 255, 248, 0.84);
|
||||
--chip-bg: rgba(255, 255, 255, 0.8);
|
||||
--chip-line: rgba(47, 106, 74, 0.18);
|
||||
--link-bg-hover: rgba(255, 255, 255, 0.9);
|
||||
--hero-a: rgba(79, 184, 178, 0.36);
|
||||
--hero-b: rgba(47, 106, 74, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--sea-ink: #d7ece8;
|
||||
--sea-ink-soft: #afcdc8;
|
||||
--lagoon: #60d7cf;
|
||||
--lagoon-deep: #8de5db;
|
||||
--palm: #6ec89a;
|
||||
--sand: #0f1a1e;
|
||||
--foam: #101d22;
|
||||
--glass-surface: rgba(16, 30, 34, 0.8);
|
||||
--surface-strong: rgba(15, 27, 31, 0.92);
|
||||
--line: rgba(141, 229, 219, 0.18);
|
||||
--inset-glint: rgba(194, 247, 238, 0.14);
|
||||
--kicker: #b8efe5;
|
||||
--bg-base: #0a1418;
|
||||
--header-bg: rgba(10, 20, 24, 0.8);
|
||||
--chip-bg: rgba(13, 28, 32, 0.9);
|
||||
--chip-line: rgba(141, 229, 219, 0.24);
|
||||
--link-bg-hover: rgba(24, 44, 49, 0.8);
|
||||
--hero-a: rgba(96, 215, 207, 0.18);
|
||||
--hero-b: rgba(110, 200, 154, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--sea-ink: #d7ece8;
|
||||
--sea-ink-soft: #afcdc8;
|
||||
--lagoon: #60d7cf;
|
||||
--lagoon-deep: #8de5db;
|
||||
--palm: #6ec89a;
|
||||
--sand: #0f1a1e;
|
||||
--foam: #101d22;
|
||||
--glass-surface: rgba(16, 30, 34, 0.8);
|
||||
--surface-strong: rgba(15, 27, 31, 0.92);
|
||||
--line: rgba(141, 229, 219, 0.18);
|
||||
--inset-glint: rgba(194, 247, 238, 0.14);
|
||||
--kicker: #b8efe5;
|
||||
--bg-base: #0a1418;
|
||||
--header-bg: rgba(10, 20, 24, 0.8);
|
||||
--chip-bg: rgba(13, 28, 32, 0.9);
|
||||
--chip-line: rgba(141, 229, 219, 0.24);
|
||||
--link-bg-hover: rgba(24, 44, 49, 0.8);
|
||||
--hero-a: rgba(96, 215, 207, 0.18);
|
||||
--hero-b: rgba(110, 200, 154, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 86px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--sea-ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
|
||||
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
|
||||
radial-gradient(
|
||||
720px 380px at 50% 115%,
|
||||
rgba(79, 184, 178, 0.1),
|
||||
transparent 68%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--sand) 68%, white) 0%,
|
||||
var(--foam) 44%,
|
||||
var(--bg-base) 100%
|
||||
);
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.28;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 15%,
|
||||
rgba(255, 255, 255, 0.8),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
|
||||
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.14;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
|
||||
}
|
||||
|
||||
a:not([class]) {
|
||||
color: var(--lagoon-deep);
|
||||
text-decoration-color: rgba(50, 143, 151, 0.4);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:not([class]):hover {
|
||||
color: #246f76;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
|
||||
border-radius: 7px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
pre code {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
width: min(1080px, calc(100% - 2rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.display-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
}
|
||||
|
||||
.island-shell {
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
var(--surface-strong),
|
||||
var(--glass-surface)
|
||||
);
|
||||
box-shadow:
|
||||
0 1px 0 var(--inset-glint) inset,
|
||||
0 22px 44px rgba(30, 90, 72, 0.1),
|
||||
0 6px 18px rgba(23, 58, 64, 0.08);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
color-mix(in oklab, var(--surface-strong) 93%, white 7%),
|
||||
var(--glass-surface)
|
||||
);
|
||||
box-shadow:
|
||||
0 1px 0 var(--inset-glint) inset,
|
||||
0 18px 34px rgba(30, 90, 72, 0.1),
|
||||
0 4px 14px rgba(23, 58, 64, 0.06);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid color-mix(in oklab, var(--line) 82%, white 18%);
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
color-mix(in oklab, white 82%, var(--lagoon) 18%),
|
||||
color-mix(in oklab, white 90%, var(--coral) 10%)
|
||||
);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.85) inset,
|
||||
0 16px 28px rgba(23, 58, 64, 0.09);
|
||||
}
|
||||
|
||||
button,
|
||||
.island-shell,
|
||||
a {
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.island-kicker {
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
font-size: 0.69rem;
|
||||
color: var(--kicker);
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.rise-in {
|
||||
animation: rise-in 560ms var(--ease) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rise-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
1
src/entities/landing/index.ts
Normal file
1
src/entities/landing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./model/content";
|
||||
148
src/entities/landing/model/content.ts
Normal file
148
src/entities/landing/model/content.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { m } from "#/paraglide/messages";
|
||||
|
||||
type Messages = typeof m;
|
||||
|
||||
export type MetricItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ContentCardItem = {
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type StepItem = {
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type FaqItem = {
|
||||
value: string;
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function getHeroMetrics(messages: Messages): MetricItem[] {
|
||||
return [
|
||||
{ value: "24/7", label: messages.hero_metric_label_1() },
|
||||
{ value: "-18%", label: messages.hero_metric_label_2() },
|
||||
{ value: "+26%", label: messages.hero_metric_label_3() },
|
||||
];
|
||||
}
|
||||
|
||||
export function getProblems(messages: Messages): ContentCardItem[] {
|
||||
return [
|
||||
{
|
||||
title: messages.problem_card_1_title(),
|
||||
text: messages.problem_card_1_text(),
|
||||
},
|
||||
{
|
||||
title: messages.problem_card_2_title(),
|
||||
text: messages.problem_card_2_text(),
|
||||
},
|
||||
{
|
||||
title: messages.problem_card_3_title(),
|
||||
text: messages.problem_card_3_text(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getSolutionItems(messages: Messages): string[] {
|
||||
return [
|
||||
messages.solution_item_1(),
|
||||
messages.solution_item_2(),
|
||||
messages.solution_item_3(),
|
||||
messages.solution_item_4(),
|
||||
messages.solution_item_5(),
|
||||
];
|
||||
}
|
||||
|
||||
export function getBenefits(messages: Messages): ContentCardItem[] {
|
||||
return [
|
||||
{
|
||||
title: messages.benefit_card_1_title(),
|
||||
text: messages.benefit_card_1_text(),
|
||||
},
|
||||
{
|
||||
title: messages.benefit_card_2_title(),
|
||||
text: messages.benefit_card_2_text(),
|
||||
},
|
||||
{
|
||||
title: messages.benefit_card_3_title(),
|
||||
text: messages.benefit_card_3_text(),
|
||||
},
|
||||
{
|
||||
title: messages.benefit_card_4_title(),
|
||||
text: messages.benefit_card_4_text(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getSteps(messages: Messages): StepItem[] {
|
||||
return [
|
||||
{
|
||||
title: messages.how_step_1_title(),
|
||||
text: messages.how_step_1_text(),
|
||||
},
|
||||
{
|
||||
title: messages.how_step_2_title(),
|
||||
text: messages.how_step_2_text(),
|
||||
},
|
||||
{
|
||||
title: messages.how_step_3_title(),
|
||||
text: messages.how_step_3_text(),
|
||||
},
|
||||
{
|
||||
title: messages.how_step_4_title(),
|
||||
text: messages.how_step_4_text(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getTrustItems(messages: Messages): ContentCardItem[] {
|
||||
return [
|
||||
{
|
||||
title: messages.trust_card_1_title(),
|
||||
text: messages.trust_card_1_text(),
|
||||
},
|
||||
{
|
||||
title: messages.trust_card_2_title(),
|
||||
text: messages.trust_card_2_text(),
|
||||
},
|
||||
{
|
||||
title: messages.trust_card_3_title(),
|
||||
text: messages.trust_card_3_text(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getFaqItems(messages: Messages): FaqItem[] {
|
||||
return [
|
||||
{
|
||||
value: "need-website",
|
||||
title: messages.faq_item_1_title(),
|
||||
content: messages.faq_item_1_text(),
|
||||
},
|
||||
{
|
||||
value: "services-prices",
|
||||
title: messages.faq_item_2_title(),
|
||||
content: messages.faq_item_2_text(),
|
||||
},
|
||||
{
|
||||
value: "online-booking",
|
||||
title: messages.faq_item_3_title(),
|
||||
content: messages.faq_item_3_text(),
|
||||
},
|
||||
{
|
||||
value: "solo-only",
|
||||
title: messages.faq_item_4_title(),
|
||||
content: messages.faq_item_4_text(),
|
||||
},
|
||||
{
|
||||
value: "mobile",
|
||||
title: messages.faq_item_5_title(),
|
||||
content: messages.faq_item_5_text(),
|
||||
},
|
||||
];
|
||||
}
|
||||
39
src/env.ts
Normal file
39
src/env.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createEnv } from "@t3-oss/env-core";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
SERVER_URL: z.string().url().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* The prefix that client-side variables must have. This is enforced both at
|
||||
* a type-level and at runtime.
|
||||
*/
|
||||
clientPrefix: "VITE_",
|
||||
|
||||
client: {
|
||||
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* What object holds the environment variables at runtime. This is usually
|
||||
* `process.env` or `import.meta.env`.
|
||||
*/
|
||||
runtimeEnv: import.meta.env,
|
||||
|
||||
/**
|
||||
* By default, this library will feed the environment variables directly to
|
||||
* the Zod validator.
|
||||
*
|
||||
* This means that if you have an empty string for a value that is supposed
|
||||
* to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag
|
||||
* it as a type mismatch violation. Additionally, if you have an empty string
|
||||
* for a value that is supposed to be a string with a default value (e.g.
|
||||
* `DOMAIN=` in an ".env" file), the default value will never be applied.
|
||||
*
|
||||
* In order to solve these issues, we recommend that all new projects
|
||||
* explicitly specify this option as true.
|
||||
*/
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
1
src/features/change-language/index.ts
Normal file
1
src/features/change-language/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LanguageSwitcher } from "./ui/language-switcher";
|
||||
26
src/features/change-language/ui/language-switcher.tsx
Normal file
26
src/features/change-language/ui/language-switcher.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ToggleGroup } from "denjs-ui";
|
||||
import { useLocale, useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const m = useMessages();
|
||||
const { locale, setLocale } = useLocale();
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={locale}
|
||||
aria-label={m.switch_language_aria()}
|
||||
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] p-0.5"
|
||||
items={[
|
||||
{ value: "ru", label: "RU" },
|
||||
{ value: "en", label: "EN" },
|
||||
]}
|
||||
onValueChange={async (value) => {
|
||||
if (value === "ru" || value === "en") {
|
||||
await setLocale(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
src/features/request-demo/index.ts
Normal file
1
src/features/request-demo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RequestDemoForm } from "./ui/request-demo-form";
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDemoRequestMailto } from "./build-demo-request-mailto";
|
||||
|
||||
function parseMailtoQuery(mailtoHref: string) {
|
||||
const query = mailtoHref.split("?")[1] ?? "";
|
||||
const params = new URLSearchParams(query);
|
||||
|
||||
return {
|
||||
subject: params.get("subject"),
|
||||
body: params.get("body"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDemoRequestMailto", () => {
|
||||
it("builds a mailto link with normalized payload fields", () => {
|
||||
const mailtoHref = buildDemoRequestMailto({
|
||||
name: " Anna ",
|
||||
phone: " +1 555 1234 ",
|
||||
specialization: "Nail artist",
|
||||
city: "Moscow",
|
||||
comment: "Need migration help",
|
||||
locale: "ru",
|
||||
pageUrl: "https://monie.app/",
|
||||
});
|
||||
const { subject, body } = parseMailtoQuery(mailtoHref);
|
||||
|
||||
expect(mailtoHref.startsWith("mailto:hello@monie.app?")).toBe(true);
|
||||
expect(subject).toBe("Monie demo request - Anna");
|
||||
expect(body).toContain("Name: Anna");
|
||||
expect(body).toContain("Phone: +1 555 1234");
|
||||
expect(body).toContain("Specialization: Nail artist");
|
||||
expect(body).toContain("City: Moscow");
|
||||
expect(body).toContain("Comment: Need migration help");
|
||||
expect(body).toContain("Locale: ru");
|
||||
expect(body).toContain("Page: https://monie.app/");
|
||||
});
|
||||
|
||||
it("uses dash fallback for empty fields and keeps base subject", () => {
|
||||
const mailtoHref = buildDemoRequestMailto({
|
||||
name: " ",
|
||||
phone: " ",
|
||||
specialization: " ",
|
||||
city: "",
|
||||
comment: "",
|
||||
locale: "",
|
||||
pageUrl: "",
|
||||
});
|
||||
const { subject, body } = parseMailtoQuery(mailtoHref);
|
||||
|
||||
expect(subject).toBe("Monie demo request");
|
||||
expect(body).toContain("Name: -");
|
||||
expect(body).toContain("Phone: -");
|
||||
expect(body).toContain("Specialization: -");
|
||||
expect(body).toContain("City: -");
|
||||
expect(body).toContain("Comment: -");
|
||||
expect(body).toContain("Locale: -");
|
||||
expect(body).toContain("Page: -");
|
||||
});
|
||||
});
|
||||
36
src/features/request-demo/model/build-demo-request-mailto.ts
Normal file
36
src/features/request-demo/model/build-demo-request-mailto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
type DemoRequestMailtoPayload = {
|
||||
name: string;
|
||||
phone: string;
|
||||
specialization: string;
|
||||
city: string;
|
||||
comment: string;
|
||||
locale: string;
|
||||
pageUrl: string;
|
||||
};
|
||||
|
||||
function normalizeField(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : "-";
|
||||
}
|
||||
|
||||
export function buildDemoRequestMailto(
|
||||
payload: DemoRequestMailtoPayload,
|
||||
): string {
|
||||
const name = payload.name.trim();
|
||||
const subject = encodeURIComponent(
|
||||
`Monie demo request${name ? ` - ${name}` : ""}`,
|
||||
);
|
||||
const body = encodeURIComponent(
|
||||
[
|
||||
`Name: ${normalizeField(payload.name)}`,
|
||||
`Phone: ${normalizeField(payload.phone)}`,
|
||||
`Specialization: ${normalizeField(payload.specialization)}`,
|
||||
`City: ${normalizeField(payload.city)}`,
|
||||
`Comment: ${normalizeField(payload.comment)}`,
|
||||
`Locale: ${normalizeField(payload.locale)}`,
|
||||
`Page: ${normalizeField(payload.pageUrl)}`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
return `mailto:hello@monie.app?subject=${subject}&body=${body}`;
|
||||
}
|
||||
14
src/features/request-demo/model/specialization-options.ts
Normal file
14
src/features/request-demo/model/specialization-options.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { m } from "#/paraglide/messages";
|
||||
|
||||
type Messages = typeof m;
|
||||
|
||||
export function getSpecializationOptions(messages: Messages) {
|
||||
return [
|
||||
{ value: "nails", label: messages.spec_nails() },
|
||||
{ value: "brows", label: messages.spec_brows() },
|
||||
{ value: "lashes", label: messages.spec_lashes() },
|
||||
{ value: "barber", label: messages.spec_barber() },
|
||||
{ value: "massage", label: messages.spec_massage() },
|
||||
{ value: "other", label: messages.spec_other() },
|
||||
];
|
||||
}
|
||||
150
src/features/request-demo/ui/request-demo-form.tsx
Normal file
150
src/features/request-demo/ui/request-demo-form.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Button, Field, Input, NativeSelect, Text, Textarea } from "denjs-ui";
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
import { buildDemoRequestMailto } from "../model/build-demo-request-mailto";
|
||||
import { getSpecializationOptions } from "../model/specialization-options";
|
||||
|
||||
export function RequestDemoForm() {
|
||||
const m = useMessages();
|
||||
const [submitState, setSubmitState] = useState<
|
||||
"idle" | "sending" | "success" | "error"
|
||||
>("idle");
|
||||
const options = useMemo(() => getSpecializationOptions(m), [m]);
|
||||
const specializationByValue = useMemo(
|
||||
() => Object.fromEntries(options.map((item) => [item.value, item.label])),
|
||||
[options],
|
||||
);
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (typeof window === "undefined") {
|
||||
setSubmitState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitState("sending");
|
||||
|
||||
try {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const phone = String(formData.get("phone") ?? "").trim();
|
||||
const specializationValue = String(
|
||||
formData.get("specialization") ?? "",
|
||||
).trim();
|
||||
const city = String(formData.get("city") ?? "").trim();
|
||||
const comment = String(formData.get("comment") ?? "").trim();
|
||||
const specialization =
|
||||
specializationByValue[specializationValue] ?? specializationValue;
|
||||
|
||||
window.location.href = buildDemoRequestMailto({
|
||||
name,
|
||||
phone,
|
||||
specialization,
|
||||
city,
|
||||
comment,
|
||||
locale: document.documentElement.lang,
|
||||
pageUrl: window.location.href,
|
||||
});
|
||||
setSubmitState("success");
|
||||
} catch {
|
||||
setSubmitState("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="rise-in grid gap-3 rounded-2xl border border-[var(--line)] bg-white/75 p-4 sm:grid-cols-2 sm:p-5"
|
||||
style={{ animationDelay: "220ms" }}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Field label={m.form_name_label()} controlId="demo-name" required>
|
||||
<Input
|
||||
id="demo-name"
|
||||
name="name"
|
||||
placeholder={m.form_name_placeholder()}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={m.form_phone_label()} controlId="demo-phone" required>
|
||||
<Input
|
||||
id="demo-phone"
|
||||
type="tel"
|
||||
name="phone"
|
||||
placeholder="+7 (999) 000-00-00"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={m.form_specialization_label()}
|
||||
controlId="demo-specialization"
|
||||
required
|
||||
>
|
||||
<NativeSelect
|
||||
id="demo-specialization"
|
||||
name="specialization"
|
||||
options={options}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={m.form_city_label()} controlId="demo-city">
|
||||
<Input
|
||||
id="demo-city"
|
||||
name="city"
|
||||
placeholder={m.form_city_placeholder()}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={m.form_comment_label()}
|
||||
controlId="demo-comment"
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Textarea
|
||||
id="demo-comment"
|
||||
name="comment"
|
||||
rows={4}
|
||||
placeholder={m.form_comment_placeholder()}
|
||||
resizable={false}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2 flex flex-wrap items-center justify-between gap-3 pt-1">
|
||||
<Text
|
||||
as="p"
|
||||
size="sm"
|
||||
tone="muted"
|
||||
className="m-0 max-w-[36ch] text-xs text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{m.form_privacy_text()}
|
||||
</Text>
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
className="rounded-full"
|
||||
disabled={submitState === "sending"}
|
||||
>
|
||||
{submitState === "sending" ? m.form_submit_busy() : m.form_submit()}
|
||||
</Button>
|
||||
|
||||
{submitState === "success" && (
|
||||
<Text
|
||||
as="p"
|
||||
size="sm"
|
||||
className="m-0 w-full text-xs text-[var(--palm)]"
|
||||
>
|
||||
{m.form_submit_success()}
|
||||
</Text>
|
||||
)}
|
||||
{submitState === "error" && (
|
||||
<Text
|
||||
as="p"
|
||||
size="sm"
|
||||
className="m-0 w-full text-xs text-[var(--coral)]"
|
||||
>
|
||||
{m.form_submit_error()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
92
src/features/theme-toggle/ThemeToggle.tsx
Normal file
92
src/features/theme-toggle/ThemeToggle.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
import {
|
||||
getNextThemeMode,
|
||||
parseThemeMode,
|
||||
resolveThemeMode,
|
||||
type ThemeMode,
|
||||
} from "./model/theme-mode";
|
||||
|
||||
function getInitialMode(): ThemeMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return parseThemeMode(window.localStorage.getItem("theme"));
|
||||
}
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
const resolved = resolveThemeMode(
|
||||
mode,
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||
);
|
||||
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(resolved);
|
||||
|
||||
if (mode === "auto") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", mode);
|
||||
}
|
||||
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const m = useMessages();
|
||||
const [mode, setMode] = useState<ThemeMode>("auto");
|
||||
|
||||
useEffect(() => {
|
||||
const initialMode = getInitialMode();
|
||||
setMode(initialMode);
|
||||
applyThemeMode(initialMode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "auto") {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => applyThemeMode("auto");
|
||||
|
||||
media.addEventListener("change", onChange);
|
||||
return () => {
|
||||
media.removeEventListener("change", onChange);
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
function toggleMode() {
|
||||
const nextMode = getNextThemeMode(mode);
|
||||
setMode(nextMode);
|
||||
applyThemeMode(nextMode);
|
||||
window.localStorage.setItem("theme", nextMode);
|
||||
}
|
||||
|
||||
const label =
|
||||
mode === "auto"
|
||||
? m.theme_toggle_aria_auto()
|
||||
: mode === "dark"
|
||||
? m.theme_toggle_aria_dark()
|
||||
: m.theme_toggle_aria_light();
|
||||
|
||||
const modeLabel =
|
||||
mode === "auto"
|
||||
? m.theme_toggle_mode_auto()
|
||||
: mode === "dark"
|
||||
? m.theme_toggle_mode_dark()
|
||||
: m.theme_toggle_mode_light();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
|
||||
>
|
||||
{modeLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
29
src/features/theme-toggle/model/theme-mode.test.ts
Normal file
29
src/features/theme-toggle/model/theme-mode.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNextThemeMode,
|
||||
parseThemeMode,
|
||||
resolveThemeMode,
|
||||
} from "./theme-mode";
|
||||
|
||||
describe("theme-mode helpers", () => {
|
||||
it("parses stored mode and falls back to auto for unknown values", () => {
|
||||
expect(parseThemeMode("light")).toBe("light");
|
||||
expect(parseThemeMode("dark")).toBe("dark");
|
||||
expect(parseThemeMode("auto")).toBe("auto");
|
||||
expect(parseThemeMode("night")).toBe("auto");
|
||||
expect(parseThemeMode(null)).toBe("auto");
|
||||
});
|
||||
|
||||
it("resolves auto mode based on system preference", () => {
|
||||
expect(resolveThemeMode("auto", true)).toBe("dark");
|
||||
expect(resolveThemeMode("auto", false)).toBe("light");
|
||||
expect(resolveThemeMode("dark", false)).toBe("dark");
|
||||
expect(resolveThemeMode("light", true)).toBe("light");
|
||||
});
|
||||
|
||||
it("cycles modes in expected order", () => {
|
||||
expect(getNextThemeMode("light")).toBe("dark");
|
||||
expect(getNextThemeMode("dark")).toBe("auto");
|
||||
expect(getNextThemeMode("auto")).toBe("light");
|
||||
});
|
||||
});
|
||||
33
src/features/theme-toggle/model/theme-mode.ts
Normal file
33
src/features/theme-toggle/model/theme-mode.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type ThemeMode = "light" | "dark" | "auto";
|
||||
type ResolvedThemeMode = Exclude<ThemeMode, "auto">;
|
||||
|
||||
export function parseThemeMode(value: string | null | undefined): ThemeMode {
|
||||
if (value === "light" || value === "dark" || value === "auto") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "auto";
|
||||
}
|
||||
|
||||
export function resolveThemeMode(
|
||||
mode: ThemeMode,
|
||||
prefersDark: boolean,
|
||||
): ResolvedThemeMode {
|
||||
if (mode === "auto") {
|
||||
return prefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function getNextThemeMode(mode: ThemeMode): ThemeMode {
|
||||
if (mode === "light") {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
if (mode === "dark") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return "light";
|
||||
}
|
||||
1
src/pages/about/index.ts
Normal file
1
src/pages/about/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AboutPage } from "./ui/about-page";
|
||||
67
src/pages/about/ui/about-page.tsx
Normal file
67
src/pages/about/ui/about-page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Badge, Button, Card, SectionHeader, Text } from "denjs-ui";
|
||||
import { scrollToSectionOrNavigateHome } from "#/shared/lib/dom";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function AboutPage() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<main className="page-wrap px-4 py-12 sm:py-14">
|
||||
<Card padding="lg" className="island-shell rounded-2xl sm:p-8">
|
||||
<Badge
|
||||
color="teal"
|
||||
rounded="full"
|
||||
bordered
|
||||
className="mb-3 w-fit px-3 py-1 text-[11px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
{m.about_badge()}
|
||||
</Badge>
|
||||
|
||||
<SectionHeader
|
||||
title={m.about_title()}
|
||||
subtitle={m.about_subtitle()}
|
||||
align="left"
|
||||
titleAs="h1"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<section className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Card
|
||||
padding="lg"
|
||||
title={m.about_focus_title()}
|
||||
className="island-shell rounded-2xl"
|
||||
>
|
||||
<ul className="m-0 space-y-2 pl-5 text-sm leading-7 text-[var(--sea-ink-soft)]">
|
||||
<li>{m.about_focus_point_1()}</li>
|
||||
<li>{m.about_focus_point_2()}</li>
|
||||
<li>{m.about_focus_point_3()}</li>
|
||||
<li>{m.about_focus_point_4()}</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
title={m.about_principle_title()}
|
||||
className="island-shell rounded-2xl"
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
tone="muted"
|
||||
className="m-0 text-sm leading-7 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{m.about_principle_text()}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
size="large"
|
||||
className="mt-5 rounded-full"
|
||||
onClick={() => scrollToSectionOrNavigateHome("demo")}
|
||||
>
|
||||
{m.about_cta()}
|
||||
</Button>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
1
src/pages/home/index.ts
Normal file
1
src/pages/home/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HomePage } from "./ui/home-page";
|
||||
29
src/pages/home/ui/home-page.tsx
Normal file
29
src/pages/home/ui/home-page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
BenefitsSection,
|
||||
FaqSection,
|
||||
FinalCtaSection,
|
||||
HeroSection,
|
||||
HowItWorksSection,
|
||||
PersonalPageSection,
|
||||
ProblemSection,
|
||||
ProductPreviewSection,
|
||||
SolutionSection,
|
||||
TrustSection,
|
||||
} from "#/widgets/landing";
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<main className="page-wrap px-4 pb-16 pt-10 sm:pt-14">
|
||||
<HeroSection />
|
||||
<ProblemSection />
|
||||
<SolutionSection />
|
||||
<ProductPreviewSection />
|
||||
<BenefitsSection />
|
||||
<HowItWorksSection />
|
||||
<PersonalPageSection />
|
||||
<TrustSection />
|
||||
<FaqSection />
|
||||
<FinalCtaSection />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
86
src/routeTree.gen.ts
Normal file
86
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about'
|
||||
id: '__root__' | '/' | '/about'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
19
src/router.tsx
Normal file
19
src/router.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
75
src/routes/__root.tsx
Normal file
75
src/routes/__root.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { Footer, FooterSimpleRow } from "denjs-ui";
|
||||
import { LocaleProvider, PostHogProvider } from "#/app/providers";
|
||||
import appCss from "#/app/styles/index.css?url";
|
||||
import { m } from "#/paraglide/messages";
|
||||
import { getLocale } from "#/paraglide/runtime";
|
||||
import { Header } from "#/widgets/layout";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: "utf-8",
|
||||
},
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
{
|
||||
title: m.seo_home_title(),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: m.seo_home_description(),
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/svg+xml",
|
||||
href: "/favicon-light.svg",
|
||||
id: "app-favicon",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang={getLocale()} suppressHydrationWarning>
|
||||
<head>
|
||||
<script src="/theme-init.js" />
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="font-sans antialiased wrap-anywhere">
|
||||
<PostHogProvider>
|
||||
<LocaleProvider>
|
||||
<Header />
|
||||
{children}
|
||||
<FooterSimpleRow />
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: "bottom-right",
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: "Tanstack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</LocaleProvider>
|
||||
</PostHogProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
18
src/routes/about.tsx
Normal file
18
src/routes/about.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AboutPage } from "#/pages/about";
|
||||
import { m } from "#/paraglide/messages";
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: m.seo_about_title(),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: m.seo_about_description(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: AboutPage,
|
||||
});
|
||||
6
src/routes/index.tsx
Normal file
6
src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { HomePage } from "#/pages/home";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomePage,
|
||||
});
|
||||
1
src/shared/lib/dom/index.ts
Normal file
1
src/shared/lib/dom/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./scroll-to-section";
|
||||
33
src/shared/lib/dom/scroll-to-section.ts
Normal file
33
src/shared/lib/dom/scroll-to-section.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function scrollToSection(id: string) {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = document.getElementById(id);
|
||||
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
section.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
export function scrollToSectionOrNavigateHome(id: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = `/#${id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const section = document.getElementById(id);
|
||||
|
||||
if (!section) {
|
||||
window.location.hash = `#${id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
section.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
1
src/shared/lib/i18n/index.ts
Normal file
1
src/shared/lib/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./locale-context";
|
||||
125
src/shared/lib/i18n/locale-context.tsx
Normal file
125
src/shared/lib/i18n/locale-context.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useRouterState } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { m } from "#/paraglide/messages";
|
||||
import type { locales } from "#/paraglide/runtime";
|
||||
import {
|
||||
getLocale,
|
||||
setLocale as setParaglideLocale,
|
||||
} from "#/paraglide/runtime";
|
||||
import { resolveSeoPage } from "./seo";
|
||||
|
||||
type Locale = (typeof locales)[number];
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => Promise<void>;
|
||||
};
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
function getCurrentLocale(): Locale {
|
||||
return getLocale() as Locale;
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(getCurrentLocale);
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
});
|
||||
|
||||
const setLocale = useCallback(async (nextLocale: Locale) => {
|
||||
await setParaglideLocale(nextLocale, { reload: false });
|
||||
setLocaleState(getCurrentLocale());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = locale;
|
||||
const seoPage = resolveSeoPage(pathname);
|
||||
document.title =
|
||||
seoPage === "about" ? m.seo_about_title() : m.seo_home_title();
|
||||
const descriptionMeta = document.querySelector(
|
||||
'meta[name="description"]',
|
||||
);
|
||||
if (descriptionMeta) {
|
||||
descriptionMeta.setAttribute(
|
||||
"content",
|
||||
seoPage === "about"
|
||||
? m.seo_about_description()
|
||||
: m.seo_home_description(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [locale, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined" || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.querySelector<HTMLLinkElement>("link#app-favicon");
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyFavicon = () => {
|
||||
const explicitTheme = document.documentElement.getAttribute("data-theme");
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
const isDark =
|
||||
explicitTheme === "dark" || (explicitTheme !== "light" && prefersDark);
|
||||
link.href = isDark ? "/favicon-dark.svg" : "/favicon-light.svg";
|
||||
};
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const observer = new MutationObserver(applyFavicon);
|
||||
|
||||
applyFavicon();
|
||||
media.addEventListener("change", applyFavicon);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
media.removeEventListener("change", applyFavicon);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
}),
|
||||
[locale, setLocale],
|
||||
);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const context = useContext(LocaleContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useLocale must be used within LocaleProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useMessages() {
|
||||
useLocale();
|
||||
return m;
|
||||
}
|
||||
33
src/shared/lib/i18n/messages-parity.test.ts
Normal file
33
src/shared/lib/i18n/messages-parity.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function readMessages(locale: "ru" | "en") {
|
||||
const filepath = resolve(process.cwd(), "messages", `${locale}.json`);
|
||||
return JSON.parse(readFileSync(filepath, "utf-8")) as Record<string, string>;
|
||||
}
|
||||
|
||||
describe("message catalogs", () => {
|
||||
it("have the same keyset for ru and en", () => {
|
||||
const ru = readMessages("ru");
|
||||
const en = readMessages("en");
|
||||
|
||||
const ruKeys = Object.keys(ru).sort();
|
||||
const enKeys = Object.keys(en).sort();
|
||||
|
||||
expect(ruKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it("have non-empty translations", () => {
|
||||
const catalogs = [readMessages("ru"), readMessages("en")];
|
||||
|
||||
for (const catalog of catalogs) {
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
expect(
|
||||
value.trim().length,
|
||||
`Empty translation for key: ${key}`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
15
src/shared/lib/i18n/seo.test.ts
Normal file
15
src/shared/lib/i18n/seo.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSeoPage } from "./seo";
|
||||
|
||||
describe("resolveSeoPage", () => {
|
||||
it("returns about page for /about and nested about routes", () => {
|
||||
expect(resolveSeoPage("/about")).toBe("about");
|
||||
expect(resolveSeoPage("/about/team")).toBe("about");
|
||||
});
|
||||
|
||||
it("returns home page for non-about routes", () => {
|
||||
expect(resolveSeoPage("/")).toBe("home");
|
||||
expect(resolveSeoPage("/pricing")).toBe("home");
|
||||
expect(resolveSeoPage("/aboutness")).toBe("home");
|
||||
});
|
||||
});
|
||||
9
src/shared/lib/i18n/seo.ts
Normal file
9
src/shared/lib/i18n/seo.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type SeoPage = "home" | "about";
|
||||
|
||||
export function resolveSeoPage(pathname: string): SeoPage {
|
||||
if (pathname === "/about" || pathname.startsWith("/about/")) {
|
||||
return "about";
|
||||
}
|
||||
|
||||
return "home";
|
||||
}
|
||||
26
src/shared/ui/lower-left-logo.tsx
Normal file
26
src/shared/ui/lower-left-logo.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type LowerLeftLogoProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
export function LowerLeftLogo({ className, ...props }: LowerLeftLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 218 78"
|
||||
width="218"
|
||||
height="78"
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Monie</title>
|
||||
<g fill="currentColor" fillRule="evenodd">
|
||||
<path d="M 167 39 L 166 40 L 166 47 L 165 48 L 165 53 L 164 54 L 164 59 L 163 60 L 163 61 L 164 62 L 164 63 L 166 65 L 171 65 L 172 64 L 174 64 L 175 63 L 174 62 L 172 62 L 171 61 L 171 56 L 172 55 L 172 49 L 173 48 L 173 43 L 174 42 L 174 41 L 173 40 L 173 39 Z" />
|
||||
<path d="M 130 39 L 129 40 L 129 48 L 128 49 L 128 54 L 127 55 L 127 60 L 126 61 L 126 64 L 133 64 L 133 63 L 134 62 L 134 56 L 135 55 L 135 51 L 142 44 L 143 44 L 144 43 L 148 43 L 149 44 L 149 45 L 150 46 L 150 48 L 149 49 L 149 53 L 148 54 L 148 58 L 147 59 L 147 63 L 149 65 L 154 65 L 155 64 L 157 64 L 158 63 L 157 63 L 155 61 L 155 55 L 156 54 L 156 49 L 157 48 L 157 43 L 156 42 L 156 41 L 154 39 L 148 39 L 147 40 L 145 40 L 143 42 L 142 42 L 141 43 L 140 43 L 138 45 L 138 46 L 137 47 L 136 46 L 136 40 L 135 39 Z" />
|
||||
<path d="M 196 38 L 195 39 L 191 39 L 190 40 L 189 40 L 188 41 L 187 41 L 181 47 L 181 48 L 180 49 L 180 51 L 179 52 L 179 58 L 180 59 L 180 60 L 183 63 L 184 63 L 185 64 L 187 64 L 188 65 L 195 65 L 196 64 L 199 64 L 200 63 L 201 63 L 202 62 L 203 62 L 205 60 L 206 60 L 206 59 L 205 59 L 204 60 L 203 60 L 202 61 L 201 61 L 200 62 L 198 62 L 197 63 L 192 63 L 191 62 L 190 62 L 187 59 L 187 55 L 186 54 L 188 52 L 196 52 L 197 51 L 200 51 L 201 50 L 203 50 L 207 46 L 207 43 L 206 42 L 206 41 L 205 40 L 204 40 L 203 39 L 200 39 L 199 38 Z M 194 40 L 199 40 L 201 42 L 201 46 L 197 50 L 196 50 L 195 51 L 188 51 L 187 50 L 187 49 L 188 48 L 188 47 L 189 46 L 189 45 Z" />
|
||||
<path d="M 108 38 L 107 39 L 103 39 L 102 40 L 101 40 L 100 41 L 99 41 L 98 42 L 97 42 L 96 43 L 96 44 L 94 46 L 94 47 L 93 48 L 93 49 L 92 50 L 92 57 L 93 58 L 93 59 L 94 60 L 94 61 L 95 62 L 96 62 L 98 64 L 101 64 L 102 65 L 109 65 L 110 64 L 113 64 L 114 63 L 115 63 L 120 58 L 120 57 L 121 56 L 121 55 L 122 54 L 122 47 L 121 46 L 121 44 L 117 40 L 116 40 L 115 39 L 112 39 L 111 38 Z M 106 41 L 107 40 L 111 40 L 114 43 L 114 44 L 115 45 L 115 53 L 114 54 L 114 56 L 113 57 L 113 58 L 112 59 L 112 60 L 109 63 L 108 63 L 107 64 L 105 64 L 104 63 L 103 63 L 100 60 L 100 49 L 101 48 L 101 46 L 102 45 L 102 44 L 105 41 Z" />
|
||||
<path d="M 173 23 L 172 24 L 170 24 L 169 25 L 169 26 L 168 27 L 168 30 L 170 32 L 173 32 L 174 31 L 175 31 L 177 29 L 177 25 L 176 24 L 175 24 L 174 23 Z" />
|
||||
<path d="M 52 10 L 51 11 L 47 11 L 46 12 L 45 12 L 44 13 L 43 13 L 41 15 L 40 15 L 30 25 L 30 26 L 27 29 L 27 30 L 25 32 L 25 33 L 23 35 L 23 36 L 22 37 L 22 38 L 21 39 L 21 40 L 19 42 L 19 43 L 18 44 L 18 45 L 17 46 L 17 47 L 16 48 L 16 49 L 15 50 L 15 51 L 14 52 L 14 54 L 13 55 L 13 56 L 12 57 L 12 58 L 11 59 L 11 61 L 10 62 L 10 65 L 11 66 L 13 66 L 14 67 L 18 67 L 19 66 L 21 66 L 22 65 L 23 65 L 25 63 L 25 61 L 26 60 L 26 59 L 27 58 L 27 56 L 28 55 L 28 53 L 29 52 L 29 50 L 30 49 L 30 47 L 31 46 L 31 45 L 32 44 L 32 43 L 33 42 L 33 41 L 34 40 L 34 39 L 35 38 L 35 37 L 36 36 L 36 35 L 37 34 L 37 33 L 39 31 L 39 30 L 41 28 L 41 27 L 47 21 L 48 22 L 48 25 L 47 26 L 47 29 L 46 30 L 46 32 L 45 33 L 45 36 L 44 37 L 44 40 L 43 41 L 43 45 L 42 46 L 42 54 L 43 55 L 43 57 L 46 60 L 47 60 L 48 61 L 53 61 L 54 60 L 55 60 L 57 58 L 58 58 L 59 57 L 59 56 L 62 53 L 62 52 L 63 51 L 63 50 L 65 48 L 65 47 L 66 46 L 66 45 L 68 43 L 68 42 L 69 41 L 69 40 L 71 38 L 71 37 L 73 35 L 73 34 L 76 31 L 76 30 L 79 27 L 80 27 L 81 28 L 80 29 L 80 31 L 79 32 L 79 34 L 78 35 L 78 37 L 77 38 L 77 40 L 76 41 L 76 44 L 75 45 L 75 48 L 74 49 L 74 54 L 73 55 L 73 57 L 74 58 L 74 61 L 75 62 L 75 63 L 77 65 L 78 65 L 79 66 L 81 66 L 82 67 L 90 67 L 91 66 L 89 66 L 88 65 L 87 65 L 85 63 L 85 62 L 84 61 L 84 50 L 85 49 L 85 46 L 86 45 L 86 42 L 87 41 L 87 38 L 88 37 L 88 34 L 89 33 L 89 31 L 90 30 L 90 27 L 91 26 L 91 23 L 92 22 L 92 19 L 90 17 L 84 17 L 83 18 L 82 18 L 80 20 L 79 20 L 73 26 L 73 27 L 69 31 L 69 32 L 66 35 L 66 36 L 64 38 L 64 39 L 62 41 L 62 42 L 59 45 L 59 46 L 56 49 L 55 49 L 54 48 L 54 47 L 53 46 L 53 44 L 54 43 L 54 38 L 55 37 L 55 34 L 56 33 L 56 31 L 57 30 L 57 27 L 58 26 L 58 24 L 59 23 L 59 19 L 60 18 L 60 16 L 59 15 L 59 13 L 58 12 L 57 12 L 56 11 L 53 11 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
93
src/styles/dark.css
Normal file
93
src/styles/dark.css
Normal file
@@ -0,0 +1,93 @@
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #000000; /* --geist-background in dark */
|
||||
--foreground: #ffffff; /* --geist-foreground in dark */
|
||||
|
||||
--surface: #111111; /* --accents-1 */
|
||||
--surface-muted: #111111; /* --accents-1 */
|
||||
--surface-hover: #333333; /* --accents-2 */
|
||||
|
||||
--border: #333333; /* --accents-2 */
|
||||
--input: #000000;
|
||||
--input-border: #333333;
|
||||
|
||||
--muted: #111111; /* --accents-1 */
|
||||
--muted-foreground: #888888; /* --accents-5 */
|
||||
|
||||
/* In dark, primary often uses a light button */
|
||||
--primary: #fafafa; /* --accents-8 */
|
||||
--primary-foreground: #000000;
|
||||
--primary-hover: #eaeaea; /* --accents-7 */
|
||||
--primary-muted: #333333; /* --accents-2 */
|
||||
|
||||
--accent: #3291ff; /* --geist-success-light */
|
||||
--accent-foreground: #000000;
|
||||
--accent-text: #3291ff;
|
||||
|
||||
--card: var(--surface);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--secondary: #111111;
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--ring: #3291ff;
|
||||
--focus: #3291ff;
|
||||
|
||||
--success: #3291ff;
|
||||
--success-foreground: #000000;
|
||||
|
||||
--warning: #f7b955; /* --geist-warning-light */
|
||||
--warning-foreground: #000000;
|
||||
|
||||
--danger: #ff1a1a; /* --geist-error-light */
|
||||
--danger-foreground: #000000;
|
||||
|
||||
--disabled: rgba(255, 255, 255, 0.1);
|
||||
--disabled-foreground: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Optional: OS auto-dark when no explicit theme set */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #000000;
|
||||
--foreground: #ffffff;
|
||||
|
||||
--surface: #111111;
|
||||
--surface-muted: #111111;
|
||||
--surface-hover: #333333;
|
||||
|
||||
--border: #333333;
|
||||
--input: #000000;
|
||||
--input-border: #333333;
|
||||
|
||||
--muted: #111111;
|
||||
--muted-foreground: #888888;
|
||||
|
||||
--primary: #fafafa;
|
||||
--primary-foreground: #000000;
|
||||
--primary-hover: #eaeaea;
|
||||
--primary-muted: #333333;
|
||||
|
||||
--accent: #3291ff;
|
||||
--accent-foreground: #000000;
|
||||
--accent-text: #3291ff;
|
||||
|
||||
--card: var(--surface);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--secondary: #111111;
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--ring: #3291ff;
|
||||
--focus: #3291ff;
|
||||
}
|
||||
}
|
||||
4
src/styles/index.css
Normal file
4
src/styles/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "./tokens.css";
|
||||
@import "./light.css";
|
||||
@import "./dark.css";
|
||||
70
src/styles/light.css
Normal file
70
src/styles/light.css
Normal file
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* ===== Core surfaces ===== */
|
||||
--background: #ffffff; /* --geist-background */
|
||||
--foreground: #000000; /* --geist-foreground */
|
||||
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #fafafa; /* --accents-1 */
|
||||
--surface-hover: #eaeaea; /* --accents-2 */
|
||||
|
||||
/* ===== Borders / inputs ===== */
|
||||
--border: #eaeaea; /* --accents-2 */
|
||||
--input: #ffffff;
|
||||
--input-border: #eaeaea;
|
||||
|
||||
/* ===== Text tokens ===== */
|
||||
--muted: #fafafa; /* --accents-1 */
|
||||
--muted-foreground: #666666; /* --accents-5 */
|
||||
|
||||
/* ===== Brand tokens (Next/Vercel) ===== */
|
||||
--primary: #111111; /* close to black UI: --accents-8 */
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-hover: #333333; /* --accents-7 */
|
||||
--primary-muted: #eaeaea; /* --accents-2 */
|
||||
|
||||
--accent: #0070f3; /* --geist-success */
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-text: #0070f3;
|
||||
|
||||
/* ===== Semantic component tokens (shadcn-like) ===== */
|
||||
--card: var(--surface);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--secondary: #fafafa; /* --accents-1 */
|
||||
--secondary-foreground: #111111;
|
||||
|
||||
--ring: #0070f3; /* --geist-success */
|
||||
--focus: #0070f3;
|
||||
|
||||
/* ===== States (Geist palette) ===== */
|
||||
--success: #0070f3; /* Geist "success" is blue */
|
||||
--success-foreground: #ffffff;
|
||||
|
||||
--warning: #f5a623;
|
||||
--warning-foreground: #111111;
|
||||
|
||||
--danger: #ee0000; /* --geist-error */
|
||||
--danger-foreground: #ffffff;
|
||||
|
||||
/* Disabled */
|
||||
--disabled: rgba(0, 0, 0, 0.08);
|
||||
--disabled-foreground: rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* ===== Data viz (Geist highlights) ===== */
|
||||
--chart-1: #60a5fa; /* light blue */
|
||||
--chart-2: #c084fc; /* light violet */
|
||||
--chart-3: #34d399; /* mint */
|
||||
--chart-4: #f472b6; /* pink */
|
||||
--chart-5: #fbbf24; /* amber */
|
||||
--chart-6: #22d3ee; /* sky/cyan */
|
||||
/* ===== Motion ===== */
|
||||
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--dur-1: 120ms;
|
||||
--dur-2: 200ms;
|
||||
--dur-3: 320ms;
|
||||
}
|
||||
62
src/styles/tokens.css
Normal file
62
src/styles/tokens.css
Normal file
@@ -0,0 +1,62 @@
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
@theme {
|
||||
/* colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-surface: var(--surface);
|
||||
--color-surfaceMuted: var(--surface-muted);
|
||||
--color-surfaceHover: var(--surface-hover);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-border: var(--input-border);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-mutedForeground: var(--muted-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primaryForeground: var(--primary-foreground);
|
||||
--color-primaryHover: var(--primary-hover);
|
||||
--color-primaryMuted: var(--primary-muted);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accentForeground: var(--accent-foreground);
|
||||
--color-accentText: var(--accent-text);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-cardForeground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popoverForeground: var(--popover-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondaryForeground: var(--secondary-foreground);
|
||||
|
||||
--color-success: var(--success);
|
||||
--color-successForeground: var(--success-foreground);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warningForeground: var(--warning-foreground);
|
||||
|
||||
--color-danger: var(--danger);
|
||||
--color-dangerForeground: var(--danger-foreground);
|
||||
|
||||
--color-ring: var(--ring);
|
||||
--color-focus: var(--focus);
|
||||
|
||||
/* motion */
|
||||
--ease: var(--ease);
|
||||
--dur-1: var(--dur-1);
|
||||
--dur-2: var(--dur-2);
|
||||
--dur-3: var(--dur-3);
|
||||
|
||||
/* charts */
|
||||
--chart-1: var(--chart-1);
|
||||
--chart-2: var(--chart-2);
|
||||
--chart-3: var(--chart-3);
|
||||
--chart-4: var(--chart-4);
|
||||
--chart-5: var(--chart-5);
|
||||
--chart-6: var(--chart-6);
|
||||
}
|
||||
31
src/widgets/landing/benefits-section/ui/benefits-section.tsx
Normal file
31
src/widgets/landing/benefits-section/ui/benefits-section.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Card, SectionHeader } from "denjs-ui";
|
||||
import { getBenefits } from "#/entities/landing";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function BenefitsSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="benefits" className="mt-10 sm:mt-14">
|
||||
<SectionHeader
|
||||
eyebrow={m.benefits_eyebrow()}
|
||||
title={m.benefits_title()}
|
||||
align="left"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{getBenefits(m).map((item, index) => (
|
||||
<Card
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
description={item.text}
|
||||
padding="lg"
|
||||
className="feature-card rise-in rounded-2xl"
|
||||
style={{ animationDelay: `${index * 90 + 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
src/widgets/landing/faq-section/ui/faq-section.tsx
Normal file
37
src/widgets/landing/faq-section/ui/faq-section.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Accordion, SectionHeader, Text } from "denjs-ui";
|
||||
import { getFaqItems } from "#/entities/landing";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function FaqSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="faq" className="mt-10 sm:mt-14">
|
||||
<SectionHeader
|
||||
eyebrow={m.faq_eyebrow()}
|
||||
title={m.faq_title()}
|
||||
align="left"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
className="mt-5 rounded-2xl border border-[var(--line)] bg-[var(--glass-surface)]"
|
||||
items={getFaqItems(m).map((item) => ({
|
||||
value: item.value,
|
||||
title: item.title,
|
||||
content: (
|
||||
<Text
|
||||
as="p"
|
||||
tone="muted"
|
||||
className="m-0 text-sm leading-6 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{item.content}
|
||||
</Text>
|
||||
),
|
||||
}))}
|
||||
type="single"
|
||||
collapsible
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Card, SectionHeader } from "denjs-ui";
|
||||
import { RequestDemoForm } from "#/features/request-demo";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function FinalCtaSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="demo" className="mt-10 sm:mt-14">
|
||||
<Card
|
||||
padding="lg"
|
||||
className="island-shell rounded-[2rem] border-[rgba(31,96,99,0.3)] bg-[linear-gradient(160deg,rgba(255,255,255,0.95),rgba(220,245,241,0.8))] sm:p-10"
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_1.08fr]">
|
||||
<div className="rise-in" style={{ animationDelay: "120ms" }}>
|
||||
<SectionHeader
|
||||
eyebrow={m.final_eyebrow()}
|
||||
title={m.final_title()}
|
||||
subtitle={m.final_subtitle()}
|
||||
align="left"
|
||||
/>
|
||||
<div className="mt-4 inline-flex rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1 text-xs font-semibold text-[var(--sea-ink-soft)]">
|
||||
{m.final_hint()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RequestDemoForm />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
src/widgets/landing/hero-section/ui/hero-section.tsx
Normal file
84
src/widgets/landing/hero-section/ui/hero-section.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Badge, Button, Card, Heading, Text } from "denjs-ui";
|
||||
import { getHeroMetrics } from "#/entities/landing";
|
||||
import { scrollToSection } from "#/shared/lib/dom";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function HeroSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section className="island-shell rise-in relative overflow-hidden rounded-[2.2rem] px-6 py-10 sm:px-10 sm:py-14">
|
||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -right-16 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(230,128,97,0.2),transparent_66%)]" />
|
||||
|
||||
<Badge
|
||||
color="teal"
|
||||
rounded="full"
|
||||
bordered
|
||||
className="w-fit px-3 py-1 text-[11px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
{m.hero_badge()}
|
||||
</Badge>
|
||||
|
||||
<Heading
|
||||
level={1}
|
||||
className="mt-4 max-w-4xl text-4xl leading-[0.98] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl"
|
||||
>
|
||||
{m.hero_title()}
|
||||
</Heading>
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
tone="muted"
|
||||
className="mt-5 max-w-3xl text-base leading-7 text-[var(--sea-ink-soft)] sm:text-lg"
|
||||
>
|
||||
{m.hero_subtitle()}
|
||||
</Text>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Button
|
||||
size="large"
|
||||
className="rounded-full"
|
||||
onClick={() => scrollToSection("demo")}
|
||||
>
|
||||
{m.hero_cta_primary()}
|
||||
</Button>
|
||||
<Button
|
||||
tone="secondary"
|
||||
size="large"
|
||||
className="rounded-full"
|
||||
onClick={() => scrollToSection("how")}
|
||||
>
|
||||
{m.hero_cta_secondary()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{getHeroMetrics(m).map((item, index) => (
|
||||
<Card
|
||||
key={item.label}
|
||||
padding="md"
|
||||
className="metric-card rise-in rounded-2xl"
|
||||
style={{ animationDelay: `${index * 100 + 80}ms` }}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
weight="semibold"
|
||||
className="m-0 text-2xl font-extrabold text-[var(--sea-ink)]"
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size="sm"
|
||||
tone="muted"
|
||||
className="mt-1 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Badge, Card, Heading, SectionHeader, Text } from "denjs-ui";
|
||||
import { getSteps } from "#/entities/landing";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="how" className="mt-10 sm:mt-14">
|
||||
<SectionHeader
|
||||
eyebrow={m.how_eyebrow()}
|
||||
title={m.how_title()}
|
||||
align="left"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{getSteps(m).map((step, index) => (
|
||||
<Card
|
||||
key={step.title}
|
||||
padding="lg"
|
||||
className="island-shell rise-in rounded-2xl"
|
||||
style={{ animationDelay: `${index * 80 + 120}ms` }}
|
||||
>
|
||||
<Badge
|
||||
color="teal"
|
||||
rounded="full"
|
||||
className="mb-3 w-fit px-2.5 py-1 text-[11px] font-semibold"
|
||||
>
|
||||
{m.how_step_label()} {index + 1}
|
||||
</Badge>
|
||||
<Heading
|
||||
level={4}
|
||||
className="text-base font-semibold text-[var(--sea-ink)]"
|
||||
>
|
||||
{step.title}
|
||||
</Heading>
|
||||
<Text
|
||||
tone="muted"
|
||||
className="mt-2 text-sm leading-6 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{step.text}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
10
src/widgets/landing/index.ts
Normal file
10
src/widgets/landing/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { BenefitsSection } from "./benefits-section/ui/benefits-section";
|
||||
export { FaqSection } from "./faq-section/ui/faq-section";
|
||||
export { FinalCtaSection } from "./final-cta-section/ui/final-cta-section";
|
||||
export { HeroSection } from "./hero-section/ui/hero-section";
|
||||
export { HowItWorksSection } from "./how-it-works-section/ui/how-it-works-section";
|
||||
export { PersonalPageSection } from "./personal-page-section/ui/personal-page-section";
|
||||
export { ProblemSection } from "./problem-section/ui/problem-section";
|
||||
export { ProductPreviewSection } from "./product-preview-section/ui/product-preview-section";
|
||||
export { SolutionSection } from "./solution-section/ui/solution-section";
|
||||
export { TrustSection } from "./trust-section/ui/trust-section";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button, Card, SectionHeader, Text } from "denjs-ui";
|
||||
import { scrollToSection } from "#/shared/lib/dom";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function PersonalPageSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="personal" className="mt-10 sm:mt-14">
|
||||
<Card
|
||||
padding="lg"
|
||||
className="island-shell rounded-[2rem] border-[rgba(31,96,99,0.28)] bg-[linear-gradient(155deg,rgba(255,255,255,0.95),rgba(220,245,241,0.74))] sm:p-10"
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_1fr] lg:items-center">
|
||||
<div>
|
||||
<SectionHeader
|
||||
eyebrow={m.personal_eyebrow()}
|
||||
title={m.personal_title()}
|
||||
subtitle={m.personal_subtitle()}
|
||||
align="left"
|
||||
/>
|
||||
<div className="mt-4 inline-flex rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1 text-xs font-semibold text-[var(--sea-ink-soft)]">
|
||||
{m.personal_example()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
className="rounded-2xl border border-[var(--line)] bg-white/82"
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
size="sm"
|
||||
tone="muted"
|
||||
className="m-0 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{m.personal_card_title()}
|
||||
</Text>
|
||||
<ul className="m-0 mt-3 space-y-2 pl-5 text-sm leading-6 text-[var(--sea-ink-soft)]">
|
||||
<li>{m.personal_point_1()}</li>
|
||||
<li>{m.personal_point_2()}</li>
|
||||
<li>{m.personal_point_3()}</li>
|
||||
</ul>
|
||||
<Button
|
||||
size="large"
|
||||
className="mt-5 w-full rounded-full"
|
||||
onClick={() => scrollToSection("demo")}
|
||||
>
|
||||
{m.personal_cta()}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/widgets/landing/problem-section/ui/problem-section.tsx
Normal file
32
src/widgets/landing/problem-section/ui/problem-section.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, SectionHeader } from "denjs-ui";
|
||||
import { getProblems } from "#/entities/landing";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function ProblemSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="problem" className="mt-10 sm:mt-14">
|
||||
<SectionHeader
|
||||
eyebrow={m.problem_eyebrow()}
|
||||
title={m.problem_title()}
|
||||
subtitle={m.problem_subtitle()}
|
||||
align="left"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-3">
|
||||
{getProblems(m).map((item, index) => (
|
||||
<Card
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
description={item.text}
|
||||
padding="lg"
|
||||
className="feature-card rise-in rounded-2xl"
|
||||
style={{ animationDelay: `${index * 90 + 110}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Badge, Card, Heading, SectionHeader, Text } from "denjs-ui";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function ProductPreviewSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="preview" className="mt-10 sm:mt-14">
|
||||
<SectionHeader
|
||||
eyebrow={m.preview_eyebrow()}
|
||||
title={m.preview_title()}
|
||||
subtitle={m.preview_subtitle()}
|
||||
align="left"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
|
||||
<Card padding="lg" className="island-shell rounded-2xl">
|
||||
<Text as="p" size="sm" tone="muted" className="island-kicker mb-2">
|
||||
{m.preview_profile_title()}
|
||||
</Text>
|
||||
<Heading
|
||||
level={3}
|
||||
className="text-2xl font-semibold text-[var(--sea-ink)] sm:text-3xl"
|
||||
>
|
||||
monie.app/anna-nails
|
||||
</Heading>
|
||||
<Text
|
||||
tone="muted"
|
||||
className="mt-3 text-sm leading-6 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{m.preview_profile_description()}
|
||||
</Text>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<Card
|
||||
padding="sm"
|
||||
title={m.preview_service_1_title()}
|
||||
description={m.preview_service_1_description()}
|
||||
className="rounded-xl border border-[var(--line)] bg-white/76"
|
||||
/>
|
||||
<Card
|
||||
padding="sm"
|
||||
title={m.preview_service_2_title()}
|
||||
description={m.preview_service_2_description()}
|
||||
className="rounded-xl border border-[var(--line)] bg-white/76"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" className="island-shell rounded-2xl">
|
||||
<Text as="p" size="sm" tone="muted" className="island-kicker mb-2">
|
||||
{m.preview_schedule_title()}
|
||||
</Text>
|
||||
<div className="space-y-3">
|
||||
<Card
|
||||
padding="sm"
|
||||
title={m.preview_appt_1_title()}
|
||||
description={m.preview_appt_1_description()}
|
||||
className="rounded-xl border border-[var(--line)] bg-white/80"
|
||||
footer={
|
||||
<Badge
|
||||
color="emerald"
|
||||
rounded="full"
|
||||
className="w-fit px-2 py-0.5 text-[11px]"
|
||||
>
|
||||
{m.preview_appt_1_badge()}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
padding="sm"
|
||||
title={m.preview_appt_2_title()}
|
||||
description={m.preview_appt_2_description()}
|
||||
className="rounded-xl border border-[var(--line)] bg-white/80"
|
||||
footer={
|
||||
<Badge
|
||||
color="sky"
|
||||
rounded="full"
|
||||
className="w-fit px-2 py-0.5 text-[11px]"
|
||||
>
|
||||
{m.preview_appt_2_badge()}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
padding="sm"
|
||||
title={m.preview_income_title()}
|
||||
description={m.preview_income_description()}
|
||||
className="rounded-xl border border-[var(--line)] bg-white/80"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/widgets/landing/solution-section/ui/solution-section.tsx
Normal file
32
src/widgets/landing/solution-section/ui/solution-section.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, SectionHeader } from "denjs-ui";
|
||||
import { getSolutionItems } from "#/entities/landing";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function SolutionSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section
|
||||
id="solution"
|
||||
className="mt-10 grid gap-6 lg:grid-cols-[1.05fr_1fr] sm:mt-14"
|
||||
>
|
||||
<Card padding="lg" className="island-shell rounded-2xl">
|
||||
<SectionHeader
|
||||
eyebrow={m.solution_eyebrow()}
|
||||
title={m.solution_title()}
|
||||
subtitle={m.solution_subtitle()}
|
||||
align="left"
|
||||
className="max-w-2xl"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" className="island-shell rounded-2xl">
|
||||
<ul className="m-0 space-y-3 pl-5 text-sm leading-6 text-[var(--sea-ink-soft)]">
|
||||
{getSolutionItems(m).map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
31
src/widgets/landing/trust-section/ui/trust-section.tsx
Normal file
31
src/widgets/landing/trust-section/ui/trust-section.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Card, SectionHeader } from "denjs-ui";
|
||||
import { getTrustItems } from "#/entities/landing";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function TrustSection() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<section id="trust" className="mt-10 sm:mt-14">
|
||||
<SectionHeader
|
||||
eyebrow={m.trust_eyebrow()}
|
||||
title={m.trust_title()}
|
||||
align="left"
|
||||
className="max-w-3xl"
|
||||
/>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-3">
|
||||
{getTrustItems(m).map((item, index) => (
|
||||
<Card
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
description={item.text}
|
||||
padding="lg"
|
||||
className="feature-card rise-in rounded-2xl"
|
||||
style={{ animationDelay: `${index * 90 + 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
2
src/widgets/layout/index.ts
Normal file
2
src/widgets/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Footer } from "./ui/footer";
|
||||
export { Header } from "./ui/header";
|
||||
105
src/widgets/layout/ui/footer.tsx
Normal file
105
src/widgets/layout/ui/footer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Link as RouterLink } from "@tanstack/react-router";
|
||||
import { Card, Divider, Link, Text } from "denjs-ui";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
|
||||
export function Footer() {
|
||||
const m = useMessages();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="site-footer mt-16 px-4 pb-12 pt-10 text-[var(--sea-ink-soft)]">
|
||||
<div className="page-wrap">
|
||||
<Card
|
||||
padding="lg"
|
||||
className="island-shell rounded-[1.8rem] border-[var(--line)] bg-[linear-gradient(165deg,var(--surface-strong),var(--glass-surface))]"
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-[1.1fr_1fr] md:items-end">
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
weight="semibold"
|
||||
className="m-0 text-sm text-[var(--sea-ink)]"
|
||||
>
|
||||
Monie
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
tone="muted"
|
||||
className="m-0 mt-2 max-w-md text-sm leading-6 text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
{m.footer_description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-wrap justify-start gap-4 text-sm md:justify-end">
|
||||
<Link
|
||||
href="/#benefits"
|
||||
tone="muted"
|
||||
underline="hover"
|
||||
className="text-sm font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
{m.footer_nav_benefits()}
|
||||
</Link>
|
||||
<Link
|
||||
href="/#faq"
|
||||
tone="muted"
|
||||
underline="hover"
|
||||
className="text-sm font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
{m.footer_nav_faq()}
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:hello@monie.app"
|
||||
external
|
||||
tone="muted"
|
||||
underline="hover"
|
||||
className="text-sm font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
hello@monie.app
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Divider className="my-6" tone="muted" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
<Text
|
||||
as="p"
|
||||
size="sm"
|
||||
tone="muted"
|
||||
className="m-0 text-xs text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
© {year} Monie. {m.footer_rights()}
|
||||
</Text>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<RouterLink
|
||||
to="/about"
|
||||
className="text-xs font-medium text-[var(--sea-ink-soft)] no-underline transition hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
{m.footer_about()}
|
||||
</RouterLink>
|
||||
<Link
|
||||
href="/#demo"
|
||||
tone="muted"
|
||||
underline="hover"
|
||||
className="text-xs font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
{m.footer_demo()}
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:legal@monie.app"
|
||||
external
|
||||
tone="muted"
|
||||
underline="hover"
|
||||
className="text-xs font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
legal@monie.app
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
85
src/widgets/layout/ui/header.tsx
Normal file
85
src/widgets/layout/ui/header.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Header as DenjsHeader } from "denjs-ui";
|
||||
import {
|
||||
AlertCircle,
|
||||
CircleHelp,
|
||||
Lightbulb,
|
||||
Mail,
|
||||
PlayCircle,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import { LanguageSwitcher } from "#/features/change-language";
|
||||
import { ThemeToggle } from "#/features/theme-toggle/ThemeToggle";
|
||||
import { useMessages } from "#/shared/lib/i18n";
|
||||
import { LowerLeftLogo } from "#/shared/ui/lower-left-logo";
|
||||
|
||||
export function Header() {
|
||||
const m = useMessages();
|
||||
|
||||
return (
|
||||
<DenjsHeader
|
||||
brand={
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex no-underline"
|
||||
aria-label="Monie home"
|
||||
>
|
||||
<LowerLeftLogo
|
||||
aria-label="Monie"
|
||||
className="h-8 w-auto max-w-40 select-none text-foreground transition-colors"
|
||||
/>
|
||||
</a>
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
productLabel={m.header_nav_solution()}
|
||||
products={[
|
||||
{
|
||||
name: m.header_nav_problem(),
|
||||
href: "/#problem",
|
||||
icon: AlertCircle,
|
||||
},
|
||||
{
|
||||
name: m.header_nav_solution(),
|
||||
href: "/#solution",
|
||||
icon: Lightbulb,
|
||||
},
|
||||
{
|
||||
name: m.header_nav_how(),
|
||||
href: "/#how",
|
||||
icon: WandSparkles,
|
||||
},
|
||||
{
|
||||
name: m.header_nav_faq(),
|
||||
href: "/#faq",
|
||||
icon: CircleHelp,
|
||||
},
|
||||
]}
|
||||
callsToAction={[
|
||||
{
|
||||
name: m.header_create_page(),
|
||||
href: "/#demo",
|
||||
icon: PlayCircle,
|
||||
},
|
||||
{
|
||||
name: m.header_email_aria(),
|
||||
href: "mailto:hello@monie.app",
|
||||
icon: Mail,
|
||||
},
|
||||
]}
|
||||
links={[
|
||||
{
|
||||
name: m.header_about(),
|
||||
href: "/about",
|
||||
},
|
||||
]}
|
||||
loginLink={{
|
||||
name: m.header_create_page(),
|
||||
href: "/#demo",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
}
|
||||
}
|
||||
30
vite.config.ts
Normal file
30
vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { devtools } from "@tanstack/devtools-vite";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const config = defineConfig({
|
||||
plugins: [
|
||||
devtools(),
|
||||
tsconfigPaths({ projects: ["./tsconfig.json"] }),
|
||||
paraglideVitePlugin({
|
||||
project: "./project.inlang",
|
||||
outdir: "./src/paraglide",
|
||||
strategy: ["localStorage", "preferredLanguage", "baseLocale"],
|
||||
localStorageKey: "monie.locale",
|
||||
emitTsDeclarations: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
viteReact({
|
||||
babel: {
|
||||
plugins: ["babel-plugin-react-compiler"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user