From dcbe35d09b65b0d242f6af078ac3712331e93d51 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 11 Jun 2026 16:15:42 +0200 Subject: [PATCH 1/4] fix design --- app/action/[userId]/page.tsx | 189 ++++++--- .../[userId]/action/[actionId]/page.tsx | 191 ++++++++- components/ActionPage/ActionWizardModal.tsx | 238 +++++++++-- components/action-modals/VoteModal.tsx | 401 ++++++++++++++++-- 4 files changed, 878 insertions(+), 141 deletions(-) diff --git a/app/action/[userId]/page.tsx b/app/action/[userId]/page.tsx index 489169b..c4b1784 100644 --- a/app/action/[userId]/page.tsx +++ b/app/action/[userId]/page.tsx @@ -22,6 +22,7 @@ import { ChevronRight, Lock, ArrowRight, + Plus, } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import Navigation from '@/components/Navigation'; @@ -423,6 +424,7 @@ const ActionsByAccountPage = () => { const [isSubActionsModalOpen, setIsSubActionsModalOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [editingActionId, setEditingActionId] = useState(null); + const [preSelectedType, setPreSelectedType] = useState(null); const [creatingSubAction, setCreatingSubAction] = useState(false); const [qrValidatorOpen, setQrValidatorOpen] = useState(false); const [subActionError, setSubActionError] = useState(null); @@ -826,6 +828,7 @@ const ActionsByAccountPage = () => { const handleWizardCompleted = () => { setWizardOpen(false); setEditingActionId(null); + setPreSelectedType(null); if (effectiveUserId) { fetchData(effectiveUserId); } @@ -834,6 +837,7 @@ const ActionsByAccountPage = () => { const handleWizardClose = () => { setWizardOpen(false); setEditingActionId(null); + setPreSelectedType(null); }; // Handler for organizations to mark a QR object as used @@ -1179,65 +1183,134 @@ const ActionsByAccountPage = () => { const filteredActions = getFilteredOrganizationActions(); return ( -
- {/* Filter and Action Buttons - Always Visible */} -
-
-
- -
- {[ - { value: 'all', label: 'All' }, - { value: 'published', label: 'Published' }, - { value: 'draft', label: 'Draft' }, - { value: 'archived', label: 'Archived' } - ].map(filter => { - let count = 0; - if (filter.value === 'all') { - count = organizationActions.length; - } else if (filter.value === 'archived') { - count = organizationActions.filter(action => action.status === 'archived' || isActionExpired(action)).length; - } else { - count = organizationActions.filter(action => action.status === filter.value && !isActionExpired(action)).length; - } - return ( - - ); - })} -
-
-
-
+
+ {/* Create New Action */} +
+

+ + Create new action +

+
+ {/* Ticket */} + + {/* Transport */} + + + {/* Service */} + + + {/* Vote */} + + + {/* Booking */} + + + {/* Membership */}
+ {/* Status filter + Scan QR */} +
+
+ {[ + { value: 'all', label: 'All' }, + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, + { value: 'archived', label: 'Archived' }, + ].map(filter => { + let count = 0; + if (filter.value === 'all') count = organizationActions.length; + else if (filter.value === 'archived') count = organizationActions.filter(a => a.status === 'archived' || isActionExpired(a)).length; + else count = organizationActions.filter(a => a.status === filter.value && !isActionExpired(a)).length; + return ( + + ); + })} +
+ +
+ {/* Content Section - Empty or Actions Grid */} {!organizationActions.length ? (
@@ -1245,20 +1318,9 @@ const ActionsByAccountPage = () => { No actions published yet
-

- Create your first action to start accepting payments or issuing tickets. They will appear here in the same - layout visitors see on your welcome page. +

+ Select an action type above to get started. Your published actions will appear here in the same layout visitors see on your welcome page.

-
) : filteredActions.length === 0 ? (
@@ -1720,6 +1782,7 @@ const ActionsByAccountPage = () => { organizationId={effectiveUserId} onCompleted={handleWizardCompleted} editingActionId={editingActionId} + preSelectedType={preSelectedType} /> ('votes-desc'); + const [viewMode, setViewMode] = useState('grid'); + const [pinnedCandidateIds, setPinnedCandidateIds] = useState([]); const filteredCandidates = useMemo(() => { let list = activeSubActions; if (searchQuery) list = list.filter((s: SubAction) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); - if (voteFilter === 'top') list = [...list].sort((a: SubAction, b: SubAction) => (b.metadata?.votes || 0) - (a.metadata?.votes || 0)); - return list; - }, [activeSubActions, searchQuery, voteFilter]); + + const getVotes = (candidate: SubAction) => Number(candidate.metadata?.votes ?? 0); + const getRank = (candidate: SubAction) => Number(candidate.metadata?.rank ?? Number.MAX_SAFE_INTEGER); + const getAgeScore = (candidate: SubAction) => { + const createdAt = Date.parse(candidate.createdAt); + return Number.isNaN(createdAt) ? Number.MAX_SAFE_INTEGER : Date.now() - createdAt; + }; + const getMomentum = (candidate: SubAction) => getVotes(candidate) / Math.max(getAgeScore(candidate) / 3_600_000, 1); + + const compareCandidates = (a: SubAction, b: SubAction) => { + const votesA = getVotes(a); + const votesB = getVotes(b); + const rankA = getRank(a); + const rankB = getRank(b); + const sortOrderA = a.sortOrder ?? Number.MAX_SAFE_INTEGER; + const sortOrderB = b.sortOrder ?? Number.MAX_SAFE_INTEGER; + const createdAtA = Date.parse(a.createdAt) || 0; + const createdAtB = Date.parse(b.createdAt) || 0; + const momentumA = getMomentum(a); + const momentumB = getMomentum(b); + + if (sortMode === 'votes-desc') return votesB - votesA || rankA - rankB || sortOrderA - sortOrderB || createdAtB - createdAtA || a.name.localeCompare(b.name); + if (sortMode === 'votes-asc') return votesA - votesB || rankA - rankB || sortOrderA - sortOrderB || createdAtA - createdAtB || a.name.localeCompare(b.name); + if (sortMode === 'newest') return createdAtB - createdAtA || votesB - votesA || rankA - rankB || sortOrderA - sortOrderB || a.name.localeCompare(b.name); + if (sortMode === 'oldest') return createdAtA - createdAtB || votesB - votesA || rankA - rankB || sortOrderA - sortOrderB || a.name.localeCompare(b.name); + if (sortMode === 'trending') return momentumB - momentumA || votesB - votesA || rankA - rankB || sortOrderA - sortOrderB || createdAtB - createdAtA || a.name.localeCompare(b.name); + return sortOrderA - sortOrderB || rankA - rankB || votesB - votesA || createdAtA - createdAtB || a.name.localeCompare(b.name); + }; + + const sortFiltered = [...list].sort(compareCandidates); + if (voteFilter === 'top') return sortFiltered; + if (voteFilter === 'new') return [...sortFiltered].sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0)); + return sortFiltered; + }, [activeSubActions, searchQuery, voteFilter, sortMode]); + + const getVotes = (candidate: SubAction) => Number(candidate.metadata?.votes ?? 0); + const getRank = (candidate: SubAction) => Number(candidate.metadata?.rank ?? Number.MAX_SAFE_INTEGER); + const getAgeScore = (candidate: SubAction) => { + const createdAt = Date.parse(candidate.createdAt); + return Number.isNaN(createdAt) ? Number.MAX_SAFE_INTEGER : Date.now() - createdAt; + }; + const getMomentum = (candidate: SubAction) => getVotes(candidate) / Math.max(getAgeScore(candidate) / 3_600_000, 1); + const getTrendLabel = (candidate: SubAction) => { + const momentum = getMomentum(candidate); + if (momentum >= 10) return 'Surging'; + if (momentum >= 4) return 'Trending'; + if (momentum >= 1) return 'Rising'; + return 'Steady'; + }; + + const pinnedCandidates = useMemo( + () => pinnedCandidateIds.map(candidateId => activeSubActions.find((candidate: SubAction) => candidate.id === candidateId)).filter(Boolean) as SubAction[], + [activeSubActions, pinnedCandidateIds] + ); + + const togglePinnedCandidate = (candidateId: string) => { + setPinnedCandidateIds(prev => { + if (prev.includes(candidateId)) return prev.filter(id => id !== candidateId); + if (prev.length >= 3) return [...prev.slice(1), candidateId]; + return [...prev, candidateId]; + }); + }; const [expandedMore, setExpandedMore] = useState>({}); @@ -801,7 +870,9 @@ function VoteContent({ {/* Search */}
+ setSearchQuery(e.target.value)} placeholder="Search candidate..." @@ -809,11 +880,120 @@ function VoteContent({ />
+
+
+ + +
+
+ + Sort by votes +
+
+ +
+ {([ + { value: 'votes-desc', label: 'Highest votes' }, + { value: 'votes-asc', label: 'Lowest votes' }, + { value: 'newest', label: 'Newest' }, + { value: 'oldest', label: 'Oldest' }, + { value: 'trending', label: 'Trending' }, + { value: 'custom', label: 'Custom rank' }, + ] as const).map(option => ( + + ))} +
+ + {pinnedCandidates.length > 0 && ( +
+
+
+

Pinned comparison

+

Compare up to 3 candidates side by side

+
+ +
+
+ {pinnedCandidates.map(candidate => ( +
+
+
+

{candidate.name}

+

{getTrendLabel(candidate)}

+
+ +
+
+
+

Votes

+

{Number(getVotes(candidate)).toLocaleString()}

+
+
+

Rank

+

{getRank(candidate) !== Number.MAX_SAFE_INTEGER ? `#${getRank(candidate)}` : '—'}

+
+
+

Trend

+

{getTrendLabel(candidate)}

+
+
+
+ ))} +
+
+ )} + {/* Filter tabs */}
{(['all', 'top', 'new'] as const).map(f => (
) : ( -
+
{filteredCandidates.map((candidate: SubAction) => { const isSelected = selectedCandidate?.id === candidate.id; const votes = candidate.metadata?.votes; @@ -848,6 +1028,9 @@ function VoteContent({ const zone = candidate.metadata?.zone; const badge = candidate.metadata?.badge; const candidateNum = candidate.metadata?.candidateNumber; + const momentum = getMomentum(candidate); + const trend = getTrendLabel(candidate); + const isPinned = pinnedCandidateIds.includes(candidate.id); const hasStats = votes !== undefined || rank !== undefined || zone; const isMoreExpanded = expandedMore[candidate.id]; diff --git a/components/ActionPage/ActionWizardModal.tsx b/components/ActionPage/ActionWizardModal.tsx index f028d2d..30b4032 100644 --- a/components/ActionPage/ActionWizardModal.tsx +++ b/components/ActionPage/ActionWizardModal.tsx @@ -47,6 +47,7 @@ interface ActionWizardModalProps { organizationId: string; onCompleted: () => void; editingActionId?: string | null; + preSelectedType?: string | null; } const allStepItems = [ @@ -61,15 +62,9 @@ const validTypes = [ 'ticket', 'transport', 'service', - 'subscription', - 'payment', - 'donation', 'vote', 'booking', - 'license', 'membership', - 'rental', - 'group', ]; // Dynamic form configuration based on action type @@ -188,6 +183,153 @@ const actionTypeConfig: Record> = { + ticket: { + builderTitle: 'Ticket Builder', + wizardTitle: 'Create a Ticket', + wizardDescription: 'Set up your event ticket — name, pricing tiers, availability, and more.', + nameLabel: 'Event Name', + shortDescLabel: 'Event Tagline', + descLabel: 'Full Event Details', + subAction: { singular: 'Ticket Tier', plural: 'Ticket Tiers' }, + stepA: { title: 'Event Details', description: 'Name & media' }, + stepB: { title: 'Ticket Pricing', description: 'Price & tiers' }, + }, + transport: { + builderTitle: 'Transport Builder', + wizardTitle: 'Create a Transport Route', + wizardDescription: 'Define your route, fare structure, and seat classes.', + nameLabel: 'Route Name', + shortDescLabel: 'Route Summary', + descLabel: 'Route Details', + subAction: { singular: 'Seat Class', plural: 'Seat Classes' }, + stepA: { title: 'Route Info', description: 'Name & media' }, + stepB: { title: 'Fare Details', description: 'Currency & fare' }, + }, + service: { + builderTitle: 'Service Builder', + wizardTitle: 'Create a Service', + wizardDescription: 'Describe your service, packages, and how clients can book or pay.', + nameLabel: 'Service Name', + shortDescLabel: 'Service Tagline', + descLabel: 'Service Details', + subAction: { singular: 'Service Package', plural: 'Service Packages' }, + stepA: { title: 'Service Details', description: 'Name & media' }, + stepB: { title: 'Service Pricing', description: 'Currency & price' }, + }, + subscription: { + builderTitle: 'Subscription Builder', + wizardTitle: 'Create a Subscription Plan', + wizardDescription: "Set up recurring plans — define tiers, benefits, and billing details.", + nameLabel: 'Plan Name', + shortDescLabel: 'Plan Summary', + descLabel: "What's Included", + subAction: { singular: 'Subscription Tier', plural: 'Subscription Tiers' }, + stepA: { title: 'Plan Details', description: 'Name & media' }, + stepB: { title: 'Plan Pricing', description: 'Currency & tiers' }, + }, + payment: { + builderTitle: 'Payment Builder', + wizardTitle: 'Create a Payment', + wizardDescription: 'Configure a payment link — purpose, amount, and collection details.', + nameLabel: 'Payment Title', + shortDescLabel: 'Payment Purpose', + descLabel: 'Additional Details', + subAction: { singular: 'Payment Option', plural: 'Payment Options' }, + stepA: { title: 'Payment Details', description: 'Title & purpose' }, + stepB: { title: 'Payment Config', description: 'Currency & amount' }, + }, + donation: { + builderTitle: 'Donation Builder', + wizardTitle: 'Create a Donation Campaign', + wizardDescription: 'Tell your story, set giving levels, and start collecting donations.', + nameLabel: 'Campaign Name', + shortDescLabel: 'Campaign Tagline', + descLabel: 'Campaign Story', + subAction: { singular: 'Giving Level', plural: 'Giving Levels' }, + stepA: { title: 'Campaign Details', description: 'Name & media' }, + stepB: { title: 'Donation Config', description: 'Currency & amounts' }, + }, + vote: { + builderTitle: 'Vote Builder', + wizardTitle: 'Create a Vote', + wizardDescription: 'Set up a poll or election — add contestants and configure voting rules.', + nameLabel: 'Vote Title', + shortDescLabel: 'What Are People Voting On?', + descLabel: 'Vote Details', + subAction: { singular: 'Contestant', plural: 'Contestants' }, + stepA: { title: 'Vote Details', description: 'Title & context' }, + stepB: { title: 'Voting Cost', description: 'Currency & price' }, + }, + booking: { + builderTitle: 'Booking Builder', + wizardTitle: 'Create a Booking', + wizardDescription: 'Configure what can be booked, availability windows, and pricing.', + nameLabel: 'Booking Name', + shortDescLabel: 'What Can Be Booked?', + descLabel: 'Booking Conditions', + subAction: { singular: 'Booking Option', plural: 'Booking Options' }, + stepA: { title: 'Booking Details', description: 'Name & media' }, + stepB: { title: 'Booking Pricing', description: 'Currency & price' }, + }, + license: { + builderTitle: 'License Builder', + wizardTitle: 'Create a License', + wizardDescription: 'Define license tiers, terms, and how they are issued to buyers.', + nameLabel: 'License Name', + shortDescLabel: 'License Summary', + descLabel: 'Terms & Conditions', + subAction: { singular: 'License Tier', plural: 'License Tiers' }, + stepA: { title: 'License Details', description: 'Name & terms' }, + stepB: { title: 'License Pricing', description: 'Currency & price' }, + }, + membership: { + builderTitle: 'Membership Builder', + wizardTitle: 'Create a Membership', + wizardDescription: 'Build membership plans with benefits, tiers, and renewal settings.', + nameLabel: 'Membership Name', + shortDescLabel: 'Member Benefits', + descLabel: 'Full Membership Details', + subAction: { singular: 'Membership Plan', plural: 'Membership Plans' }, + stepA: { title: 'Membership Details', description: 'Name & media' }, + stepB: { title: 'Membership Pricing', description: 'Currency & tiers' }, + }, + rental: { + builderTitle: 'Rental Builder', + wizardTitle: 'Create a Rental', + wizardDescription: 'List what can be rented, set pricing options, and define rental terms.', + nameLabel: 'Rental Name', + shortDescLabel: 'What Can Be Rented?', + descLabel: 'Rental Terms', + subAction: { singular: 'Rental Option', plural: 'Rental Options' }, + stepA: { title: 'Rental Details', description: 'Name & media' }, + stepB: { title: 'Rental Pricing', description: 'Currency & price' }, + }, + group: { + builderTitle: 'Group Builder', + wizardTitle: 'Create a Group', + wizardDescription: 'Set up a community group — membership tiers, fees, and group details.', + nameLabel: 'Group Name', + shortDescLabel: 'Group Summary', + descLabel: 'About the Group', + subAction: { singular: 'Membership Tier', plural: 'Membership Tiers' }, + stepA: { title: 'Group Details', description: 'Name & media' }, + stepB: { title: 'Group Pricing', description: 'Currency & fees' }, + }, +}; + // const visibilityOptions = [ // { label: 'Public', value: 'public' }, // { label: 'Private', value: 'private' }, @@ -465,7 +607,7 @@ const KeyValueInput: React.FC = ({ value, onChange, placehol ); }; -const ActionWizardModal: React.FC = ({ open, onClose, organizationId, onCompleted, editingActionId }) => { +const ActionWizardModal: React.FC = ({ open, onClose, organizationId, onCompleted, editingActionId, preSelectedType }) => { const [currentStep, setCurrentStep] = useState(0); const [form] = Form.useForm(); const [subActionForm] = Form.useForm(); @@ -487,11 +629,13 @@ const ActionWizardModal: React.FC = ({ open, onClose, or const [pricingMode, setPricingMode] = useState('fixed'); const [availabilityMode, setAvailabilityMode] = useState('always'); - const subActionLabel = useMemo(() => { - if (selectedType === 'vote') return { singular: 'Contestant', plural: 'Contestants' }; - return { singular: 'Sub-action', plural: 'Sub-actions' }; + const currentLabels = useMemo(() => { + const overrides = selectedType ? (actionTypeLabels[selectedType] ?? {}) : {}; + return { ...defaultLabels, ...overrides }; }, [selectedType]); + const subActionLabel = useMemo(() => currentLabels.subAction, [currentLabels]); + // Filter steps based on pricing mode - show subActions for tiered and pay_what_you_want pricing const stepItems = useMemo(() => { return allStepItems @@ -505,9 +649,15 @@ const ActionWizardModal: React.FC = ({ open, onClose, or if (step.key === 'subActions') { return { ...step, title: subActionLabel.plural, description: subActionLabel.plural }; } + if (step.key === 'stepA') { + return { ...step, title: currentLabels.stepA.title, description: currentLabels.stepA.description }; + } + if (step.key === 'stepB') { + return { ...step, title: currentLabels.stepB.title, description: currentLabels.stepB.description }; + } return step; }); - }, [pricingMode, subActionLabel]); + }, [pricingMode, subActionLabel, currentLabels]); const stepKey = useMemo(() => stepItems[currentStep]?.key, [currentStep, stepItems]); const isLastStep = currentStep === stepItems.length - 1; @@ -570,11 +720,12 @@ const ActionWizardModal: React.FC = ({ open, onClose, or description: existingAction.description || '', dedicatedQrCode: existingAction.dedicatedQrCode || '', }); - // Set preview if cover image exists if (existingAction.coverImage) { setCoverImagePreview(existingAction.coverImage); } - + } else if (preSelectedType) { + setSelectedType(preSelectedType); + form.setFieldValue('type', preSelectedType); } else { setSelectedType(undefined); } @@ -635,8 +786,11 @@ const ActionWizardModal: React.FC = ({ open, onClose, or setExistingAction(null); setActionId(null); setActionNameForSubActions(''); + if (preSelectedType) { + setSelectedType(preSelectedType); + } } - }, [editingActionId, open]); + }, [editingActionId, open, preSelectedType]); const loadSubActions = useCallback(async () => { if (!actionId) return; @@ -1088,20 +1242,28 @@ const ActionWizardModal: React.FC = ({ open, onClose, or case 'stepA': return (
- - +
+ ) : ( + + = ({ open, onClose, or )} {selectedType && actionTypeConfig[selectedType]?.showFields?.includes('shortDescription') && ( - + )} - + {selectedType && actionTypeConfig[selectedType]?.showFields?.includes('description') && ( - +