Skip to main content
$ npx create-react-app tempero --template typescript && cd tempero && npm install framer-motion react-router-dom lucide-react tailwindcss postcss autoprefixer @types/react @types/node /// $ npx create-react-app tempero --template typescript && cd tempero && npm install framer-motion react-router-dom lucide-react tailwindcss postcss autoprefixer @types/react @types/node /// $ npx create-react-app tempero --template typescript && cd tempero && npm install framer-motion react-router-dom lucide-react tailwindcss postcss autoprefixer @types/react @types/node
Creating a new React app in /home/tempero/projects/tempero-nz... Installing packages. This might take a couple of minutes. Please be patient. Resolving dependencies... /// Creating a new React app in /home/tempero/projects/tempero-nz... Installing packages. This might take a couple of minutes. Please be patient. Resolving dependencies... /// Creating a new React app in /home/tempero/projects/tempero-nz... Installing packages. This might take a couple of minutes. Please be patient. Resolving dependencies...
Installing react@19.0.0, react-dom@19.0.0, react-scripts@5.0.1, typescript@5.6.0... added 1,247 packages, audited 1,289 packages in 18.4s — found 0 vulnerabilities — 198 packages looking for funding /// Installing react@19.0.0, react-dom@19.0.0, react-scripts@5.0.1, typescript@5.6.0... added 1,247 packages, audited 1,289 packages in 18.4s — found 0 vulnerabilities — 198 packages looking for funding
✓ Success! Created tempero at /home/tempero/projects/tempero-nz — Installing additional dependencies: framer-motion@11, react-router-dom@7, lucide-react@0.460, tailwindcss@4 /// ✓ Success! Created tempero at /home/tempero/projects/tempero-nz — Installing additional dependencies: framer-motion@11, react-router-dom@7, lucide-react@0.460, tailwindcss@4 /// ✓ Success! Created tempero at /home/tempero/projects/tempero-nz — Installing additional dependencies: framer-motion@11, react-router-dom@7, lucide-react@0.460, tailwindcss@4
 
import { useState, useEffect, useRef, useCallback, useMemo, Suspense, lazy } from 'react'; // React 19 hooks — state, effects, refs, memoization, code splitting, concurrent features /// import { useState, useEffect, useRef, useCallback, useMemo, Suspense, lazy } from 'react'; // React 19 hooks — state, effects, refs, memoization, code splitting, concurrent features /// import { useState, useEffect, useRef, useCallback, useMemo, Suspense, lazy } from 'react'; // React 19 hooks — state, effects, refs, memoization, code splitting, concurrent features
import { motion, AnimatePresence, useScroll, useTransform, useInView, useMotionValue } from 'framer-motion'; // Animation library — spring physics, gestures, scroll-linked, layout animations /// import { motion, AnimatePresence, useScroll, useTransform, useInView, useMotionValue } from 'framer-motion'; // Animation library — spring physics, gestures, scroll-linked, layout animations /// import { motion, AnimatePresence, useScroll, useTransform, useInView, useMotionValue } from 'framer-motion'; // Animation library — spring physics, gestures, scroll-linked, layout animations
import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom'; // Client-side routing — nested routes, lazy loading, navigation guards /// import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom'; // Client-side routing — nested routes, lazy loading, navigation guards /// import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom'; // Client-side routing — nested routes, lazy loading, navigation guards
import { ArrowRight, Play, ZoomIn, Heart, MessageCircle, Share2, ChevronDown, X, Search, Menu, ExternalLink, Mail, Phone } from 'lucide-react'; // Hand-crafted SVG icon library — 1000+ icons /// import { ArrowRight, Play, ZoomIn, Heart, MessageCircle, Share2, ChevronDown, X, Search, Menu, ExternalLink, Mail, Phone } from 'lucide-react'; // Hand-crafted SVG icon library — 1000+ icons /// import { ArrowRight, Play, ZoomIn, Heart, MessageCircle, Share2, ChevronDown, X, Search, Menu, ExternalLink, Mail, Phone } from 'lucide-react'; // Hand-crafted SVG icon library — 1000+ icons
 
export default function App(): JSX.Element { const [data, setData] = useState<Service[] | null>(null); const [loading, setLoading] = useState<boolean>(true); const navigate = useNavigate(); /// export default function App(): JSX.Element { const [data, setData] = useState<Service[] | null>(null); const [loading, setLoading] = useState<boolean>(true); const navigate = useNavigate(); /// export default function App(): JSX.Element { const [data, setData] = useState<Service[] | null>(null); const [loading, setLoading] = useState<boolean>(true); const navigate = useNavigate();
const location = useLocation(); const containerRef = useRef<HTMLDivElement>(null); const isDesktop = useMediaQuery('(min-width: 768px)'); const theme = useTheme(); // Custom hook for dark/light mode /// const location = useLocation(); const containerRef = useRef<HTMLDivElement>(null); const isDesktop = useMediaQuery('(min-width: 768px)'); const theme = useTheme(); // Custom hook for dark/light mode
 
useEffect(() => { async function fetchData() { try { const response = await fetch('/api/v2/services', { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); /// useEffect(() => { async function fetchData() { try { const response = await fetch('/api/v2/services', { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); /// useEffect(() => { async function fetchData() { try { const response = await fetch('/api/v2/services', { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
if (!response.ok) throw new Error(`HTTP error ${response.status}: ${response.statusText} — check network tab for details`); const json: ApiResponse<Service[]> = await response.json(); /// if (!response.ok) throw new Error(`HTTP error ${response.status}: ${response.statusText} — check network tab for details`); const json: ApiResponse<Service[]> = await response.json(); /// if (!response.ok) throw new Error(`HTTP error ${response.status}: ${response.statusText} — check network tab for details`); const json: ApiResponse<Service[]> = await response.json();
setData(json.data.filter(s => s.status === 'active').sort((a, b) => a.order - b.order)); } catch (err) { console.error('[tempero] Fetch failed:', err); } finally { setLoading(false); } /// setData(json.data.filter(s => s.status === 'active').sort((a, b) => a.order - b.order)); } catch (err) { console.error('[tempero] Fetch failed:', err); } finally { setLoading(false); } /// setData(json.data.filter(s => s.status === 'active').sort((a, b) => a.order - b.order)); } catch (err) { console.error('[tempero] Fetch failed:', err); } finally { setLoading(false); }
} fetchData(); }, [token]); // Re-fetch when auth token changes — dependency array ensures cleanup and prevents memory leaks when component unmounts during pending requests /// } fetchData(); }, [token]); // Re-fetch when auth token changes — dependency array ensures cleanup and prevents memory leaks when component unmounts during pending requests /// } fetchData(); }, [token]); // Re-fetch when auth token changes — dependency array ensures cleanup and prevents memory leaks when component unmounts during pending requests
 
const handleSubmit = async (formData: ContactForm) => { const payload = { ...formData, timestamp: new Date().toISOString(), source: 'website', referrer: document.referrer, session: crypto.randomUUID() }; /// const handleSubmit = async (formData: ContactForm) => { const payload = { ...formData, timestamp: new Date().toISOString(), source: 'website', referrer: document.referrer, session: crypto.randomUUID() };
const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); // POST to backend API — validates, stores, emails, notifies Slack /// const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); // POST to backend API — validates, stores, emails, notifies Slack
if (res.ok) { analytics.track('contact_submitted', { service: formData.service }); toast.success('Message sent! We\'ll get back to you within 24 hours.'); navigate('/success'); } }; /// if (res.ok) { analytics.track('contact_submitted', { service: formData.service }); toast.success('Message sent! We\'ll get back to you within 24 hours.'); navigate('/success'); } }; /// if (res.ok) { analytics.track('contact_submitted', { service: formData.service }); toast.success('Message sent! We\'ll get back to you within 24 hours.'); navigate('/success'); } };
 
return ( <Layout> <Suspense fallback={<div className='min-h-screen bg-dark' />}> <AnimatePresence mode='wait'> <Routes> <Route path='/' element={<Home />} /> <Route path='/services' element={<Services />} /> /// return ( <Layout> <Suspense fallback={<div className='min-h-screen bg-dark' />}> <AnimatePresence mode='wait'> <Routes> <Route path='/' element={<Home />} /> <Route path='/services' element={<Services />} />
<Route path='/services/:slug' element={<ServiceDetail />} /> <Route path='/about' element={<About />} /> <Route path='/about/who' element={<OurStory />} /> <Route path='/news' element={<News />} /> /// <Route path='/services/:slug' element={<ServiceDetail />} /> <Route path='/about' element={<About />} /> <Route path='/about/who' element={<OurStory />} /> <Route path='/news' element={<News />} />
<Route path='/contact' element={<Navigate to='/#contact' replace />} /> <Route path='/privacy' element={<Privacy />} /> <Route path='/terms' element={<Terms />} /> <Route path='*' element={<NotFound />} /> /// <Route path='/contact' element={<Navigate to='/#contact' replace />} /> <Route path='/privacy' element={<Privacy />} /> <Route path='/terms' element={<Terms />} /> <Route path='*' element={<NotFound />} />
</Routes> </AnimatePresence> </Suspense> </Layout> ); } // End App — all 15 service pages + 5 audience pages + 7 about sub-pages lazy-loaded via React.lazy() for optimal code splitting /// </Routes> </AnimatePresence> </Suspense> </Layout> ); } // End App — all 15 service pages + 5 audience pages + 7 about sub-pages lazy-loaded via React.lazy() for optimal code splitting /// </Routes> </AnimatePresence> </Suspense> </Layout> ); } // End App — all 15 service pages + 5 audience pages + 7 about sub-pages lazy-loaded via React.lazy() for optimal code splitting
 
type Service = { id: string; slug: string; title: string; shortTitle: string; description: string; fullDescription: string[]; heroImage: string; thumbImage: string; subServices: SubService[]; /// type Service = { id: string; slug: string; title: string; shortTitle: string; description: string; fullDescription: string[]; heroImage: string; thumbImage: string; subServices: SubService[]; /// type Service = { id: string; slug: string; title: string; shortTitle: string; description: string; fullDescription: string[]; heroImage: string; thumbImage: string; subServices: SubService[];
ctaHeading: string; ctaText: string; category: 'classics' | 'connected' | 'clever' | 'crazy'; theme: { layout: LayoutType; visual: VisualTheme }; galleryImages?: string[]; }; /// ctaHeading: string; ctaText: string; category: 'classics' | 'connected' | 'clever' | 'crazy'; theme: { layout: LayoutType; visual: VisualTheme }; galleryImages?: string[]; }; /// ctaHeading: string; ctaText: string; category: 'classics' | 'connected' | 'clever' | 'crazy'; theme: { layout: LayoutType; visual: VisualTheme }; galleryImages?: string[]; };
type LayoutType = 'showcase' | 'magazine' | 'explorer' | 'story' | 'interactive' | 'spotlight'; // Six unique layout archetypes — each service page gets its own creative identity and visual personality /// type LayoutType = 'showcase' | 'magazine' | 'explorer' | 'story' | 'interactive' | 'spotlight'; // Six unique layout archetypes — each service page gets its own creative identity and visual personality
interface ApiResponse<T> { data: T; status: number; message: string; timestamp: Date; pagination?: { page: number; total: number; perPage: number; hasNext: boolean; }; cache?: { hit: boolean; ttl: number }; }; /// interface ApiResponse<T> { data: T; status: number; message: string; timestamp: Date; pagination?: { page: number; total: number; perPage: number; hasNext: boolean; }; cache?: { hit: boolean; ttl: number }; };
 
const config = { database: { host: 'db.tempero.nz', port: 5432, ssl: true, pool: { min: 2, max: 10 }, migrations: 'src/db/migrations' }, api: { baseUrl: '/api/v2', timeout: 5000, retries: 3, rateLimit: 100 }, /// const config = { database: { host: 'db.tempero.nz', port: 5432, ssl: true, pool: { min: 2, max: 10 }, migrations: 'src/db/migrations' }, api: { baseUrl: '/api/v2', timeout: 5000, retries: 3, rateLimit: 100 },
auth: { provider: 'firebase', projectId: 'tempero-nz', region: 'australia-southeast1', sessionDuration: 86400 }, cache: { ttl: 3600, strategy: 'stale-while-revalidate', maxSize: '50mb' }, }; /// auth: { provider: 'firebase', projectId: 'tempero-nz', region: 'australia-southeast1', sessionDuration: 86400 }, cache: { ttl: 3600, strategy: 'stale-while-revalidate', maxSize: '50mb' }, }; /// auth: { provider: 'firebase', projectId: 'tempero-nz', region: 'australia-southeast1', sessionDuration: 86400 }, cache: { ttl: 3600, strategy: 'stale-while-revalidate', maxSize: '50mb' }, };
 
router.get('/services', rateLimit({ windowMs: 60000, max: 100 }), cache('5m'), async (req: Request, res: Response) => { // GET /api/v2/services — returns all active services with sub-service counts /// router.get('/services', rateLimit({ windowMs: 60000, max: 100 }), cache('5m'), async (req: Request, res: Response) => { // GET /api/v2/services — returns all active services with sub-service counts /// router.get('/services', rateLimit({ windowMs: 60000, max: 100 }), cache('5m'), async (req: Request, res: Response) => { // GET /api/v2/services — returns all active services with sub-service counts
const services = await db.query('SELECT s.*, COUNT(ss.id) as sub_count FROM services s LEFT JOIN sub_services ss ON s.id = ss.service_id WHERE s.active = true GROUP BY s.id ORDER BY s.sort_order'); /// const services = await db.query('SELECT s.*, COUNT(ss.id) as sub_count FROM services s LEFT JOIN sub_services ss ON s.id = ss.service_id WHERE s.active = true GROUP BY s.id ORDER BY s.sort_order');
const enriched = services.rows.map(s => ({ ...s, url: `/services/${s.slug}`, subCount: parseInt(s.sub_count) })); res.json({ services: enriched, count: services.rowCount, cached: !!res.getHeader('X-Cache') }); /// const enriched = services.rows.map(s => ({ ...s, url: `/services/${s.slug}`, subCount: parseInt(s.sub_count) })); res.json({ services: enriched, count: services.rowCount, cached: !!res.getHeader('X-Cache') });
 
router.post('/contact', rateLimit({ windowMs: 300000, max: 5 }), validate(contactSchema), sanitize(), async (req: Request, res: Response) => { // POST /api/v2/contact — form submissions with email + Slack /// router.post('/contact', rateLimit({ windowMs: 300000, max: 5 }), validate(contactSchema), sanitize(), async (req: Request, res: Response) => { // POST /api/v2/contact — form submissions with email + Slack
const { name, email, message, service } = req.body; const ticket = await db.query('INSERT INTO tickets (name, email, message, service, ip, created_at) VALUES ($1,$2,$3,$4,$5,NOW()) RETURNING id', /// const { name, email, message, service } = req.body; const ticket = await db.query('INSERT INTO tickets (name, email, message, service, ip, created_at) VALUES ($1,$2,$3,$4,$5,NOW()) RETURNING id', /// const { name, email, message, service } = req.body; const ticket = await db.query('INSERT INTO tickets (name, email, message, service, ip, created_at) VALUES ($1,$2,$3,$4,$5,NOW()) RETURNING id',
[name, email, message, service, req.ip]); await Promise.
Services/Websites & Apps
tempero:~/services $ build --with-love

Websites &
Apps

Modern websites, apps, and digital solutions that work for you.

What We Build

Pick a tab to explore our digital services

Websites

Websites

Your website is the first impression people have of your business. We build beautiful, fast, mobile-first websites that tell your story and convert visitors into customers, all built from scratch, never from templates.

Landing Pages

Landing Pages

Simple, effective and built from the ground up static sites for your portfolio, showcase, announcement or small business.

Apps

Apps

When off-the-shelf software doesn't fit, we build custom. From booking systems to internal dashboards, we create web applications tailored to how your business actually works, not how software companies think it should.

Client Portals

Client Portals

Give your clients a secure, branded space to access their documents, track project progress, and communicate with your team. No more email attachments, no more "did you get my message?" - just clarity and professionalism.

Management Systems

Management Systems

Running an organisation is complex. We build systems that bring your team, tasks, and communication together, so you can focus on your mission instead of your admin.

Lookback

Lookback

Rewind your year and share with the world. Lookback.nz provides the perfect christmas card. Share your photos, videos and memories with your community.

Free Wedding Websites

Free Wedding Websites

All wedding clients get a website with any of our services absolutely FREE! Share your special day with your friends and family around the world in this unique and creative way.

Estimate Your Project

Get an instant ballpark — no commitment required

Web Development Pricing

Estimate your project cost — all prices excl. GST

Select a product above to get your estimate

Sidequest Digital

Sidequest Digital

Discover more about our web dev studio

Visit sidequest.nz

Ready to go digital?

Let's build something amazing together.