feat: implement PWA scaffold, dark design system, and 3-tab glassmorphism nav

- Add @serwist/next for PWA service worker (precaching, offline support)
- Create public/manifest.json with dark theme (#0a0a0f) and SVG icons
- Rewrite globals.css with full dark premium design system (Tailwind v4)
  - Design tokens: bg-deep, bg-card, gold, nur-purple, emerald, borders, text
  - Utility classes: .glass, .glass-light, .gold-gradient, .nur-gradient, .gold-purple-gradient
  - Prayer ring SVG styles
- Replace old 5-tab nav with 3-tab floating glassmorphism pill
  - Home (House), Nur center raised 56px gold-purple circle, Profile (User)
  - Active gold #D4AF37, inactive #666, safe-area-aware
- Create src/lib/api/wordpress.ts fetchAPI wrapper with JWT auth
- Update layout.tsx with manifest, apple-web-app, Viewport export
This commit is contained in:
FalahMobile
2026-07-02 20:54:00 +08:00
parent 61baf3ac5d
commit 1d82eb66ac
10 changed files with 240 additions and 50 deletions
+10 -4
View File
@@ -1,7 +1,13 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next'
import withSerwistInit from '@serwist/next'
const withSerwist = withSerwistInit({
swSrc: 'src/sw.ts',
swDest: 'public/sw.js',
})
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: process.env.NETLIFY === "true" ? undefined : "standalone", output: process.env.NETLIFY === 'true' ? undefined : 'standalone',
}; }
export default nextConfig; export default withSerwist(nextConfig)
+3
View File
@@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@serwist/cli": "^9.5.11",
"@serwist/next": "^9.5.11",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.14", "@types/leaflet": "^1.9.14",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -21,6 +23,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"serwist": "^9.5.11",
"stripe": "^17.0.0" "stripe": "^17.0.0"
}, },
"devDependencies": { "devDependencies": {
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="32" fill="#0a0a0f"/>
<path d="M86 36c-28 8-50 34-50 64 0 36 28 64 64 64 18 0 34-8 46-20-6 2-12 4-18 4-30 0-54-24-54-54 0-22 14-42 34-50l-22-8z" fill="#D4AF37"/>
<path d="M114 56l8 22 20 4-16 14 6 22-18-10-18 10 6-22-16-14 20-4z" fill="#A78BFA"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#0a0a0f"/>
<path d="M220 72c-74 20-130 90-130 170 0 96 76 172 172 172 48 0 90-20 122-54-16 6-32 10-48 10-82 0-146-64-146-146 0-58 36-110 90-134l-60-18z" fill="#D4AF37"/>
<path d="M308 144l20 58 54 10-42 36 14 58-48-26-48 26 14-58-42-36 54-10z" fill="#A78BFA"/>
</svg>

After

Width:  |  Height:  |  Size: 407 B

+23
View File
@@ -0,0 +1,23 @@
{
"name": "Falah",
"short_name": "Falah",
"description": "Your Islamic lifestyle companion",
"start_url": "/home",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#0a0a0f",
"icons": [
{
"src": "/icons/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+80 -15
View File
@@ -1,25 +1,40 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @theme {
--color-bg-deep: #0a0a0f;
--color-bg-card: #111118;
--color-gold: #D4AF37;
--color-gold-light: #e2c55e;
--color-nur-purple: #A78BFA;
--color-nur-bg: #1a1a2e;
--color-emerald: #10B981;
--color-border: #222;
--color-border-light: #333;
--color-text-primary: #fff;
--color-text-secondary: #999;
--color-text-muted: #666;
--color-gold-gradient-start: #D4AF37;
--color-gold-gradient-end: #B8860B;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-arabic: "Noto Naskh Arabic", "Traditional Arabic", serif;
}
:root { :root {
--background: #f5f4f0; --nav-floating-bottom: 24px;
--foreground: #111827;
--gold: #D4AF37;
--emerald: #059669;
--nav-height: 64px;
} }
html { html {
height: 100%; height: 100%;
color-scheme: light; color-scheme: dark;
} }
body { body {
height: 100%; height: 100%;
color: var(--foreground); color: var(--color-text-primary);
background: var(--background); background: var(--color-bg-deep);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overscroll-behavior: none; overscroll-behavior: none;
@@ -42,11 +57,61 @@ input, select, textarea {
font-size: 16px; font-size: 16px;
} }
/* Scrollbar hidden utility */
.scrollbar-none::-webkit-scrollbar { display: none; }
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
/* Chat scroll */
.chat-scroll { .chat-scroll {
scroll-behavior: smooth; scroll-behavior: smooth;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Hide scrollbar on category strips */ /* Glassmorphism utility */
.scrollbar-none::-webkit-scrollbar { display: none; } .glass {
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; } background: rgba(17, 17, 24, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-light {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* Gold gradient */
.gold-gradient {
background: linear-gradient(135deg, #D4AF37, #B8860B);
}
/* Nur purple gradient */
.nur-gradient {
background: linear-gradient(135deg, #A78BFA, #7C3AED);
}
/* Gold-purple combined gradient (for center Nur tab) */
.gold-purple-gradient {
background: linear-gradient(135deg, #D4AF37, #A78BFA);
}
/* Prayer countdown ring SVG */
.prayer-ring {
transform: rotate(-90deg);
}
.prayer-ring-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 4;
}
.prayer-ring-fg {
fill: none;
stroke: #D4AF37;
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 1s ease;
}
+17 -8
View File
@@ -1,22 +1,31 @@
import type { Metadata } from 'next' import type { Metadata, Viewport } from 'next'
import { AuthProvider } from '@/lib/AuthContext' import { AuthProvider } from '@/lib/AuthContext'
import BottomNav from '@/components/BottomNav' import BottomNav from '@/components/BottomNav'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Falah — Islamic Lifestyle', title: 'Falah — Your Islamic Lifestyle Companion',
description: 'Nur AI coaching, Halal marketplace, community & wallet', description: 'Nur AI coaching, daily spiritual digest, prayer times, halal marketplace & community',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Falah',
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: '#0a0a0f',
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <link rel="apple-touch-icon" href="/icons/icon-512.svg" />
<meta name="theme-color" content="#0a0a0f" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head> </head>
<body> <body>
<AuthProvider> <AuthProvider>
+51 -23
View File
@@ -2,43 +2,67 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Home, Moon, Bot, Repeat2, User } from 'lucide-react' import { House, Brain, User } from 'lucide-react'
const tabs = [ const tabs = [
{ href: '/home', icon: Home, label: 'Home' }, { href: '/home', icon: House, label: 'Home' },
{ href: '/prayer', icon: Moon, label: 'Prayer' }, { href: '/nur', icon: Brain, label: 'Nur', center: true },
{ href: '/nur', icon: Bot, label: 'Nur', center: true }, { href: '/profile', icon: User, label: 'Profile' },
{ href: '/dhikr', icon: Repeat2, label: 'Dhikr' },
{ href: '/profile',icon: User, label: 'Profile' },
] ]
export default function BottomNav() { export default function BottomNav() {
const pathname = usePathname() const pathname = usePathname()
// Hide on auth pages
if (pathname === '/login' || pathname === '/register') return null if (pathname === '/login' || pathname === '/register') return null
return ( return (
<nav <nav className="fixed bottom-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-100" <div
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', height: 'calc(64px + env(safe-area-inset-bottom, 0px))' }} className="glass rounded-[28px] px-5 py-1.5 flex items-center gap-2 pointer-events-auto"
> style={{
<div className="flex items-stretch h-16 px-2"> marginBottom: 'max(env(safe-area-inset-bottom, 0px), 16px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}
>
{tabs.map(({ href, icon: Icon, label, center }) => { {tabs.map(({ href, icon: Icon, label, center }) => {
const isActive = pathname === href || (href !== '/home' && pathname.startsWith(href + '/')) const isActive =
pathname === href ||
(href !== '/home' && pathname.startsWith(href + '/'))
if (center) { if (center) {
return ( return (
<Link <Link
key={href} key={href}
href={href} href={href}
className="flex-1 flex flex-col items-center justify-center gap-0.5" className="relative flex flex-col items-center active:scale-95 transition-transform"
style={{ marginTop: '-20px' }}
> >
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm transition-all active:scale-95 ${ <div
isActive className={`w-14 h-14 rounded-full flex items-center justify-center ${
? 'bg-gradient-to-br from-violet-500 to-purple-600' isActive
: 'bg-gradient-to-br from-emerald-500 to-teal-600' ? 'gold-purple-gradient shadow-lg shadow-purple-500/20'
}`}> : 'gold-purple-gradient opacity-80'
<Icon size={22} className="text-white" strokeWidth={2} /> }`}
style={{
border: isActive
? '2px solid rgba(255,255,255,0.15)'
: '2px solid rgba(255,255,255,0.06)',
}}
>
<Icon
size={22}
className={isActive ? 'text-white' : 'text-white/80'}
strokeWidth={isActive ? 2.5 : 1.8}
/>
</div> </div>
<span
className={`text-[10px] font-semibold mt-0.5 ${
isActive ? 'text-gold' : 'text-text-muted'
}`}
>
{label}
</span>
</Link> </Link>
) )
} }
@@ -47,14 +71,18 @@ export default function BottomNav() {
<Link <Link
key={href} key={href}
href={href} href={href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-opacity active:opacity-60" className="flex flex-col items-center justify-center gap-0.5 px-6 py-2 active:scale-95 transition-transform"
> >
<Icon <Icon
size={22} size={20}
className={isActive ? 'text-emerald-600' : 'text-gray-400'} className={isActive ? 'text-gold' : 'text-text-muted'}
strokeWidth={isActive ? 2.5 : 1.8} strokeWidth={isActive ? 2.5 : 1.8}
/> />
<span className={`text-[10px] font-medium ${isActive ? 'text-emerald-600' : 'text-gray-400'}`}> <span
className={`text-[10px] font-semibold ${
isActive ? 'text-gold' : 'text-text-muted'
}`}
>
{label} {label}
</span> </span>
</Link> </Link>
+25
View File
@@ -0,0 +1,25 @@
const WP_API = process.env.NEXT_PUBLIC_WP_API_URL || ''
export async function fetchAPI<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const token =
typeof window !== 'undefined'
? localStorage.getItem('flh_token')
: null
const res = await fetch(`${WP_API}/${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...options,
})
if (!res.ok) {
throw new Error(`WordPress API error: ${res.status}`)
}
return res.json()
}
+21
View File
@@ -0,0 +1,21 @@
import { defaultCache } from '@serwist/next/worker'
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { Serwist } from 'serwist'
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
}
}
declare const self: ServiceWorkerGlobalScope
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
})
serwist.addEventListeners()