Compare commits

...

3 Commits

Author SHA1 Message Date
Matt 41a36f72b3 Add WhatsApp feature flag and improve onboarding
Build and Push Docker Image / build (push) Failing after 2m59s Details
- Add getFeatureFlags endpoint to check if WhatsApp is enabled
- Skip phone step in onboarding when WhatsApp is disabled
- Hide WhatsApp notification options when disabled
- Add ExpertiseSelect component with predefined ocean conservation tags
- Fix onboarding layout to fill viewport on desktop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:58:22 +01:00
Matt 4f0531d2ee Add ExpertiseSelect component for onboarding
Replace database-backed TagInput with a cleaner ExpertiseSelect
component that has predefined ocean conservation expertise areas.
Features a checkbox grid UI that's more user-friendly for onboarding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:56:03 +01:00
Matt 39f7bc207b Use PhoneInput component with country dropdown in onboarding
Replace plain text input with the existing PhoneInput component that
includes country code dropdown with flags and auto-formatting.
Default country is set to Monaco (MC).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:54:57 +01:00
3 changed files with 209 additions and 30 deletions

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input'
import { import {
Card, Card,
CardContent, CardContent,
@ -21,7 +22,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { toast } from 'sonner' import { toast } from 'sonner'
import { TagInput } from '@/components/shared/tag-input' import { ExpertiseSelect } from '@/components/shared/expertise-select'
import { import {
User, User,
Phone, Phone,
@ -47,10 +48,23 @@ export default function OnboardingPage() {
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE' 'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL') >('EMAIL')
// Fetch feature flags
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
const completeOnboarding = trpc.user.completeOnboarding.useMutation() const completeOnboarding = trpc.user.completeOnboarding.useMutation()
const steps: Step[] = ['name', 'phone', 'tags', 'preferences', 'complete'] // Dynamic steps based on WhatsApp availability
const steps: Step[] = useMemo(() => {
if (whatsappEnabled) {
return ['name', 'phone', 'tags', 'preferences', 'complete']
}
// Skip phone step if WhatsApp is disabled
return ['name', 'tags', 'preferences', 'complete']
}, [whatsappEnabled])
const currentIndex = steps.indexOf(step) const currentIndex = steps.indexOf(step)
const totalVisibleSteps = steps.length - 1 // Exclude 'complete' from count
const goNext = () => { const goNext = () => {
if (step === 'name' && !name.trim()) { if (step === 'name' && !name.trim()) {
@ -91,8 +105,8 @@ export default function OnboardingPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]"> <div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-lg"> <Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl">
{/* Progress indicator */} {/* Progress indicator */}
<div className="px-6 pt-6"> <div className="px-6 pt-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -111,7 +125,7 @@ export default function OnboardingPage() {
))} ))}
</div> </div>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Step {currentIndex + 1} of {steps.length - 1} Step {currentIndex + 1} of {totalVisibleSteps}
</p> </p>
</div> </div>
@ -147,8 +161,8 @@ export default function OnboardingPage() {
</> </>
)} )}
{/* Step 2: Phone */} {/* Step 2: Phone (only if WhatsApp enabled) */}
{step === 'phone' && ( {step === 'phone' && whatsappEnabled && (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -162,15 +176,15 @@ export default function OnboardingPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phone">Phone Number (Optional)</Label> <Label htmlFor="phone">Phone Number (Optional)</Label>
<Input <PhoneInput
id="phone" id="phone"
type="tel"
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(value) => setPhoneNumber(value || '')}
placeholder="+377 12 34 56 78" defaultCountry="MC"
placeholder="Enter phone number"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Include country code for WhatsApp notifications Select your country and enter your number for WhatsApp notifications
</p> </p>
</div> </div>
@ -201,15 +215,11 @@ export default function OnboardingPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <ExpertiseSelect
<Label>Expertise Tags</Label> value={expertiseTags}
<TagInput onChange={setExpertiseTags}
value={expertiseTags} maxTags={5}
onChange={setExpertiseTags} />
placeholder="Select your expertise areas..."
maxTags={10}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1"> <Button variant="outline" onClick={goBack} className="flex-1">
@ -251,16 +261,20 @@ export default function OnboardingPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="EMAIL">Email only</SelectItem> <SelectItem value="EMAIL">Email only</SelectItem>
<SelectItem value="WHATSAPP" disabled={!phoneNumber}> {whatsappEnabled && (
WhatsApp only <>
</SelectItem> <SelectItem value="WHATSAPP" disabled={!phoneNumber}>
<SelectItem value="BOTH" disabled={!phoneNumber}> WhatsApp only
Both Email and WhatsApp </SelectItem>
</SelectItem> <SelectItem value="BOTH" disabled={!phoneNumber}>
Both Email and WhatsApp
</SelectItem>
</>
)}
<SelectItem value="NONE">No notifications</SelectItem> <SelectItem value="NONE">No notifications</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{!phoneNumber && ( {whatsappEnabled && !phoneNumber && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Add a phone number to enable WhatsApp notifications Add a phone number to enable WhatsApp notifications
</p> </p>
@ -273,7 +287,7 @@ export default function OnboardingPage() {
<p> <p>
<span className="text-muted-foreground">Name:</span> {name} <span className="text-muted-foreground">Name:</span> {name}
</p> </p>
{phoneNumber && ( {whatsappEnabled && phoneNumber && (
<p> <p>
<span className="text-muted-foreground">Phone:</span>{' '} <span className="text-muted-foreground">Phone:</span>{' '}
{phoneNumber} {phoneNumber}

View File

@ -0,0 +1,149 @@
'use client'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Check, X } from 'lucide-react'
// Predefined expertise areas for ocean conservation
const EXPERTISE_OPTIONS = [
{ id: 'marine-biology', name: 'Marine Biology', color: '#0ea5e9' },
{ id: 'ocean-conservation', name: 'Ocean Conservation', color: '#06b6d4' },
{ id: 'climate-science', name: 'Climate Science', color: '#14b8a6' },
{ id: 'sustainable-fishing', name: 'Sustainable Fishing', color: '#22c55e' },
{ id: 'plastic-pollution', name: 'Plastic Pollution', color: '#84cc16' },
{ id: 'coral-reef', name: 'Coral Reef Restoration', color: '#f97316' },
{ id: 'blue-economy', name: 'Blue Economy', color: '#3b82f6' },
{ id: 'marine-technology', name: 'Marine Technology', color: '#8b5cf6' },
{ id: 'environmental-policy', name: 'Environmental Policy', color: '#a855f7' },
{ id: 'oceanography', name: 'Oceanography', color: '#0284c7' },
{ id: 'renewable-energy', name: 'Renewable Energy', color: '#16a34a' },
{ id: 'waste-management', name: 'Waste Management', color: '#65a30d' },
{ id: 'biodiversity', name: 'Biodiversity', color: '#059669' },
{ id: 'shipping-maritime', name: 'Shipping & Maritime', color: '#6366f1' },
{ id: 'education-outreach', name: 'Education & Outreach', color: '#ec4899' },
{ id: 'entrepreneurship', name: 'Entrepreneurship', color: '#f43f5e' },
{ id: 'investment-finance', name: 'Investment & Finance', color: '#eab308' },
{ id: 'research-academia', name: 'Research & Academia', color: '#7c3aed' },
]
interface ExpertiseSelectProps {
value: string[]
onChange: (tags: string[]) => void
maxTags?: number
disabled?: boolean
className?: string
}
export function ExpertiseSelect({
value,
onChange,
maxTags = 10,
disabled = false,
className,
}: ExpertiseSelectProps) {
const handleToggle = (name: string) => {
if (disabled) return
if (value.includes(name)) {
onChange(value.filter((t) => t !== name))
} else {
if (maxTags && value.length >= maxTags) return
onChange([...value, name])
}
}
const handleRemove = (name: string) => {
if (disabled) return
onChange(value.filter((t) => t !== name))
}
const getOption = (name: string) =>
EXPERTISE_OPTIONS.find((o) => o.name === name)
return (
<div className={cn('space-y-4', className)}>
{/* Selected tags at the top */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2">
{value.map((name) => {
const option = getOption(name)
return (
<Badge
key={name}
variant="secondary"
className="gap-1.5 py-1 px-2 text-sm"
style={{
backgroundColor: option?.color ? `${option.color}15` : undefined,
borderColor: option?.color || undefined,
color: option?.color || undefined,
}}
>
{name}
{!disabled && (
<button
type="button"
onClick={() => handleRemove(name)}
className="ml-0.5 rounded-full p-0.5 hover:bg-black/10 transition-colors"
aria-label={`Remove ${name}`}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
)
})}
</div>
)}
{/* Grid of options */}
<div className="grid grid-cols-2 gap-2">
{EXPERTISE_OPTIONS.map((option) => {
const isSelected = value.includes(option.name)
const isDisabled = disabled || (!isSelected && value.length >= maxTags)
return (
<Button
key={option.id}
type="button"
variant="outline"
size="sm"
disabled={isDisabled}
onClick={() => handleToggle(option.name)}
className={cn(
'justify-start h-auto py-2 px-3 text-left font-normal transition-all',
isSelected && 'ring-2 ring-offset-1',
isDisabled && !isSelected && 'opacity-50'
)}
style={{
borderColor: isSelected ? option.color : undefined,
ringColor: option.color,
backgroundColor: isSelected ? `${option.color}10` : undefined,
}}
>
<div
className={cn(
'h-4 w-4 rounded border-2 mr-2 flex items-center justify-center transition-colors',
isSelected ? 'border-current bg-current' : 'border-muted-foreground/30'
)}
style={{
borderColor: isSelected ? option.color : undefined,
backgroundColor: isSelected ? option.color : undefined,
}}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
<span className="text-sm">{option.name}</span>
</Button>
)
})}
</div>
{/* Counter */}
<p className="text-xs text-muted-foreground text-center">
{value.length} of {maxTags} selected
</p>
</div>
)
}

View File

@ -19,6 +19,22 @@ function categorizeModel(modelId: string): string {
} }
export const settingsRouter = router({ export const settingsRouter = router({
/**
* Get public feature flags (no auth required)
* These are non-sensitive settings that can be exposed to any user
*/
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled] = await Promise.all([
ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
}),
])
return {
whatsappEnabled: whatsappEnabled?.value === 'true',
}
}),
/** /**
* Get all settings by category * Get all settings by category
*/ */