A modern property rental platform for apartments and office spaces.
Browse curated listings, explore properties with photo galleries and interactive maps, pick your dates, and book in minutes. Authenticated users get a personal dashboard to track orders and manage their profile. An AI assistant is available throughout the app whenever you need help.
Admins get a full back-office panel — manage properties with Google Maps location picking, handle orders, and oversee users — all from one place.
Built with React 19 and TypeScript, bundled by Vite, styled with Tailwind CSS, tested end-to-end with Playwright, and deployed as a static site to AWS S3. Available in English and Finnish.
| Environment | URL |
|---|---|
| Local (dev) | http://localhost:5173 |
| Staging | http://jussispace-staging.s3-website.eu-north-1.amazonaws.com |
| Production | http://jussispace-production.s3-website.eu-north-1.amazonaws.com |
| Layer | Tool |
|---|---|
| Framework | React 19 |
| Language | TypeScript 5.9 |
| Bundler | Vite 8 |
| Styles | Tailwind CSS |
| E2E Tests | Playwright |
| Linter | ESLint 9 + typescript-eslint |
| Container | Docker (multi-stage) + nginx |
| CI | GitHub Actions |
All client-exposed variables must be prefixed with VITE_. Vite inlines them into the JS bundle at build time — they are not available at runtime.
| Variable | Description | Default |
|---|---|---|
VITE_APP_TITLE |
App display title | jussispace-frontend |
VITE_APP_ENV |
Environment name | development |
VITE_API_URL |
Backend API base URL | http://localhost:8000/api |
VITE_JUSSILOG_BACKEND_API_URL |
Jussilog backend API URL | http://localhost:8000/api |
VITE_JUSSI_AIBOT_API_URL |
AI chatbot backend URL | http://localhost:8080 |
VITE_AI_SECRET_KEY |
Secret key for AI service | — |
VITE_GOOGLE_MAP_API_KEY |
Google Maps API key | — |
VITE_STORAGE_URL |
File/asset storage base URL | — |
| File | Committed | Purpose |
|---|---|---|
.env.example |
Yes | Documents all available variables |
.env.development |
Yes | Safe defaults for local dev |
.env.production |
No | Production values — never commit |
.env |
No | Local overrides, takes precedence |
.env.local |
No | Machine-local overrides |
Use src/env.ts instead of import.meta.env directly — it provides typed, defaulted access:
import env from './env'
console.log(env.apiUrl) // VITE_API_URL
console.log(env.appTitle) // VITE_APP_TITLEBecause Vite inlines vars at build time, the production image must receive them as Docker build args:
# Using .env.production on your machine
VITE_API_URL=https://api.example.com ./dev prod:up
# Or passing directly
docker build \
--target production \
--build-arg VITE_API_URL=https://api.example.com \
--build-arg VITE_APP_TITLE=jussispace-frontend \
-t jussispace-frontend .In CI, store these as GitHub Actions secrets and pass them as build-args in your workflow.
npm install
npm run dev # http://localhost:5173
npm run build # production build → dist/
npm run preview # serve dist/ locally
npm run lint # ESLintThe project uses a multi-stage Dockerfile with two runtime targets:
| Target | Base | Purpose |
|---|---|---|
development |
node:22-alpine | Vite dev server with hot-reload |
production |
nginx:1.27-alpine | Optimised static file serving |
Make it executable once:
chmod +x ./dev./dev up # build & start dev container → http://localhost:5173
./dev down # stop dev container
./dev restart # restart dev container
./dev logs # tail dev logs
./dev shell # open shell inside dev container./dev prod:up # build & start nginx container → http://localhost:80
./dev prod:down
./dev prod:restart
./dev prod:logs./dev deploy # build & deploy to S3 production (jussispace-production)
./dev deploy:staging # build & deploy to S3 staging (jussispace-staging)./dev build # build all images without starting
./dev ps # list running containers
./dev prune # remove stopped containers and dangling imagesThe app is deployed as a static site to Amazon S3 (eu-north-1 / Stockholm) via scripts/deploy.sh.
| Environment | Bucket | URL |
|---|---|---|
| Production | jussispace-production |
http://jussispace-production.s3-website.eu-north-1.amazonaws.com |
| Staging | jussispace-staging |
http://jussispace-staging.s3-website.eu-north-1.amazonaws.com |
Both buckets must be created once before deploying. The steps below use the AWS CLI — alternatively, see the Terraform setup section to provision everything as code.
1. Create the buckets
aws s3api create-bucket \
--bucket jussispace-production \
--region eu-north-1 \
--create-bucket-configuration LocationConstraint=eu-north-1
aws s3api create-bucket \
--bucket jussispace-staging \
--region eu-north-1 \
--create-bucket-configuration LocationConstraint=eu-north-12. Enable static website hosting
aws s3 website s3://jussispace-production \
--index-document index.html \
--error-document index.html
aws s3 website s3://jussispace-staging \
--index-document index.html \
--error-document index.html3. Disable "Block Public Access"
aws s3api put-public-access-block \
--bucket jussispace-production \
--public-access-block-configuration "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"
aws s3api put-public-access-block \
--bucket jussispace-staging \
--public-access-block-configuration "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"4. Attach a public-read bucket policy
for BUCKET in jussispace-production jussispace-staging; do
aws s3api put-bucket-policy \
--bucket "$BUCKET" \
--policy "{
\"Version\": \"2012-10-17\",
\"Statement\": [{
\"Effect\": \"Allow\",
\"Principal\": \"*\",
\"Action\": \"s3:GetObject\",
\"Resource\": \"arn:aws:s3:::$BUCKET/*\"
}]
}"
doneAn alternative to the manual steps above — provisions both buckets as code using terraform/main.tf (already in this repo).
Apply:
./dev tf:init
./dev tf:applyOr manually:
cd terraform
terraform init
terraform applyIf buckets already exist, import them into Terraform state first:
cd terraform
terraform import 'aws_s3_bucket.site["jussispace-production"]' jussispace-production
terraform import 'aws_s3_bucket.site["jussispace-staging"]' jussispace-stagingShow bucket URLs:
./dev tf:outputNotes:
prevent_destroy = trueis set on the buckets —terraform destroywill error out to protect against accidental deletion.- Versioning is enabled on both buckets, allowing rollback if a bad deploy overwrites files.
Cost note: S3 static hosting costs are minimal — typically < $0.01/month for a low-traffic site. You pay only for storage (
$0.023/GB in eu-north-1) and data transfer out ($0.09/GB). There are no compute or load-balancer charges.
Requires AWS CLI configured with valid credentials.
./dev deploy # deploy to production
./dev deploy:staging # deploy to staging
# With API URL override
VITE_API_URL=https://api.example.com ./dev deployDeployments are triggered by pushing a tag to the repository:
git tag 1.0.0-production && git push origin 1.0.0-production # → production
git tag 1.0.0-staging && git push origin 1.0.0-staging # → staging| Workflow | Trigger tag | Bucket |
|---|---|---|
.github/workflows/deploy.yml |
*-production |
jussispace-production |
.github/workflows/deploy-staging.yml |
*-staging |
jussispace-staging |
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID |
AWS access key ID |
AWS_SECRET_ACCESS_KEY |
AWS secret access key |
VITE_API_URL |
Production API base URL |
STAGING_VITE_API_URL |
Staging API base URL |
VITE_APP_TITLE |
App display title |
VITE_STORAGE_URL |
File/asset storage base URL |
VITE_GOOGLE_MAP_API_KEY |
Google Maps API key |
VITE_JUSSI_AIBOT_API_URL |
AI chatbot backend URL |
VITE_AI_SECRET_KEY |
Secret key for AI service |
CLOUDFRONT_DISTRIBUTION_ID |
(optional) Production CloudFront distribution ID |
STAGING_CLOUDFRONT_DISTRIBUTION_ID |
(optional) Staging CloudFront distribution ID |
nvm use
npm install -D @playwright/test
npx playwright install --with-deps
cp .env.example .env.development./dev test # headless (all browsers)
./dev test:ui # interactive Playwright UI mode
./dev test:debug # step-through debugger
./dev test:report # open last HTML reportOr via npm directly:
npm run test:e2e
npm run test:e2e:ui
npm run test:e2e:debug
npm run test:e2e:reportAll e2e specs live in the e2e/ directory with the .spec.ts extension.
e2e/
└── app.spec.ts # base smoke tests
Playwright auto-starts the Vite dev server (webServer in playwright.config.ts) before running tests, so no manual server management is needed.
- Chromium (Desktop)
- Firefox (Desktop)
- WebKit / Safari (Desktop)
- Pixel 5 (Mobile Chrome)
- iPhone 12 (Mobile Safari)
Three workflows run on every push and pull request to master/main:
| Workflow | File | What it does |
|---|---|---|
| Lint & Type Check | .github/workflows/lint.yml |
ESLint + tsc --noEmit |
| Security & Vulnerabilities | .github/workflows/security.yml |
npm audit, Trivy FS scan, Trivy Docker image scan, SARIF upload to Security tab |
| Playwright E2E | .github/workflows/playwright.yml |
Runs tests in Chromium, Firefox, WebKit in parallel; uploads HTML report as artifact |
The security workflow also runs on a weekly schedule (Mondays 08:00 UTC) to catch newly disclosed CVEs.
The production image uses a custom nginx.conf with:
- Gzip compression for JS, CSS, SVG, fonts
- Long-term caching (
Cache-Control: public, immutable, max-age=1y) for Vite-hashed assets - No-cache on
index.htmlto always serve the latest deploy - SPA fallback (
try_files $uri /index.html) for client-side routing - Security headers:
X-Frame-Options,X-Content-Type-Options,X-XSS-Protection,Referrer-Policy
.
├── e2e/ # Playwright e2e tests
│ └── app.spec.ts
├── public/ # Static assets (favicons, site.webmanifest)
├── src/ # Application source
│ ├── main.tsx
│ ├── App.tsx
│ ├── api.ts # API client
│ ├── env.ts # Typed env variable accessors
│ ├── i18n.ts # i18n translations
│ ├── index.css
│ ├── navigate.ts # Navigation utility
│ ├── components/
│ │ ├── AppHeader.tsx
│ │ ├── ChatbotWidget.tsx # AI chatbot widget
│ │ ├── DateRangePicker.tsx
│ │ ├── MapPicker.tsx # Google Maps location picker
│ │ └── PropertyMap.tsx # Google Maps property display
│ ├── contexts/
│ │ └── LanguageContext.tsx
│ └── views/
│ ├── Admin.tsx
│ ├── AdminOrderForm.tsx
│ ├── AdminOrders.tsx
│ ├── AdminProperties.tsx
│ ├── AdminPropertyForm.tsx
│ ├── AdminUserForm.tsx
│ ├── AdminUsers.tsx
│ ├── Checkout.tsx
│ ├── Listings.tsx
│ ├── Login.tsx
│ ├── MyOrders.tsx
│ ├── Profile.tsx
│ └── PropertyDetail.tsx
├── .github/workflows/
│ ├── lint.yml
│ ├── security.yml
│ ├── playwright.yml
│ ├── deploy.yml # S3 production deploy (tag: *-production)
│ └── deploy-staging.yml # S3 staging deploy (tag: *-staging)
├── terraform/
│ └── main.tf # S3 bucket provisioning (production + staging)
├── scripts/
│ └── deploy.sh # S3 deploy script (used by ./dev deploy)
├── dev # Docker + test CLI helper
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Dev + prod services
├── nginx.conf # Production nginx config
├── playwright.config.ts # Playwright configuration
├── vite.config.ts
├── tsconfig.json
└── package.json