From 389f4991ad6b5c49c743278f240123ab7b2b4263 Mon Sep 17 00:00:00 2001 From: silverlion2 Date: Sat, 30 May 2026 19:05:33 +0800 Subject: [PATCH 1/4] Fix study tour links and route voting --- next.config.mjs | 27 ++++ schema.sql | 21 +++ src/__tests__/RouteVoteWidget.test.jsx | 76 +++++++++++ src/__tests__/china-study-tours.test.jsx | 16 ++- src/__tests__/routeVoteIdentity.test.js | 34 +++++ src/__tests__/routeVoteStorage.test.js | 122 ++++++++++++++++++ src/__tests__/routeVoting.test.js | 34 +++++ src/app/api/route-votes/route.js | 93 +++++++++++++ src/app/china-study-tours/page.js | 78 +++-------- src/app/layout.js | 4 +- src/app/sitemap.js | 2 +- src/app/study-in-china/page.js | 2 +- .../[city]}/page.js | 8 +- src/components/engagement/RouteVoteWidget.jsx | 99 ++++++++++++++ src/components/layout/Footer.jsx | 8 +- src/lib/routeVoteIdentity.js | 23 ++++ src/lib/routeVoteStorage.js | 69 ++++++++++ src/lib/routeVoting.js | 46 +++++++ .../20260530000000_add_route_votes.sql | 27 ++++ 19 files changed, 713 insertions(+), 76 deletions(-) create mode 100644 src/__tests__/RouteVoteWidget.test.jsx create mode 100644 src/__tests__/routeVoteIdentity.test.js create mode 100644 src/__tests__/routeVoteStorage.test.js create mode 100644 src/__tests__/routeVoting.test.js create mode 100644 src/app/api/route-votes/route.js rename src/app/{study-in-[city] => study-in/[city]}/page.js (98%) create mode 100644 src/components/engagement/RouteVoteWidget.jsx create mode 100644 src/lib/routeVoteIdentity.js create mode 100644 src/lib/routeVoteStorage.js create mode 100644 src/lib/routeVoting.js create mode 100644 supabase/migrations/20260530000000_add_route_votes.sql diff --git a/next.config.mjs b/next.config.mjs index 25d1cde..c83e8aa 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,8 +1,35 @@ +const studyCitySlugs = [ + 'beijing', + 'changsha', + 'chengdu', + 'dalian', + 'guangzhou', + 'hangzhou', + 'harbin', + 'hefei', + 'nanjing', + 'shanghai', + 'tianjin', + 'wuhan', + 'xi-an', + 'xiamen', +]; + +const cityRedirects = studyCitySlugs.map((city) => ({ + source: `/study-in-${city}`, + destination: `/study-in/${city}`, + permanent: true, +})); + /** @type {import('next').NextConfig} */ const nextConfig = { // Ensure consistent URLs by not adding trailing slashes trailingSlash: false, + async redirects() { + return cityRedirects; + }, + async rewrites() { return [ { diff --git a/schema.sql b/schema.sql index dbc2daf..1a4e7d1 100644 --- a/schema.sql +++ b/schema.sql @@ -122,6 +122,19 @@ CREATE TABLE IF NOT EXISTS public.feedbacks ( created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL ); +CREATE TABLE IF NOT EXISTS public.route_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + route_path TEXT NOT NULL CHECK (route_path ~ '^/'), + voter_key TEXT NOT NULL CHECK (voter_key ~ '^[a-f0-9]{64}$'), + user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + UNIQUE(route_path, voter_key) +); + +CREATE INDEX IF NOT EXISTS route_votes_route_path_idx ON public.route_votes(route_path); +CREATE INDEX IF NOT EXISTS route_votes_created_at_idx ON public.route_votes(created_at DESC); + -- 6. Dynamic Content (Cities, Universities & Programs) CREATE TABLE IF NOT EXISTS public.cities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -198,6 +211,7 @@ ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY; ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE public.leads ENABLE ROW LEVEL SECURITY; ALTER TABLE public.feedbacks ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.route_votes ENABLE ROW LEVEL SECURITY; ALTER TABLE public.cities ENABLE ROW LEVEL SECURITY; ALTER TABLE public.universities ENABLE ROW LEVEL SECURITY; ALTER TABLE public.programs ENABLE ROW LEVEL SECURITY; @@ -286,6 +300,13 @@ CREATE POLICY "Admins can update feedbacks" ON public.feedbacks FOR UPDATE USING DROP POLICY IF EXISTS "Admins can delete feedbacks" ON public.feedbacks; CREATE POLICY "Admins can delete feedbacks" ON public.feedbacks FOR DELETE USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin')); +-- route_votes: tracked through the server API, admins can inspect/delete +DROP POLICY IF EXISTS "Admins can view route votes" ON public.route_votes; +CREATE POLICY "Admins can view route votes" ON public.route_votes FOR SELECT USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin')); + +DROP POLICY IF EXISTS "Admins can delete route votes" ON public.route_votes; +CREATE POLICY "Admins can delete route votes" ON public.route_votes FOR DELETE USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin')); + -- cities: anyone can read, admins modify DROP POLICY IF EXISTS "Anyone can view cities" ON public.cities; CREATE POLICY "Anyone can view cities" ON public.cities FOR SELECT USING (true); diff --git a/src/__tests__/RouteVoteWidget.test.jsx b/src/__tests__/RouteVoteWidget.test.jsx new file mode 100644 index 0000000..7fa2b7b --- /dev/null +++ b/src/__tests__/RouteVoteWidget.test.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import RouteVoteWidget from '@/components/engagement/RouteVoteWidget'; +import { getRouteVoteBaseCount } from '@/lib/routeVoting'; + +vi.mock('next/navigation', () => ({ + usePathname: () => '/tools/roi', +})); + +describe('RouteVoteWidget', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows the seeded route count before backend totals load', () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + routePath: '/tools/roi', + baseCount: getRouteVoteBaseCount('/tools/roi'), + realCount: 4, + displayCount: getRouteVoteBaseCount('/tools/roi') + 4, + userHasVoted: false, + }), + }); + + render(); + + expect(screen.getByText(String(getRouteVoteBaseCount('/tools/roi')))).toBeInTheDocument(); + }); + + it('loads real vote totals and posts a route vote', async () => { + const baseCount = getRouteVoteBaseCount('/tools/roi'); + const fetchMock = vi.spyOn(global, 'fetch'); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + routePath: '/tools/roi', + baseCount, + realCount: 4, + displayCount: baseCount + 4, + userHasVoted: false, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + routePath: '/tools/roi', + baseCount, + realCount: 5, + displayCount: baseCount + 5, + userHasVoted: true, + }), + }); + + render(); + + expect(await screen.findByText(String(baseCount + 4))).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /vote for this page/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/route-votes', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ path: '/tools/roi' }), + }) + ); + expect(screen.getByText(String(baseCount + 5))).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/china-study-tours.test.jsx b/src/__tests__/china-study-tours.test.jsx index af2c2df..635bad7 100644 --- a/src/__tests__/china-study-tours.test.jsx +++ b/src/__tests__/china-study-tours.test.jsx @@ -48,7 +48,7 @@ const validInternalRoutes = new Set([ '/tools/advisor', ]); -const validDynamicRoutes = []; +const validDynamicRoutes = [/^\/study-in\/[a-z0-9-]+$/]; function expectResolvableLinks(container) { const anchors = [...container.querySelectorAll('a[href]')]; @@ -150,12 +150,16 @@ describe('China study tours page', () => { expectResolvableLinks(container); }); - it('shows contact details beside downloadable study-tour brochures', () => { - const { container, getAllByText } = render(); + it('uses internal route briefs instead of downloadable brochure links', () => { + const { container } = render(); + const linkedText = getLinkedText(container); + + ['AI/Tech Route Brief', 'Healthcare Route Brief', 'School Study Tour Route Brief'].forEach((title) => { + const matchingLinks = linkedText.filter((text) => new RegExp(escapeRegex(title), 'i').test(text)); - expect(getAllByText('hello@pandaoffer.top').length).toBeGreaterThanOrEqual(3); - expect(container.querySelectorAll('a[href^="mailto:hello@pandaoffer.top"]').length).toBeGreaterThanOrEqual(3); - expect(container.querySelectorAll('a[download][href^="/brochures/"]').length).toBe(3); + expect(matchingLinks.length, `${title} should be rendered inside a link`).toBeGreaterThan(0); + }); + expect(container.querySelectorAll('a[download][href^="/brochures/"]').length).toBe(0); }); it('does not render unresolved SEO landing-page links', () => { diff --git a/src/__tests__/routeVoteIdentity.test.js b/src/__tests__/routeVoteIdentity.test.js new file mode 100644 index 0000000..5f47e62 --- /dev/null +++ b/src/__tests__/routeVoteIdentity.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + ROUTE_VOTER_COOKIE, + buildVoterKey, + isValidVoterId, + routeVoterCookieOptions, +} from '@/lib/routeVoteIdentity'; + +describe('route vote visitor identity', () => { + it('accepts only generated UUID-style visitor ids', () => { + expect(isValidVoterId('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + expect(isValidVoterId('visitor-hash')).toBe(false); + expect(isValidVoterId('')).toBe(false); + }); + + it('hashes visitor ids before storing them as voter keys', () => { + const first = buildVoterKey('550e8400-e29b-41d4-a716-446655440000'); + const second = buildVoterKey('550e8400-e29b-41d4-a716-446655440000'); + + expect(first).toBe(second); + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(first).not.toContain('550e8400'); + }); + + it('uses a persistent same-site cookie for route vote dedupe', () => { + expect(ROUTE_VOTER_COOKIE).toBe('po_route_voter'); + expect(routeVoterCookieOptions).toMatchObject({ + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 365, + }); + }); +}); diff --git a/src/__tests__/routeVoteStorage.test.js b/src/__tests__/routeVoteStorage.test.js new file mode 100644 index 0000000..c9ea79d --- /dev/null +++ b/src/__tests__/routeVoteStorage.test.js @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { countRouteVotes, toggleRouteVote } from '@/lib/routeVoteStorage'; + +function createSupabaseStub({ + countResult = { count: 0, error: null }, + existingVoteResult = { data: null, error: null }, + mutationResult = { error: null }, +} = {}) { + const calls = []; + + const createBuilder = () => { + let result = mutationResult; + const builder = { + select(columns, options) { + calls.push(['select', columns, options]); + result = options?.count ? countResult : existingVoteResult; + return builder; + }, + eq(column, value) { + calls.push(['eq', column, value]); + return builder; + }, + maybeSingle() { + calls.push(['maybeSingle']); + return Promise.resolve(existingVoteResult); + }, + insert(payload) { + calls.push(['insert', payload]); + return Promise.resolve(mutationResult); + }, + delete() { + calls.push(['delete']); + result = mutationResult; + return builder; + }, + then(resolve) { + return Promise.resolve(result).then(resolve); + }, + }; + return builder; + }; + + return { + calls, + supabase: { + from(table) { + calls.push(['from', table]); + return createBuilder(); + }, + }, + }; +} + +describe('route vote storage', () => { + it('counts real votes with a normalized route path', async () => { + const { supabase, calls } = createSupabaseStub({ + countResult: { count: 12, error: null }, + }); + + await expect(countRouteVotes(supabase, '/tools/roi/?utm=1')).resolves.toBe(12); + + expect(calls).toContainEqual(['from', 'route_votes']); + expect(calls).toContainEqual(['select', 'id', { count: 'exact', head: true }]); + expect(calls).toContainEqual(['eq', 'route_path', '/tools/roi']); + }); + + it('adds a vote when this visitor has not voted on the route yet', async () => { + const { supabase, calls } = createSupabaseStub({ + existingVoteResult: { data: null, error: null }, + }); + + await expect( + toggleRouteVote(supabase, { + routePath: '/tools/city/', + voterKey: 'visitor-hash', + userId: null, + userAgent: 'vitest', + }) + ).resolves.toEqual({ action: 'added', userHasVoted: true }); + + expect(calls).toContainEqual([ + 'insert', + { + route_path: '/tools/city', + voter_key: 'visitor-hash', + user_id: null, + user_agent: 'vitest', + }, + ]); + }); + + it('removes an existing vote when the visitor clicks again', async () => { + const { supabase, calls } = createSupabaseStub({ + existingVoteResult: { data: { id: 'vote-1' }, error: null }, + }); + + await expect( + toggleRouteVote(supabase, { + routePath: '/tools/city', + voterKey: 'visitor-hash', + }) + ).resolves.toEqual({ action: 'removed', userHasVoted: false }); + + expect(calls).toContainEqual(['delete']); + expect(calls).toContainEqual(['eq', 'route_path', '/tools/city']); + expect(calls).toContainEqual(['eq', 'voter_key', 'visitor-hash']); + }); + + it('does not mark a visitor as voted when storage is unavailable', async () => { + const { supabase } = createSupabaseStub({ + existingVoteResult: { data: null, error: null }, + mutationResult: { error: new Error('table missing') }, + }); + + await expect( + toggleRouteVote(supabase, { + routePath: '/tools/city', + voterKey: 'visitor-hash', + }) + ).resolves.toEqual({ action: 'unavailable', userHasVoted: false }); + }); +}); diff --git a/src/__tests__/routeVoting.test.js b/src/__tests__/routeVoting.test.js new file mode 100644 index 0000000..96ee988 --- /dev/null +++ b/src/__tests__/routeVoting.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + buildRouteVoteStats, + getRouteVoteBaseCount, + normalizeRoutePath, +} from '@/lib/routeVoting'; + +describe('route voting helpers', () => { + it('normalizes routes before using them as vote keys', () => { + expect(normalizeRoutePath('https://www.pandaoffer.top/tools/roi/?utm=test#pricing')).toBe('/tools/roi'); + expect(normalizeRoutePath('/china-study-tours#request-route')).toBe('/china-study-tours'); + expect(normalizeRoutePath('')).toBe('/'); + }); + + it('generates stable seeded baseline counts per route', () => { + const first = getRouteVoteBaseCount('/tools/roi'); + const second = getRouteVoteBaseCount('/tools/roi/'); + const otherRoute = getRouteVoteBaseCount('/tools/city'); + + expect(first).toBe(second); + expect(first).toBeGreaterThanOrEqual(86); + expect(first).toBeLessThanOrEqual(486); + expect(otherRoute).not.toBe(first); + }); + + it('adds real backend votes on top of seeded baseline display counts', () => { + const stats = buildRouteVoteStats('/tools/roi', 7, true); + + expect(stats.routePath).toBe('/tools/roi'); + expect(stats.realCount).toBe(7); + expect(stats.displayCount).toBe(stats.baseCount + 7); + expect(stats.userHasVoted).toBe(true); + }); +}); diff --git a/src/app/api/route-votes/route.js b/src/app/api/route-votes/route.js new file mode 100644 index 0000000..5e1f4d8 --- /dev/null +++ b/src/app/api/route-votes/route.js @@ -0,0 +1,93 @@ +import { createClient } from '@supabase/supabase-js'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { + ROUTE_VOTER_COOKIE, + buildVoterKey, + createVoterId, + isValidVoterId, + routeVoterCookieOptions, +} from '@/lib/routeVoteIdentity'; +import { countRouteVotes, hasRouteVote, toggleRouteVote } from '@/lib/routeVoteStorage'; +import { buildRouteVoteStats, normalizeRoutePath } from '@/lib/routeVoting'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +let supabaseAdmin = null; + +function getSupabaseAdmin() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!url || !key) return null; + + if (!supabaseAdmin) { + supabaseAdmin = createClient(url, key, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + }); + } + + return supabaseAdmin; +} + +async function getVoterIdentity() { + const cookieStore = await cookies(); + const existingVoterId = cookieStore.get(ROUTE_VOTER_COOKIE)?.value; + const voterId = isValidVoterId(existingVoterId) ? existingVoterId : createVoterId(); + + return { + voterId, + voterKey: buildVoterKey(voterId), + shouldSetCookie: voterId !== existingVoterId, + }; +} + +function jsonWithVoterCookie(payload, identity, init) { + const response = NextResponse.json(payload, init); + + if (identity.shouldSetCookie) { + response.cookies.set(ROUTE_VOTER_COOKIE, identity.voterId, routeVoterCookieOptions); + } + + return response; +} + +export async function GET(request) { + const identity = await getVoterIdentity(); + const routePath = normalizeRoutePath(new URL(request.url).searchParams.get('path') || '/'); + const supabase = getSupabaseAdmin(); + const realCount = await countRouteVotes(supabase, routePath); + const userHasVoted = await hasRouteVote(supabase, routePath, identity.voterKey); + + return jsonWithVoterCookie( + buildRouteVoteStats(routePath, realCount, userHasVoted), + identity + ); +} + +export async function POST(request) { + const identity = await getVoterIdentity(); + const body = await request.json().catch(() => ({})); + const routePath = normalizeRoutePath(body.path || request.headers.get('referer') || '/'); + const supabase = getSupabaseAdmin(); + + const voteResult = await toggleRouteVote(supabase, { + routePath, + voterKey: identity.voterKey, + userId: null, + userAgent: request.headers.get('user-agent')?.slice(0, 256) || null, + }); + const realCount = await countRouteVotes(supabase, routePath); + + return jsonWithVoterCookie( + { + ...buildRouteVoteStats(routePath, realCount, voteResult.userHasVoted), + action: voteResult.action, + }, + identity + ); +} diff --git a/src/app/china-study-tours/page.js b/src/app/china-study-tours/page.js index 8e633fc..fc6d6fc 100644 --- a/src/app/china-study-tours/page.js +++ b/src/app/china-study-tours/page.js @@ -12,7 +12,6 @@ import { CircuitBoard, ClipboardCheck, Cpu, - Download, Factory, FileText, FlaskConical, @@ -34,35 +33,12 @@ import { conversionPathway, pricingAnchors, productLineup, + routeBriefs, studyTourEmail, trustItems, visitOptionSets, } from '@/data/studyTours'; -const brochureDownloads = [ - { - title: 'AI/Tech Study Tour Brochure', - href: '/brochures/pandaoffer-ai-tech-study-tour.pdf', - audience: 'MBA/EMBA groups, university teams, investors, and tech students', - description: - 'AI applications, digital economy, robotics, smart hardware, enterprise software, and innovation park visit options.', - }, - { - title: 'Healthcare Study Tour Brochure', - href: '/brochures/pandaoffer-healthcare-study-tour.pdf', - audience: 'Healthcare executives, medical educators, investors, and hospital managers', - description: - 'Hospital operations, international departments, checkup centers, medtech, digital health, and doctor-led Q&A formats.', - }, - { - title: 'School Study Tour Brochure', - href: '/brochures/pandaoffer-school-study-tour.pdf', - audience: 'Middle schools, high schools, agencies, families, and student groups', - description: - 'Campus visits, Mandarin and culture modules, student safety operations, parent reporting, and study-abroad conversion support.', - }, -]; - const studyTourMailHref = `mailto:${studyTourEmail}?subject=${encodeURIComponent('China Study Tour Program')}&body=${encodeURIComponent( 'Group size:\nAge/professional profile:\nPreferred dates:\nTarget cities:\nLearning theme:\nBudget level:\nPrimary visit interests:\n', )}`; @@ -570,7 +546,7 @@ export default function ChinaStudyToursPage() { ['#product-system', 'Product system'], ['#content-system', 'Content system'], ['#pricing', 'Pricing'], - ['#brochures', 'Brochures'], + ['#route-briefs', 'Route briefs'], ['#trust', 'Trust'], ['#program-tracks', 'Program tracks'], ['#visit-options', 'Visit options'], @@ -726,56 +702,40 @@ export default function ChinaStudyToursPage() { -
+
-

Download Brochures

+

Route Briefs

- Three PDF brochures for fast partner review + Three starting points for custom route design

- Share these with school leaders, parents, agency partners, MBA coordinators, or professional delegation sponsors. Contact PandaOffer at {studyTourEmail}. + Use these public briefs to start a proposal conversation. They are PandaOffer-owned route concepts, not uploaded PPT or PDF material. Contact PandaOffer at {studyTourEmail}.

- {brochureDownloads.map((brochure) => ( - +

{brief.audience}

+

{brief.description}

+ + Open route brief + + + ))}
diff --git a/src/app/layout.js b/src/app/layout.js index 42f1593..ee91afd 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -2,6 +2,7 @@ import '@fontsource-variable/inter'; import '@fontsource-variable/outfit'; import { GoogleAnalytics } from '@next/third-parties/google'; import { Toaster } from 'sonner'; +import RouteVoteWidget from '@/components/engagement/RouteVoteWidget'; import AuthProvider from '@/components/providers/AuthProvider'; import { ModeProvider } from '@/components/providers/ModeProvider'; import "./globals.css"; @@ -71,6 +72,7 @@ export default function RootLayout({ children }) { {/* 这里是你网站的所有页面内容 */} {children} + @@ -83,4 +85,4 @@ export default function RootLayout({ children }) { ); -} \ No newline at end of file +} diff --git a/src/app/sitemap.js b/src/app/sitemap.js index 101e1f1..e74d720 100644 --- a/src/app/sitemap.js +++ b/src/app/sitemap.js @@ -149,7 +149,7 @@ export default function sitemap() { )); const cityRoutes = uniqueCities.map(city => ({ - url: `${baseUrl}/study-in-${city}`, + url: `${baseUrl}/study-in/${city}`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8, diff --git a/src/app/study-in-china/page.js b/src/app/study-in-china/page.js index 5fb2e26..96cc256 100644 --- a/src/app/study-in-china/page.js +++ b/src/app/study-in-china/page.js @@ -130,7 +130,7 @@ export default function StudyInChinaPillarPage() {

Choose your city and discover the top universities there.

{cities.map(city => ( - +

{city.name}

{city.count} unis diff --git a/src/app/study-in-[city]/page.js b/src/app/study-in/[city]/page.js similarity index 98% rename from src/app/study-in-[city]/page.js rename to src/app/study-in/[city]/page.js index b11add8..eb2665b 100644 --- a/src/app/study-in-[city]/page.js +++ b/src/app/study-in/[city]/page.js @@ -44,11 +44,11 @@ export async function generateMetadata({ params }) { return { title, description, - alternates: { canonical: `https://www.pandaoffer.top/study-in-${citySlug}` }, + alternates: { canonical: `https://www.pandaoffer.top/study-in/${citySlug}` }, openGraph: { title, description, - url: `https://www.pandaoffer.top/study-in-${citySlug}`, + url: `https://www.pandaoffer.top/study-in/${citySlug}`, type: 'article', images: [{ url: '/og-image.jpg', width: 1200, height: 630, alt: title }], }, @@ -86,7 +86,7 @@ export default async function CityLandingPage({ params }) { "@type": "CollectionPage", "name": `Study in ${cityData.name}`, "description": `Comprehensive guide to universities and student life in ${cityData.name}, China.`, - "url": `https://www.pandaoffer.top/study-in-${citySlug}`, + "url": `https://www.pandaoffer.top/study-in/${citySlug}`, "mainEntity": { "@type": "ItemList", "itemListElement": sortedUnis.map((uni, index) => ({ @@ -103,7 +103,7 @@ export default async function CityLandingPage({ params }) { "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://www.pandaoffer.top" }, { "@type": "ListItem", "position": 2, "name": "Universities", "item": "https://www.pandaoffer.top/universities" }, - { "@type": "ListItem", "position": 3, "name": `Study in ${cityData.name}`, "item": `https://www.pandaoffer.top/study-in-${citySlug}` }, + { "@type": "ListItem", "position": 3, "name": `Study in ${cityData.name}`, "item": `https://www.pandaoffer.top/study-in/${citySlug}` }, ] } ]) diff --git a/src/components/engagement/RouteVoteWidget.jsx b/src/components/engagement/RouteVoteWidget.jsx new file mode 100644 index 0000000..04eb4a2 --- /dev/null +++ b/src/components/engagement/RouteVoteWidget.jsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useMemo, useState } from 'react'; +import { ThumbsUp } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { buildRouteVoteStats, normalizeRoutePath } from '@/lib/routeVoting'; + +export default function RouteVoteWidget() { + const pathname = usePathname() || '/'; + const routePath = useMemo(() => normalizeRoutePath(pathname), [pathname]); + const [stats, setStats] = useState(() => buildRouteVoteStats(routePath)); + const [isVoting, setIsVoting] = useState(false); + + useEffect(() => { + let isActive = true; + + setStats(buildRouteVoteStats(routePath)); + + async function loadVotes() { + try { + const response = await fetch(`/api/route-votes?path=${encodeURIComponent(routePath)}`, { + cache: 'no-store', + }); + + if (!response.ok) return; + const nextStats = await response.json(); + if (isActive) setStats(nextStats); + } catch { + // Keep the seeded count if analytics is unavailable. + } + } + + loadVotes(); + + return () => { + isActive = false; + }; + }, [routePath]); + + async function handleVote() { + if (isVoting) return; + + setIsVoting(true); + + try { + const response = await fetch('/api/route-votes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path: routePath }), + }); + + if (!response.ok) return; + setStats(await response.json()); + } catch { + // Voting should never block page usage. + } finally { + setIsVoting(false); + } + } + + return ( + + ); +} diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx index 4b19930..f662611 100644 --- a/src/components/layout/Footer.jsx +++ b/src/components/layout/Footer.jsx @@ -42,10 +42,10 @@ export default function Footer() {

Study By City

  • Study in China Guide
  • -
  • Study in Shanghai
  • -
  • Study in Beijing
  • -
  • Study in Hangzhou
  • -
  • Study in Wuhan
  • +
  • Study in Shanghai
  • +
  • Study in Beijing
  • +
  • Study in Hangzhou
  • +
  • Study in Wuhan
diff --git a/src/lib/routeVoteIdentity.js b/src/lib/routeVoteIdentity.js new file mode 100644 index 0000000..f6e8c39 --- /dev/null +++ b/src/lib/routeVoteIdentity.js @@ -0,0 +1,23 @@ +import crypto from 'node:crypto'; + +export const ROUTE_VOTER_COOKIE = 'po_route_voter'; + +export const routeVoterCookieOptions = { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 60 * 60 * 24 * 365, +}; + +export function isValidVoterId(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value || ''); +} + +export function createVoterId() { + return crypto.randomUUID(); +} + +export function buildVoterKey(voterId) { + return crypto.createHash('sha256').update(String(voterId)).digest('hex'); +} diff --git a/src/lib/routeVoteStorage.js b/src/lib/routeVoteStorage.js new file mode 100644 index 0000000..a2c1d45 --- /dev/null +++ b/src/lib/routeVoteStorage.js @@ -0,0 +1,69 @@ +import { normalizeRoutePath } from '@/lib/routeVoting'; + +export async function countRouteVotes(supabase, routePath) { + if (!supabase) return 0; + + const { count, error } = await supabase + .from('route_votes') + .select('id', { count: 'exact', head: true }) + .eq('route_path', normalizeRoutePath(routePath)); + + if (error) return 0; + return Number.isFinite(Number(count)) ? Number(count) : 0; +} + +export async function hasRouteVote(supabase, routePath, voterKey) { + if (!supabase || !voterKey) return false; + + const { data, error } = await supabase + .from('route_votes') + .select('id') + .eq('route_path', normalizeRoutePath(routePath)) + .eq('voter_key', voterKey) + .maybeSingle(); + + if (error) return false; + return Boolean(data?.id); +} + +export async function addRouteVote(supabase, { routePath, voterKey, userId = null, userAgent = null }) { + if (!supabase || !voterKey) return false; + + const { error } = await supabase.from('route_votes').insert({ + route_path: normalizeRoutePath(routePath), + voter_key: voterKey, + user_id: userId, + user_agent: userAgent, + }); + + return !error; +} + +export async function removeRouteVote(supabase, { routePath, voterKey }) { + if (!supabase || !voterKey) return false; + + const { error } = await supabase + .from('route_votes') + .delete() + .eq('route_path', normalizeRoutePath(routePath)) + .eq('voter_key', voterKey); + + return !error; +} + +export async function toggleRouteVote(supabase, { routePath, voterKey, userId = null, userAgent = null }) { + const routePathKey = normalizeRoutePath(routePath); + const alreadyVoted = await hasRouteVote(supabase, routePathKey, voterKey); + + if (alreadyVoted) { + const removed = await removeRouteVote(supabase, { routePath: routePathKey, voterKey }); + return removed + ? { action: 'removed', userHasVoted: false } + : { action: 'unavailable', userHasVoted: true }; + } + + const added = await addRouteVote(supabase, { routePath: routePathKey, voterKey, userId, userAgent }); + return added + ? { action: 'added', userHasVoted: true } + : { action: 'unavailable', userHasVoted: false }; +} diff --git a/src/lib/routeVoting.js b/src/lib/routeVoting.js new file mode 100644 index 0000000..48fd896 --- /dev/null +++ b/src/lib/routeVoting.js @@ -0,0 +1,46 @@ +const BASE_MIN = 86; +const BASE_MAX = 486; + +export function normalizeRoutePath(value) { + if (!value || typeof value !== 'string') return '/'; + + let path = value.trim(); + + try { + path = new URL(path, 'https://www.pandaoffer.top').pathname; + } catch { + path = path.split('#')[0].split('?')[0]; + } + + if (!path.startsWith('/')) path = `/${path}`; + path = path.replace(/\/{2,}/g, '/'); + path = path.length > 1 ? path.replace(/\/+$/, '') : path; + + return path || '/'; +} + +export function getRouteVoteBaseCount(path) { + const normalizedPath = normalizeRoutePath(path); + const range = BASE_MAX - BASE_MIN + 1; + let hash = 0; + + for (let index = 0; index < normalizedPath.length; index += 1) { + hash = (hash * 31 + normalizedPath.charCodeAt(index)) >>> 0; + } + + return BASE_MIN + (hash % range); +} + +export function buildRouteVoteStats(path, realCount = 0, userHasVoted = false) { + const routePath = normalizeRoutePath(path); + const safeRealCount = Number.isFinite(Number(realCount)) ? Math.max(0, Number(realCount)) : 0; + const baseCount = getRouteVoteBaseCount(routePath); + + return { + routePath, + baseCount, + realCount: safeRealCount, + displayCount: baseCount + safeRealCount, + userHasVoted: Boolean(userHasVoted), + }; +} diff --git a/supabase/migrations/20260530000000_add_route_votes.sql b/supabase/migrations/20260530000000_add_route_votes.sql new file mode 100644 index 0000000..9029bed --- /dev/null +++ b/supabase/migrations/20260530000000_add_route_votes.sql @@ -0,0 +1,27 @@ +-- Route-level voting / popularity tracking. +-- The public UI shows a seeded baseline count; this table stores only real visitor interactions. + +CREATE TABLE IF NOT EXISTS public.route_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + route_path TEXT NOT NULL CHECK (route_path ~ '^/'), + voter_key TEXT NOT NULL CHECK (voter_key ~ '^[a-f0-9]{64}$'), + user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + UNIQUE(route_path, voter_key) +); + +CREATE INDEX IF NOT EXISTS route_votes_route_path_idx ON public.route_votes(route_path); +CREATE INDEX IF NOT EXISTS route_votes_created_at_idx ON public.route_votes(created_at DESC); + +ALTER TABLE public.route_votes ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Admins can view route votes" ON public.route_votes; +CREATE POLICY "Admins can view route votes" ON public.route_votes + FOR SELECT + USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin')); + +DROP POLICY IF EXISTS "Admins can delete route votes" ON public.route_votes; +CREATE POLICY "Admins can delete route votes" ON public.route_votes + FOR DELETE + USING (EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'admin')); From 9a39b667c3e3fbaad55a064b78c89cf4880474ae Mon Sep 17 00:00:00 2001 From: silverlion2 Date: Sun, 31 May 2026 01:52:57 +0800 Subject: [PATCH 2/4] Enhance study tour product experience --- src/__tests__/RouteVoteWidget.test.jsx | 13 + src/__tests__/china-study-tours.test.jsx | 145 ++++- src/app/[slug]/page.js | 36 + src/app/account/history/page.jsx | 67 +- src/app/admin/layout.jsx | 4 +- src/app/blog/[slug]/page.js | 21 +- src/app/blog/page.js | 4 +- src/app/career/page.js | 2 +- src/app/china-study-tours/page.js | 614 ++++++++++++++---- src/app/community/page.js | 2 +- src/app/expat-tools/page.js | 2 +- src/app/faq/page.js | 3 +- src/app/layout.js | 111 +++- src/app/page.js | 40 +- src/app/privacy/page.jsx | 2 +- src/app/robots.js | 2 +- src/app/scholarships/page.js | 2 +- src/app/sitemap.js | 23 +- src/app/study-[major]-in-china/page.js | 10 +- src/app/study-in-china/page.js | 4 +- src/app/study-in/[city]/page.js | 2 +- src/app/terms/page.jsx | 2 +- src/app/tools/advisor/page.js | 3 +- src/app/tools/budget/page.js | 3 +- src/app/tools/city/page.js | 3 +- src/app/tools/documents/page.js | 3 +- src/app/tools/page.js | 2 +- src/app/tools/roi/page.js | 3 +- src/app/tools/simulator/page.js | 2 +- src/app/tools/timeline/page.js | 3 +- src/app/universities/[slug]/page.js | 2 +- src/app/universities/page.js | 2 +- src/components/engagement/RouteVoteWidget.jsx | 16 +- src/components/home/FAQSection.jsx | 2 +- src/components/home/HeroHeader.jsx | 6 +- src/components/home/HomeClientManager.jsx | 3 +- src/components/home/StudyTourLead.jsx | 4 +- src/components/home/UnlockResults.jsx | 4 +- src/components/layout/Footer.jsx | 25 +- src/components/layout/Navbar.jsx | 2 +- .../study-tours/StudyTourQuoteBuilder.jsx | 216 ++++++ .../study-tours/StudyTourSeoPage.jsx | 125 +++- .../documents/RecommendationTemplate.jsx | 2 +- src/data/studyTours.js | 332 +++++++++- 44 files changed, 1572 insertions(+), 302 deletions(-) create mode 100644 src/app/[slug]/page.js create mode 100644 src/components/study-tours/StudyTourQuoteBuilder.jsx diff --git a/src/__tests__/RouteVoteWidget.test.jsx b/src/__tests__/RouteVoteWidget.test.jsx index 7fa2b7b..61fd029 100644 --- a/src/__tests__/RouteVoteWidget.test.jsx +++ b/src/__tests__/RouteVoteWidget.test.jsx @@ -30,6 +30,19 @@ describe('RouteVoteWidget', () => { expect(screen.getByText(String(getRouteVoteBaseCount('/tools/roi')))).toBeInTheDocument(); }); + it('uses a compact mobile layout so it does not cover page content', () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + json: async () => ({}), + }); + + render(); + + expect(screen.getByLabelText('Page popularity')).toHaveClass('right-4', 'sm:left-4'); + expect(screen.getByRole('button', { name: /vote for this page/i })).toHaveClass('h-12', 'w-12', 'sm:w-auto'); + expect(screen.getByText('Popular')).toHaveClass('hidden', 'sm:block'); + }); + it('loads real vote totals and posts a route vote', async () => { const baseCount = getRouteVoteBaseCount('/tools/roi'); const fetchMock = vi.spyOn(global, 'fetch'); diff --git a/src/__tests__/china-study-tours.test.jsx b/src/__tests__/china-study-tours.test.jsx index 635bad7..1e9c13c 100644 --- a/src/__tests__/china-study-tours.test.jsx +++ b/src/__tests__/china-study-tours.test.jsx @@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render } from '@testing-library/react'; import ChinaStudyToursPage from '@/app/china-study-tours/page'; import StudyTourSeoPage from '@/components/study-tours/StudyTourSeoPage'; -import { seoTourPages } from '@/data/studyTours'; +import { productLineup, seoTourPages } from '@/data/studyTours'; vi.mock('next/link', () => ({ default: ({ children, href, ...props }) => @@ -49,6 +49,13 @@ const validInternalRoutes = new Set([ ]); const validDynamicRoutes = [/^\/study-in\/[a-z0-9-]+$/]; +const validExternalRoutes = [ + /^https:\/\/www\.linkedin\.com\/sharing\/share-offsite\/\?/, + /^https:\/\/twitter\.com\/intent\/tweet\?/, + /^https:\/\/wa\.me\/\?/, + /^https:\/\/www\.facebook\.com\/sharer\/sharer\.php\?/, + /^https:\/\/t\.me\/share\/url\?/, +]; function expectResolvableLinks(container) { const anchors = [...container.querySelectorAll('a[href]')]; @@ -80,6 +87,12 @@ function expectResolvableLinks(container) { return; } + if (href.startsWith('http')) { + const isSupportedShareLink = validExternalRoutes.some((pattern) => pattern.test(href)); + expect(isSupportedShareLink, `${href} should be a supported external share link`).toBe(true); + return; + } + expect(href.startsWith('mailto:'), `${href} should be a supported external link`).toBe(true); }); } @@ -150,16 +163,24 @@ describe('China study tours page', () => { expectResolvableLinks(container); }); - it('uses internal route briefs instead of downloadable brochure links', () => { + it('renders downloadable original PDF brochures and share links', () => { const { container } = render(); const linkedText = getLinkedText(container); - ['AI/Tech Route Brief', 'Healthcare Route Brief', 'School Study Tour Route Brief'].forEach((title) => { + [ + 'AI/Tech Study Tour Brochure', + 'Healthcare Study Tour Brochure', + 'School Study Tour Brochure', + ].forEach((title) => { const matchingLinks = linkedText.filter((text) => new RegExp(escapeRegex(title), 'i').test(text)); expect(matchingLinks.length, `${title} should be rendered inside a link`).toBeGreaterThan(0); }); - expect(container.querySelectorAll('a[download][href^="/brochures/"]').length).toBe(0); + expect(container.querySelectorAll('a[download][href^="/brochures/"]').length).toBe(3); + expect(container).toHaveTextContent('Share this study tour hub'); + ['LinkedIn', 'X', 'WhatsApp', 'Facebook', 'Telegram', 'Email'].forEach((label) => { + expect(container).toHaveTextContent(label); + }); }); it('does not render unresolved SEO landing-page links', () => { @@ -169,4 +190,120 @@ describe('China study tours page', () => { unmount(); }); }); + + it('renders route marketplace buying signals on the study tour catalog', () => { + const { container } = render(); + + [ + 'Route Marketplace', + 'Compare by fit, access, outcome, and operating risk', + 'Buyer fit', + 'Access level', + 'Learning output', + 'Primary access', + 'Backup plan', + 'Compare route', + ].forEach((text) => { + expect(container).toHaveTextContent(text); + }); + }); + + it('renders route marketplace products with visual thumbnails', () => { + const { container } = render(); + const productSection = container.querySelector('#product-system'); + const images = productSection.querySelectorAll('img'); + + expect(images.length).toBeGreaterThanOrEqual(productLineup.length); + productLineup.forEach((product) => { + expect(product.image).toMatch(/^\/.+\.(jpg|jpeg|png|webp|svg)$/i); + expect(product.imageAlt).toContain('study tour'); + }); + }); + + it('orders the study-tour catalog like a travel product page', () => { + const { container } = render(); + const sectionIds = [...container.querySelectorAll('main section[id]')].map((section) => section.id); + + expect(sectionIds.indexOf('product-system')).toBeLessThan(sectionIds.indexOf('route-flow')); + expect(sectionIds.indexOf('route-flow')).toBeLessThan(sectionIds.indexOf('availability-fit')); + expect(sectionIds.indexOf('availability-fit')).toBeLessThan(sectionIds.indexOf('pricing')); + expect(sectionIds.indexOf('product-system')).toBeLessThan(sectionIds.indexOf('pricing')); + expect(sectionIds.indexOf('pricing')).toBeLessThan(sectionIds.indexOf('trust')); + expect(sectionIds.indexOf('trust')).toBeLessThan(sectionIds.indexOf('content-system')); + expect(sectionIds.indexOf('content-system')).toBeLessThan(sectionIds.indexOf('brochures')); + expect(sectionIds.indexOf('brochures')).toBeLessThan(sectionIds.indexOf('request-route')); + }); + + it('puts included and excluded scope next to pricing on the study-tour catalog', () => { + const { container } = render(); + const pricingSection = container.querySelector('#pricing'); + + ['What is included', 'Not automatically included'].forEach((text) => { + expect(pricingSection).toHaveTextContent(text); + }); + }); + + it('renders a route flow and booking path before pricing', () => { + const { container } = render(); + const routeFlowSection = container.querySelector('#route-flow'); + + [ + 'Sample Route Flow', + 'Before arrival', + 'Arrival and orientation', + 'Campus or industry access days', + 'Final debrief and next step', + 'Booking Path', + 'Check availability', + 'Host approval and backup route', + ].forEach((text) => { + expect(routeFlowSection).toHaveTextContent(text); + }); + }); + + it('renders route flow as a visual itinerary board', () => { + const { container } = render(); + const routeFlowSection = container.querySelector('#route-flow'); + const images = routeFlowSection.querySelectorAll('img'); + + expect(routeFlowSection).toHaveTextContent('Visual itinerary board'); + expect(images.length).toBeGreaterThanOrEqual(3); + }); + + it('renders an availability and fit board before pricing', () => { + const { container } = render(); + const availabilitySection = container.querySelector('#availability-fit'); + + [ + 'Dates and fit', + 'Availability windows', + 'Open for private quote', + 'Best fit', + 'Group readiness', + 'Host approval risk', + 'Request these dates', + ].forEach((text) => { + expect(availabilitySection).toHaveTextContent(text); + }); + + expect(availabilitySection.querySelectorAll('[data-testid="availability-window"]').length).toBeGreaterThanOrEqual(3); + }); + + it('renders a quote builder with concrete group fields on SEO route pages', () => { + const { container } = render(); + + [ + 'Build a quote brief', + 'Group size', + 'Audience', + 'Preferred dates', + 'Route priority', + 'Budget level', + 'Send quote brief', + 'Included in the proposal', + 'Not automatically included', + ].forEach((text) => { + expect(container).toHaveTextContent(text); + }); + }); }); diff --git a/src/app/[slug]/page.js b/src/app/[slug]/page.js new file mode 100644 index 0000000..9ee25f2 --- /dev/null +++ b/src/app/[slug]/page.js @@ -0,0 +1,36 @@ +import { notFound } from 'next/navigation'; +import MajorLandingPage, { + generateMetadata as generateMajorMetadata, + generateStaticParams as generateMajorStaticParams, +} from '../study-[major]-in-china/page'; + +export const dynamicParams = false; + +const majorRoutePattern = /^study-(.+)-in-china$/; + +async function getMajorParams(params) { + const { slug } = await params; + const match = majorRoutePattern.exec(slug); + + if (!match) { + notFound(); + } + + return { major: match[1] }; +} + +export function generateStaticParams() { + return generateMajorStaticParams().map(({ major }) => ({ + slug: `study-${major}-in-china`, + })); +} + +export async function generateMetadata({ params }) { + const majorParams = await getMajorParams(params); + return generateMajorMetadata({ params: Promise.resolve(majorParams) }); +} + +export default async function StudyMajorRoutePage({ params }) { + const majorParams = await getMajorParams(params); + return MajorLandingPage({ params: Promise.resolve(majorParams) }); +} diff --git a/src/app/account/history/page.jsx b/src/app/account/history/page.jsx index b1ec8ba..da4edf1 100644 --- a/src/app/account/history/page.jsx +++ b/src/app/account/history/page.jsx @@ -1,22 +1,30 @@ "use client"; -import { useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; import { useAuth } from '@/components/providers/AuthProvider'; import { createClient } from '@/lib/supabase/client'; import { toast } from 'sonner'; -import { History, Trash2, Loader2, Brain, GraduationCap, BookOpen, Sparkles } from 'lucide-react'; -import Link from 'next/link'; +import { Trash2, Loader2, Brain, GraduationCap, BookOpen, Sparkles } from 'lucide-react'; export default function HistoryPage() { const { user } = useAuth(); + const supabase = useMemo(() => createClient(), []); const [matchHistory, setMatchHistory] = useState([]); const [savedChats, setSavedChats] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('matches'); - const supabase = createClient(); useEffect(() => { - const fetchHistory = async () => { + let isActive = true; + + async function fetchHistory() { + if (!user?.id) { + setLoading(false); + return; + } + + setLoading(true); const [matchRes, chatRes] = await Promise.all([ supabase .from('match_history') @@ -32,18 +40,28 @@ export default function HistoryPage() { .limit(50), ]); + if (!isActive) return; + + if (matchRes.error || chatRes.error) { + toast.error('Failed to load history.'); + } + setMatchHistory(matchRes.data || []); setSavedChats(chatRes.data || []); setLoading(false); - }; + } + + fetchHistory(); - if (user) fetchHistory(); - }, [user, supabase]); + return () => { + isActive = false; + }; + }, [supabase, user?.id]); const deleteMatch = async (id) => { const { error } = await supabase.from('match_history').delete().eq('id', id); if (!error) { - setMatchHistory(matchHistory.filter(m => m.id !== id)); + setMatchHistory((items) => items.filter((match) => match.id !== id)); toast.success('Match result removed.'); } }; @@ -51,7 +69,7 @@ export default function HistoryPage() { const deleteChat = async (id) => { const { error } = await supabase.from('saved_chats').delete().eq('id', id); if (!error) { - setSavedChats(savedChats.filter(c => c.id !== id)); + setSavedChats((items) => items.filter((chat) => chat.id !== id)); toast.success('Saved Q&A removed.'); } }; @@ -76,7 +94,6 @@ export default function HistoryPage() {

Your saved AI Matcher results and AI Advisor Q&A pairs.

- {/* Tabs */}
{tabs.map((tab) => ( ))}
- {/* Match History Tab */} {activeTab === 'matches' && ( <> {matchHistory.length === 0 ? ( @@ -118,17 +135,22 @@ export default function HistoryPage() { const results = match.results || {}; const unis = results.universities || []; const formData = match.form_data || {}; + return (

- {new Date(match.created_at).toLocaleDateString('en-US', { - year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' + {new Date(match.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', })}

- {formData.nationality} · {formData.target_major} · GPA: {formData.gpa?.value} + {formData.nationality} - {formData.target_major} - GPA: {formData.gpa?.value}

- {unis.map((uni, i) => ( - + {unis.map((uni) => ( + {uni.name} ({uni.matchPercent}%) ))} @@ -155,7 +177,6 @@ export default function HistoryPage() { )} - {/* Saved Chats Tab */} {activeTab === 'chats' && ( <> {savedChats.length === 0 ? ( @@ -165,7 +186,7 @@ export default function HistoryPage() {

No saved Q&A yet

- Chat with the AI Advisor and click the 💾 save button on any answer to keep it here. + Chat with the AI Advisor and click the save button on any answer to keep it here.

) : ( @@ -188,9 +209,9 @@ export default function HistoryPage() { Sources:

- {chat.sources.map((s, j) => ( - - {s} + {chat.sources.map((source) => ( + + {source} ))}
diff --git a/src/app/admin/layout.jsx b/src/app/admin/layout.jsx index 7637922..147183d 100644 --- a/src/app/admin/layout.jsx +++ b/src/app/admin/layout.jsx @@ -6,7 +6,7 @@ import { Loader2, ShieldAlert } from 'lucide-react'; import Link from 'next/link'; export default function AdminLayout({ children }) { - const { user, profile, loading } = useAuth(); + const { profile, loading } = useAuth(); if (loading) { return ( @@ -31,7 +31,7 @@ export default function AdminLayout({ children }) {

Access Denied

You don't have admin privileges.

- ← Go back home + Go back home diff --git a/src/app/blog/[slug]/page.js b/src/app/blog/[slug]/page.js index 435adf3..63fd432 100644 --- a/src/app/blog/[slug]/page.js +++ b/src/app/blog/[slug]/page.js @@ -6,6 +6,18 @@ import { getPostData, getAllPostSlugs, getSortedPostsData } from '@/lib/markdown import Navbar from '@/components/layout/Navbar'; import Footer from '@/components/layout/Footer'; +function compactTitle(title, maxLength = 44) { + const withoutYear = title.replace(/\s*\(2026\)\s*$/i, '').trim(); + const beforeColon = withoutYear.split(':')[0].trim(); + const candidate = beforeColon.length >= 24 ? beforeColon : withoutYear; + + if (candidate.length <= maxLength) { + return candidate; + } + + return `${candidate.slice(0, maxLength - 3).replace(/\s+\S*$/, '')}...`; +} + export async function generateStaticParams() { const slugs = getAllPostSlugs(); return slugs.map((post) => ({ @@ -20,9 +32,10 @@ export async function generateMetadata({ params }) { if (!post) return { title: 'Post Not Found | PandaOffer' }; - const title = post.title; + const title = compactTitle(post.title); + const shareTitle = post.title; const image = post.image || '/og-image.jpg'; - const imageAlt = post.imageAlt || title; + const imageAlt = post.imageAlt || shareTitle; const description = post.description || `Read ${post.title} — expert advice for international students studying in China.`; return { @@ -32,7 +45,7 @@ export async function generateMetadata({ params }) { canonical: `https://www.pandaoffer.top/blog/${slug}`, }, openGraph: { - title, + title: shareTitle, description, url: `https://www.pandaoffer.top/blog/${slug}`, type: 'article', @@ -49,7 +62,7 @@ export async function generateMetadata({ params }) { }, twitter: { card: 'summary_large_image', - title, + title: shareTitle, description, images: [image], }, diff --git a/src/app/blog/page.js b/src/app/blog/page.js index eb8bcbb..bbbff7b 100644 --- a/src/app/blog/page.js +++ b/src/app/blog/page.js @@ -5,7 +5,7 @@ import Footer from '@/components/layout/Footer'; import BlogGrid from '@/components/home/BlogGrid'; export const metadata = { - title: 'Blog & Guides — Study in China Tips & Scholarships', + title: 'Study in China Blog & Guides', description: 'Expert guides on CSC Scholarships, MBBS in China, student visas, cost of living, and survival tips for international students planning to study in China.', openGraph: { title: 'Blog & Guides — Study in China Tips & Scholarships', @@ -34,4 +34,4 @@ export default function BlogHome() {
); -} \ No newline at end of file +} diff --git a/src/app/career/page.js b/src/app/career/page.js index fb80b03..e938cee 100644 --- a/src/app/career/page.js +++ b/src/app/career/page.js @@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'; import CareerBoardClient from './CareerBoardClient'; export const metadata = { - title: 'Expat Jobs & Internships in China | PandaOffer', + title: 'Expat Jobs & Internships in China', description: 'Find Z-Visa sponsored jobs and legal internships for international students and expats in China. Roles at ByteDance, Shein, DJI, Huawei, and more.', openGraph: { title: 'Expat Jobs & Internships in China | PandaOffer', diff --git a/src/app/china-study-tours/page.js b/src/app/china-study-tours/page.js index fc6d6fc..e10ecc9 100644 --- a/src/app/china-study-tours/page.js +++ b/src/app/china-study-tours/page.js @@ -12,6 +12,7 @@ import { CircuitBoard, ClipboardCheck, Cpu, + Download, Factory, FileText, FlaskConical, @@ -24,17 +25,27 @@ import { MapPinned, Route, ShieldCheck, + Share2, Sparkles, Stethoscope, Users, } from 'lucide-react'; import { + availabilityWindows, + fitBoardSignals, contentPillars, conversionPathway, pricingAnchors, + proposalExclusions, + proposalIncludes, productLineup, - routeBriefs, + bookingPath, + sampleRouteFlow, + routeFlowVisuals, + studyTourBrochures, studyTourEmail, + studyTourShareLinks, + tripFacts, trustItems, visitOptionSets, } from '@/data/studyTours'; @@ -44,9 +55,9 @@ const studyTourMailHref = `mailto:${studyTourEmail}?subject=${encodeURIComponent )}`; export const metadata = { - title: 'China Study Tour Programs: Campus, Healthcare, AI & Tech', + title: 'China Study Tours: AI, Healthcare & School', description: - 'Custom China study tour programs for schools, agencies, families, and professional groups: university visits, Mandarin learning, healthcare, AI, tech companies, business, and safety support.', + 'Plan China study tours for schools, MBA groups, and families with AI/tech, healthcare, campus routes, PDF brochures, host approval, and admissions follow-up.', keywords: [ 'China study tour', 'China healthcare study tour', @@ -56,12 +67,29 @@ export const metadata = { 'MBA China study tour', 'EMBA China study tour', ], - alternates: { canonical: 'https://www.pandaoffer.top/china-study-tours' }, + alternates: { + canonical: 'https://www.pandaoffer.top/china-study-tours', + languages: { + 'x-default': 'https://www.pandaoffer.top/china-study-tours', + }, + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, openGraph: { - title: 'China Study Tour Programs: Campus, Healthcare, AI & Tech | PandaOffer', + title: 'China Study Tours: AI, Healthcare & School | PandaOffer', description: - 'Build a safe, practical China study tour with campus visits, healthcare industry visits, AI and tech company routes, academic workshops, and local logistics handled by PandaOffer.', + 'Build a China study tour with AI and tech routes, healthcare visits, campus experiences, PDF brochures, pricing anchors, trust details, and admissions follow-up.', url: 'https://www.pandaoffer.top/china-study-tours', + siteName: 'PandaOffer', + locale: 'en_US', type: 'website', images: [ { @@ -74,10 +102,12 @@ export const metadata = { }, twitter: { card: 'summary_large_image', - title: 'China Study Tour Programs: Campus, Healthcare, AI & Tech', + title: 'China Study Tours: AI, Healthcare & School', description: - 'Custom China study tours with campus visits, healthcare industry visits, AI companies, tech companies, and local logistics.', + 'Custom China study tours with AI/tech, healthcare, school routes, PDF brochures, pricing anchors, host approval, and admissions follow-up.', images: ['/images/study-tours/ai-tech-company-study-tour.jpg'], + site: '@pandaoffer', + creator: '@pandaoffer', }, }; @@ -453,17 +483,51 @@ export default function ChinaStudyToursPage() { areaServed: 'China', serviceType: 'Study tour planning and education travel support', description: - 'Custom China study tour programs for schools, agencies, families, student groups, and professional delegations, including campus, healthcare, AI, and tech company routes.', + 'Custom China study tour programs for schools, agencies, families, student groups, and professional delegations, including campus, healthcare, AI, tech company routes, downloadable brochures, host approval safeguards, and admissions follow-up.', hasOfferCatalog: { '@type': 'OfferCatalog', name: 'China Study Tour Route Catalog', itemListElement: [ - { '@type': 'Offer', itemOffered: { '@type': 'Service', name: 'University Discovery Study Tour' } }, - { '@type': 'Offer', itemOffered: { '@type': 'Service', name: 'Healthcare Industry Study Tour' } }, - { '@type': 'Offer', itemOffered: { '@type': 'Service', name: 'AI and Tech Company Study Tour' } }, + { + '@type': 'Offer', + price: '1899', + priceCurrency: 'USD', + description: 'From $1,899/student. Final quote depends on group size and route design.', + itemOffered: { '@type': 'Service', name: 'School and University Discovery Study Tour' }, + }, + { + '@type': 'Offer', + description: 'Quoted by group size with primary and backup visit options.', + itemOffered: { '@type': 'Service', name: 'Healthcare Industry Study Tour' }, + }, + { + '@type': 'Offer', + description: 'Quoted by group size with company visits subject to host approval.', + itemOffered: { '@type': 'Service', name: 'AI and Tech Company Study Tour' }, + }, ], }, }, + { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'China Study Tour Programs', + url: 'https://www.pandaoffer.top/china-study-tours', + description: + 'China study tour route marketplace with AI/tech, healthcare, school study tour brochures, trust details, pricing anchors, and admissions pathway support.', + publisher: { + '@type': 'Organization', + name: 'PandaOffer', + url: 'https://www.pandaoffer.top', + }, + hasPart: studyTourBrochures.map((brochure) => ({ + '@type': 'DigitalDocument', + name: brochure.title, + url: `https://www.pandaoffer.top${brochure.href}`, + encodingFormat: 'application/pdf', + description: brochure.description, + })), + }, { '@context': 'https://schema.org', '@type': 'BreadcrumbList', @@ -526,15 +590,10 @@ export default function ChinaStudyToursPage() {
- {[ - ['6', 'product lines'], - ['4', 'SEO landing pages'], - ['3', 'PDF brochures'], - ['2-layer', 'visit options'], - ].map(([value, label]) => ( + {tripFacts.map(([label, value]) => (
-
{value}
-
{label}
+
{label}
+
{value}
))}
@@ -543,11 +602,13 @@ export default function ChinaStudyToursPage() {
@@ -700,44 +964,37 @@ export default function ChinaStudyToursPage() { ))} -
-
-
-
-
-

Route Briefs

-

- Three starting points for custom route design -

+
+
+
+ +

What is included

-

- Use these public briefs to start a proposal conversation. They are PandaOffer-owned route concepts, not uploaded PPT or PDF material. Contact PandaOffer at {studyTourEmail}. -

-
+
    + {proposalIncludes.map((item) => ( +
  • + + {item} +
  • + ))} +
+ -
- {routeBriefs.map((brief) => ( - -
- -
-

- {brief.title} -

-

{brief.audience}

-

{brief.description}

- - Open route brief - - - - ))} -
+
+
+ +

Not automatically included

+
+
    + {proposalExclusions.map((item) => ( +
  • + + {item} +
  • + ))} +
+
@@ -767,6 +1024,123 @@ export default function ChinaStudyToursPage() {
+
+
+
+
+

Decision Materials

+

+ Give buyers the materials they need before deposit +

+
+

+ The best study tour pages reduce risk before the first call: route options, city logic, trust details, and the post-tour path are all visible. +

+
+ +
+ {contentPillars.map((pillar) => ( + +
+
+ +
+
+

+ {pillar.title} +

+

{pillar.purpose}

+
+
+
    + {pillar.assets.map((asset) => ( +
  • + + {asset} +
  • + ))} +
+ + {pillar.cta} + + + + ))} +
+
+
+ +
+
+
+
+

Download Brochures

+

+ Three original PDFs for partner review +

+
+

+ Share the AI/Tech, Healthcare, or School Study Tour brochure with faculty, parents, agencies, or sponsors before the first planning call. +

+
+ + + +
+
+
+
+ + Share this study tour hub +
+

+ Use these share links for LinkedIn groups, school partner chats, parent communities, and agency outreach. +

+
+
+ {studyTourShareLinks.map((link) => ( + + {link.label} + + ))} +
+
+
+
+
+
diff --git a/src/app/community/page.js b/src/app/community/page.js index 9db04b1..e18f2a9 100644 --- a/src/app/community/page.js +++ b/src/app/community/page.js @@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'; import CommunityBoardClient from './CommunityBoardClient'; export const metadata = { - title: 'Community Hub | PandaOffer', + title: 'Community Hub', description: 'Join verified WeChat groups, attend expat meetups, and connect with international students across China.', openGraph: { title: 'Community Hub | PandaOffer', diff --git a/src/app/expat-tools/page.js b/src/app/expat-tools/page.js index 4d7a56b..ab75428 100644 --- a/src/app/expat-tools/page.js +++ b/src/app/expat-tools/page.js @@ -4,7 +4,7 @@ import Footer from '@/components/layout/Footer'; import { ShieldAlert, Stethoscope, Home, CreditCard, Smartphone, Car, CheckCircle2, ChevronRight, FileText, Heart } from 'lucide-react'; export const metadata = { - title: 'Expat Survival Tools & Guides | PandaOffer', + title: 'Expat Survival Tools & Guides', description: 'Essential tools and guides for international students living in China: VPNs, renting, banking, and medical.', openGraph: { title: 'Expat Survival Tools & Guides | PandaOffer', diff --git a/src/app/faq/page.js b/src/app/faq/page.js index 7648fae..fcccd15 100644 --- a/src/app/faq/page.js +++ b/src/app/faq/page.js @@ -4,7 +4,7 @@ import Footer from '@/components/layout/Footer'; import FAQSection from '@/components/home/FAQSection'; export const metadata = { - title: 'Frequently Asked Questions | PandaOffer', + title: 'Frequently Asked Questions', description: 'Answers to common questions about studying in China, CSC Scholarships, student visas, costs, and the PandaOffer admission process.', openGraph: { title: 'Frequently Asked Questions | PandaOffer', @@ -23,6 +23,7 @@ export default function FAQPage() {
+

PandaOffer Frequently Asked Questions

diff --git a/src/app/layout.js b/src/app/layout.js index ee91afd..5ddc294 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -5,48 +5,102 @@ import { Toaster } from 'sonner'; import RouteVoteWidget from '@/components/engagement/RouteVoteWidget'; import AuthProvider from '@/components/providers/AuthProvider'; import { ModeProvider } from '@/components/providers/ModeProvider'; -import "./globals.css"; +import './globals.css'; + +const siteJsonLd = [ + { + '@context': 'https://schema.org', + '@type': 'Organization', + '@id': 'https://www.pandaoffer.top/#organization', + name: 'PandaOffer', + alternateName: 'Panda Offer', + url: 'https://www.pandaoffer.top/', + logo: 'https://www.pandaoffer.top/og-image.jpg', + description: + 'PandaOffer helps international students study in China with AI university matching, CSC scholarship planning, MBBS guidance, China study tours, and application support.', + contactPoint: { + '@type': 'ContactPoint', + email: 'hello@pandaoffer.top', + contactType: 'customer support', + availableLanguage: ['English', 'Chinese'], + }, + sameAs: ['https://discord.gg/7bU9kb23'], + }, + { + '@context': 'https://schema.org', + '@type': 'WebSite', + '@id': 'https://www.pandaoffer.top/#website', + name: 'PandaOffer', + alternateName: 'Panda Offer', + url: 'https://www.pandaoffer.top/', + publisher: { + '@id': 'https://www.pandaoffer.top/#organization', + }, + }, +]; -// Global SEO metadata (displayed in browser tabs and Google search results) export const metadata = { metadataBase: new URL('https://www.pandaoffer.top'), + applicationName: 'PandaOffer', verification: { google: 'zLREZ7AR-fIkNCQu19gJE9hmMZZEAqMEedboCeo1zyE', }, title: { - default: "PandaOffer (Panda Offer) | AI-Powered Study in China Guide", - template: "%s | PandaOffer" + default: 'PandaOffer (Panda Offer) | AI-Powered Study in China Guide', + template: '%s | PandaOffer', }, - description: "PandaOffer (Panda Offer) — your ultimate guide to studying in China. AI-driven university matching, CSC Scholarship probability calculator, WHO/NMC-recognized MBBS programs, and expert guidance for international students.", - keywords: ["PandaOffer", "Panda Offer", "pandaoffer.top", "Study in China", "Study in China 2026", "CSC Scholarship", "CSC Scholarship 2026", "MBBS in China", "Chinese Universities", "China Admissions", "Study Abroad China", "Chinese Government Scholarship", "Universities in China for international students", "C9 League universities", "985 universities China"], - authors: [{ name: "PandaOffer Team" }], - creator: "PandaOffer", - publisher: "PandaOffer", + description: + 'PandaOffer (Panda Offer) is an AI-powered Study in China guide with university matching, CSC scholarship planning, WHO/NMC-recognized MBBS guidance, China study tours, and expert support for international students.', + keywords: [ + 'PandaOffer', + 'Panda Offer', + 'pandaoffer.top', + 'Study in China', + 'Study in China 2026', + 'CSC Scholarship', + 'CSC Scholarship 2026', + 'MBBS in China', + 'Chinese Universities', + 'China Admissions', + 'Study Abroad China', + 'Chinese Government Scholarship', + 'Universities in China for international students', + 'C9 League universities', + '985 universities China', + 'China study tours', + ], + authors: [{ name: 'PandaOffer Team' }], + creator: 'PandaOffer', + publisher: 'PandaOffer', + category: 'education', alternates: { - canonical: 'https://www.pandaoffer.top', + canonical: 'https://www.pandaoffer.top/', }, openGraph: { - title: "PandaOffer (Panda Offer) | AI-Powered Study in China Guide", - description: "PandaOffer (Panda Offer) — AI-driven university matching, CSC Scholarship calculator, and expert guidance for international students studying in China.", - url: "https://www.pandaoffer.top", - siteName: "PandaOffer", + title: 'PandaOffer (Panda Offer) | AI-Powered Study in China Guide', + description: + 'PandaOffer helps international students study in China with AI matching, CSC scholarship planning, MBBS guidance, China study tours, and expert application support.', + url: 'https://www.pandaoffer.top/', + siteName: 'PandaOffer', images: [ { - url: "/og-image.jpg", + url: '/og-image.jpg', width: 1200, height: 630, - alt: "PandaOffer - Study in China", + alt: 'PandaOffer - Study in China', }, ], - locale: "en_US", - type: "website", + locale: 'en_US', + type: 'website', }, twitter: { - card: "summary_large_image", - title: "PandaOffer (Panda Offer) | AI-Powered Study in China Guide", - description: "PandaOffer (Panda Offer) — AI-driven university matching, CSC Scholarship calculator, and expert guidance for international students studying in China.", - images: ["/og-image.jpg"], - creator: "@pandaoffer", + card: 'summary_large_image', + title: 'PandaOffer (Panda Offer) | AI-Powered Study in China Guide', + description: + 'AI university matching, CSC scholarship planning, MBBS guidance, China study tours, and application support for international students.', + images: ['/og-image.jpg'], + site: '@pandaoffer', + creator: '@pandaoffer', }, robots: { index: true, @@ -66,22 +120,21 @@ export default function RootLayout({ children }) { +