fix: les 6

This commit is contained in:
2026-03-11 14:07:00 +01:00
parent d5066021ab
commit 9ffdecf2c4
117 changed files with 13198 additions and 5194 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -122,7 +122,7 @@ endobj
endobj
17 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260303132400+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260303132400+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Author (\(anonymous\)) /CreationDate (D:20260305162606+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260305162606+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
@@ -133,59 +133,59 @@ endobj
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1195
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1201
>>
stream
Gau`R95iQE&AJ$C9Q*Z<ZQ_h8<%a*9FkRO4Va6Tm`>@'6*''2G:ib7oqr;lk4^qW8Z^8YJJ-#6rRdVu'9YnUWFl/)Yi-c%&c6uKnnL[R435cUeNCOL(g7B"Apjt4+I@j'U!k^G&H>0#"IfEhE\.gJ&J1qc0J-(R0`:59g3Pn;7^5Ihu\A/js0POb,6J):u6j,?&n3EM\pd.ur:E]YE<o4Z>>LtR2(<K_tXc"E[<tN-#amL1&E:m\068Ba5/e5]/JaE.24Ug#:n::p:A:oIHi<MN'Zg9'8$>Mt+&\'CXW_D!Ja_JG6T+oH+nr5OV-rk/9m1<1[O\!R%>*2dl1?9=ea,'54nI#1:$4,b@!'X"N6_F/c0Y]i&pnPV+7bh;q4cTaZH&4IH/1#tGK>Ib@(^7BeOY.OY:BI4!9rNl4N43mhUNt2`m+@8;]BUHK_lW=*[Bs7"Rkt:2pG#;Xa4VrEd`oe%3%CMl0a?.0aN@5:CsL((Bkbjm2*Z]-Tfq.q)dBb+"[4;F>"s+LD`:>af&cY+e7l?bmo6=0@?h?B;e^k9S=Wrmhoq.A+]\eO9CcS[ku<dFohsTbAaUYJ.>B-YPnXrq-7,nA!57An#F+E"-X7WL55.&,,4=Uf2mtBq[9i_a,(AaLjGa2=<O.s\=]MTZK#h!^/T%baT^?T\G^OQN_l$H.151dUe(Gg)^kV794lV/N49W_!H,%+,.HWC@b/R>$@&Ydd^8nOD]j@]gf@E1MSpef'G%M!=4R.<0H7/%'e!%po[&<_J46K`F0r\T4dR0^C(C?:0D4O9!A"nT>:@_Y@1.I`?SogF2W,U?(\l`UU;PlV^lu/^cQSf[j=hF=YDcSk#];pR5dF_/tADj9bW>H9lpXgY;=X<kBLJc+UMc&.tJWjMiTdt(q4'IdF(>-=B<_J2fREZIA)KPD-#Nf\t\3h1LWup#p!__"BPH[X3qTZ(f(lsIhT[s]>UEUmKrdWDeD&Qc2`=N@6ch#h44Is`X?C;Ai#=OBh5DY,MrVk)qYZ@QP'CtRMn#BIWRAsPIO8;A7lQklq(j)Xm*9Iq/->\E)o`7^WZLr140^2j)]dfe9/1)/2LS1eCCsdQ-YFB'9!rc`6\+YLl\Z9$iEW:pW4S8-X!=:knTR1/m=?CI=@Pg?R=UY/qDbr4nZcg,/iNpPV>23hN+h.5(=lQIdkZ30eq?PD-D&i~>endstream
Gau`R968iG&AII3Ci4:56i[c$_-B#r,<*oXE]g&]M_9qobEPRb"t?DAoZ).^;R@]QEA!Z>B'^*NbP`LZ;%+k%R/8ru!'\?jm[O=<&DmL%6pQ,+PBc^Z@bjVTLbCcDd.`A04MYa-l^.;_lh;Jk6n$0p!!qb]#T/YZ7fo;l(F+;QlauX3`!*TbJZGXO/;+IO6"Ic_#S4j&N%];Wi]Y6[XRt,TbuQ\qWd1nuesPG"i[)N]_:u_W&?]AV;^tP,0B=i)1/RrKcbiR2$6PXW7(+K\OabZP37%SY<'uWn.KW[_?'WM=elgh14sYij\9r(.$<icX@(-E0U*Po%X&^KGXY"gQ:H=QeQ`n18U4=p0%H+#R/G-I`!VcHnLd)4b^keILM@)J%a@j/X@OW`F3Z*,:`G2o0Mfc3p;?_`]1nc*cERE1qN(0H8fs;P0c)Q-S*j`SU[\<BBfq^2bh8_-U*,!I%9%RkUhGNh@N%kIbP8&nU>h;O1Cn-b"e%)Vn-h&d2a0uf(%H52CT9:t34B!:^Y2OckncrHh1g?dM3n<>rb3s+M)2g):>r7/JR@Tj7X=^lHjJO[hW@#gRV7O.i5&?00L*^?#'GYGI'):#[^$+'<`#$QD66>m8TRtN]\.S^ko1O;0QTpFb+&Z(Oo`/dGlkS]CY`7Y*W(uq)H*NrOh/-,:I!ZXH+(t(g!["Tme)0kL%GSjKKSCs=#06I,^OP/kmUCsNel?PEWm;Ttl*:N">3IFpNIT;<LR,c+mG&3$fpI/"K"]_2kt1%thNRl2RrtgI8SXWfp/'u,`I`;H)QOLdW7YQ$-[Dj\>AJ$Y:F8e@>e"Qj;lm90CKmD42Rq7Ear2;*W*OupG,q0dh:4g+kj5`01,.1-<K)f3:k"@G*m&m2EO;S7`OP9p_!*Pe1_i!W4g2qVL[;ihbCtS/eE.Z^%7tgL">dD6g`qm;o_30)6,V/l.q0I*op]$fE=id$T\GN8@ko6+rI=G/FV<SZ_&*L:c\q$^4IOJi?C;Df#=Q_U5Dk8NrVkZ4n4ogT1\0s=m)rHO\>ibfO61eWRj?f.1>'/JP?<(tFkBHGmN^<;E:T6qL1>pWkCW/&,urI1imBEtF]2b-NBPOdNJBFR%pJ;UH]bp3H]e1aV%=fhT*Q^8\A/S$7Ik?Y0Z[@HDJMr8UL-CU*)*R8\q!Q\UMG<s0T"TEQXkZN)dkc+_<=,4\8IT~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 887
/Filter [ /ASCII85Decode /FlateDecode ] /Length 774
>>
stream
Gau0A9iKe#&A@7.CcS=G#YC=mM-HZ-V8KYoG^ARm0o6/CNbrUqd/<aC/B:RRC(;DYM+2gfGjhb8bCJTmgAf7ri$<lKEs@RjEXthT!QcXX-)7@MlXhV@,/lR\;A%#EN6=]BDrH<BpsY$9ac0\YXjS]9<f*1nW+MeEGe;E!e!P[._YsH>M6(nh=@0=_(m6m?QXDp;_Dmb%@&mtB"VBSAIS1M2o9CgLZaDc/rfjj_<3C$IYt[kq'ubbIr#(YX,l:p(p"Kh`,*^4:$5r=&G:>r#e?0K\VZ2c;-qtS9_-qYZ1o4?9^IO`K#iD&UAQ9N]OC8b##1CS/'bT[OIsOOU\"n*gL%h-a<j_ge_pQ$YL;K0phNE/UfaYhf?8lSa=^/_lQ8YAhI,\sj4Nngel^?M3_:%[&3V%1k/eB,c.5`/_'\hcTN*23kON602mtSltD$-]pNdSBfV/&7(Wt=e/QLDmo*>>HJ>oA+&Qlb$F\GPFHC99sZ<FM*]/XNI>j?EY2I<B[YQeAe+,e_qOQHM9#>4\j^US>dn-toLhG4Go[$[BprVCZK63'3WG6IQapL'&1>h7Ym6/ZN@l'2O%DWA$`HNP*'6AoYa6&@BKk&ED(=U.*"p(l7at3!d@P'?M^m$o\S)YnPUu)076g/!ncD>)F?CrL.kk%Z^.?&riX=AoK2tee&W:iNH`Q5-\7ZcRLl'4FS$q0mpI7EgV*[Wf!g2>L$`GV@C$R+$R3@e&#]LT7)<tm*UPj*j[b[Ek,B,%&V=egs'8)i&4#l'm4Bbbt%cErAoWU'e%J#6m<HdM-^J##84/$PkhZrT(F2c+[hH+bB&"@`g=+<E=</>bF>qF_HC4uK:F#JIpKk!@YSIdTd[3#Ga6QZScXDX'1RHP[uhLY~>endstream
Gau0@9i'Ou&A@P9(cZ@uF`#0+iDq@,WDEsHlCm2MZGYZJRtN&6.)2?FS6`Q2-l7]9=IpCkhg=Cg!)>oRs(i!\SA:K[!ec6f(5--(P&iTW-4^bE6lO)i,:-H/Qkls'D3N/pr/ob/+"!4r*?[@%8<tB'Qb,JAGGjt[e8mCE_%]55`@"1l>EOOUcAJJno,P#i)-0k`YfJpb7t#W&d,D]d*?-_[`l;I>@jQY;j%MkI;3i/Q1+>8k*-=k@#R**&mD08Kh/cGEMh\_DaFk8]`&&ENSH@@o?'g\p<(l]]paAnt@_t]YhpC"XZ13=*`e.D(k365hiFGqIAIW"m@--.VE>H]7Tc@-<36SgihFm@%jm;L%cJQ'c(MboUlj?=]f2A*SU&T2Lr\r`12`\#Y-gAnT3*du$k#)%gdC@p*5H&6CGF74(!eILWh,\+I>E@f]TcN#W2I7fTR=*>hW)j.5*V=?I.V;GE(QC_//Sl[Wd/5nsUCqbu!coh(OTf@.cNYj='YuY]`A/4uR21>adoOmWKT_MG?;:k\]05IM12&:I4HW%$XLmTU@!YO@cp[;&GC#3n>0Lcs\3J]HlP+dl4F1a*XR!00i*&_\Za9nnd*i4Y3%H`X`I&1ko6UmCSI_uUCA%@X3QsV&_Co;";OXo6bGN+-]hnR2*s9F-%%OkZ,#a)H,V9_d"AR<X:'NFdZ71qF7>_f>6JMJd9[BsEFTfh$$Epbh="Tq#=^#6>QHMPVfC(q:n=uL`PMML8*fLp"g_VE\/Y"M?!o_:XoD~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1127
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1175
>>
stream
Gb"/fbAu;j'Sc?E`Q=-NVQM2f(V4?6[&(;\::nV;0oFuk&s3WojQV#-9L9!hLRND8nVf3.oUhoIaRX6V":prn_Z@PZ!$AsPTF?EVT\SN<*'lZ"4>^a!e+G<IAIO':Mu,]U@1BkgRHK+>R)^3S<f=@FC7(rnnmRREAo/6pQ=YDrpg>B&m\"6:@(/6N_h`SCfCFj0fKKb'H7gd=#6SXG[(j&10"3"6LL;LqHbLn$#Nt#"Sn0p/+/_RkS(YRto%`24eO18502q[SoD)O"IAfXOos%\lbIpTs-(`FqqV1FQ9u>b`l1unVmH$-leq<&V?uK'HbpM^OTTQm(15J,tVn>PV)L4c@a!-,-mA!`_S#m'6fbSeB]>",5V]F"'cV!9!HeGKFYnZ&LRfn!KR*&kOJ"6hj':9(`BnlSK8[JM4%%[^0hi1V](%gUO<#(MEOd]X?g27->H"0<`%hlNl(7>Bd"pS8.E7G8t[nBZD\5BOlDVt(g)TY]DG9nLFUH"/-18t8UNkEN-(!TM$g_B$nY%Mg1WZFh`T0Omrc]a=b9?N:T4a1s<")14:-m9!ErO=?kkf*;0.a,&VXJu@#S0f?;880<l\Lo,Jg#`QhQWQa4^^"!-'ZNU1<Tqtq;<]m<dU-1I0!;UR*lcXd`E4N"Wgd9I"6FH]dt*1T%],UL_VM%A7%3%$?mKVFl.2#gjdB<^$'o!]*Cb'#e^g38cKC@YRL2fHjn[aP;_bt(gXP4r0E.@s3bTb-_qjAH`@?AU>+W\>F\D[1`h-)JqsTUFMEa-9")5n"<+]@Cn+c3_mqWZ\@9T\V#qdh89=USfdeH#D=3-qk]63nPV6T7dC-X:g>=X*;,]O++j)Bk4G[X[/8Q-oseZn?2A-TT-'72r0<e90il5;\IW8$Yj'ZpaolS*1*"EA0shTsPTc#=]nN_6.Y'k*@b=GK7J9WpKffo8LS.b<tbK_]B"l\M/Cdo\-JLGP]\d\Ljte90ESNMEY^RkH&SX<tW8p6@ti<VOr\I,AQ2:<[/:7Vh)faKmfmqhLc\#E*Ya3%_?SJdlf3ONS!GaK<Zq3Os>q_G;oc.E?KIRWt2d3sc'C8<)MffBBj)jRbqI5D^VQKQgVo-]Ma_MNrFoq`s1#8u#qo~>endstream
Gau`ShfG8V&:W5<Z'imZPg#NO*QJ2+Wfte1WGL`&8RMC96"`fK%f&BT3.H>m,h]J>i'c\k0S@`Xp[.CXH`'XR#[XmK-Zaes1&US]"j.t%!m2MnnJ%^BF>KX1:D#Rh?jo7%LBdjtAKuR6bkJn]bi^#N-Y+Aj%$Q]nZcj)W[ScN]a7T`F^aQ^=KqS*mDG=Aj&Y$;I#rZeN^($$GJo4W0Eu9CaIZL@u?UFZ2i`EPVa/?68YQg`i3m6@R=;pj<joY:uS\n0/+cPpf;GLK2?Ua?LII`V`'Dd=X2524B^j#]^d>plO6'rk[KljpWg%L%TrEt\`JC]DYCXPa`>;)^po@,U2#G^PIpe7RS=#>?P[GAR$culkV@Q=5[,`;?SG9NF>m])._7cA?sFc^s#/M*@c_`W1-^eUi9);Zu?=VsuTi-hd!=f,,o+]gmm[\2[uW<FlQSPC;-homjf`e5tbRnR>f%H`AEi6*C[I+artbo7tTa4h]T8f;;[VO/e\DjFOpk,Pd2m21,VOgZuk)tR1sgHEmQ%]dQrh>%[5KjT;-/FR76Y4op,;&Usk9L2<cTZja1.cEGcIVglqa2lDhcsNjE,YN8g<'.9h%!)&h%),fq7leU!>SIpSN(e*@4%;]AHWXAL'SVt6XBcX#9Gr4[8I\SDlK9doK'21YSUt",.)"^J:R>>/Wl.V>'HT$[MiO"_Njg5YfLFW7,#p.i?T^s?j'FKF;8F,k-klKH@f+m@bjdhLr83ad];Vn%cdT5a;kH6oCQ1j$G'ud#]gKOuU(=UAGuDfg:\qHa,L6FT*JPI*6]/bmh/5V"3>I@f"j]uH)[DQ=9f@WCh\J7MMlINJo`N5ZDE&('4Bj8Fr%B8=Z\c9&c`%(L6ZCr(1.rb(?$+/MHbP5&Lh(ecWP-pa:hE#6&DW][R-:UI(FN&qN!p$@a%//ie*SMn#jar\b$<',Aib\)f2S91WEH["G;19G5kBFoL;7+HWiXQ&LC^7a-rX\8e-lXo8W>e3g?YZV4^9@q^M%.iI?q[pE11FFZls'Q"Vr>(32j'KR);e!)/^Uf(X"W.G\!lZi8t7nD#DDG!-:+g'bGo5\PH&h@;n'M]nh8W7A3)ILXN.;BN&_#J)/WG3AK3]JuD?gmY(`B,@7+QK(Fo('E$USW\XC^..Xe9K1n'Q@J?FHdD,Ht=;L~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1082
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1095
>>
stream
Gb!;c?#Q2d'Re<2\;t?jGd]=2qUTEI(2_kT>$q-CLsnBgP@;hFrq]pA7oStIl+1RqMBZPjO3`N,NYt[+=T'2:!,!If4D2GhKG.aF#RK>*l`g,Y`tgINOEh=[5Y@C<Y4(:ip$M%eYC!.876.kUP(3cGL5pdh_;r*r!`'/a!=:%EHuVAXqV.l3JAfhe;eSl4V_!,BRQ',79u3$7VX)R4eb4Csdrbr2K'9t-;O)ksJoD(UE<Z.UY;KGM&-kYf"<RQ2.6(%DA<ku]JtN`VVIHsM-L`#C*e)33LW%(F9ta5a8BY^q\56[@VRsg?+F$tI]u(b4!C&53>\I]B'&+C,H+CpZ;n7<218PXI^"la"^M>_)_q!t$I?n"D?a;5:X+$9Y=;?apRB*+#Fn,M/Z@$P+:&;ejS:boGi\FPQE9hofXR>NjJ2[Yq^WKMrd\)F"[F&\&-#d'iRLNQ4M%XAb@l_[k<gLOqm1"KRdo-A+1K.J_ZUd`dDW#VI)X?R3PH!_3W_HV=\#[5$EDfuB-=BhNcE<12n.B>1>jbQlTUuKkHI[EfI*$@o_RV&Lb+a,'cpnl>6gr:?$]*h?l8MG;Fs,S/W8@f,D&48#V?<m!\Tf%ck_Gpkd>'Xkp9B=J_hep]B^T?k_jpV)#sSo:K[[%eqTuo=qC))MN]`9r[h?MH4n^-&k-\o4]\t8Pf8$tJ-g9hOfEoeOQC5@t)t[]@1lrdmSX9tLn+L+hn$IdH?*NY?cpF`bfWVTQ-HW"P!49!7qU>L?7o8L1:)1a>$rQ9-BLg+NfSFe\Z+;L%g8`[,r%cFD@6$E#muc-T'H"/-<,Q.l.T[tRo;t+uP$;m[mdm5phGUI.fHX=nHkN-n%Nq5U3\sWbInPFoFrpaVP,iH]im/:?gBuI7HZ:MR^grSb#8fc]4Q[J%s)?nED;toJ@<"2CdFLD;[oDU"C2u72PF88/QNgF&K8oZu!c4AWAaE.Bm/>*a\nM%.9p`",$P<gGfE[?M*d%Q-0#[kBfWaH.JDpi$R>[3&MDXellPCrAC$TQ)lWF70IIfMm@jZa;+2<fnD0Co?DmCp^%t9D=[>@/MnV#b;`^[$W~>endstream
Gat=+9p=$c&A@7.oP#h['-YK',gU<V^/Dc5!a`BU&K,0"K-LWU.;JYN[-W1&`3H(X<@T<(??\WWYFc\/]NFbe>6$c]iRo^cN'fW2-A[3E*P>'95=TH0M2P%r49?rGdBS^N?ZXt/jk\#R-r(qOOsE.u0Sq3B(?SC_,Z2(.!D+P:>3t74PMq!Vb0(?kPMrn5!CJb^d\]`fga(GrgW&s]YUs&td`"D'p(1a,"O[NGT6d^PGjPL9(>0B8!h2A+/_l`!/:M4!XtrSD1l5q2"Qal<b1f[%N0F@1\t=uofN0R^X0]ZokG@IA,:#c$*DlfKdSImfp)'pNfO8Y1_g(H]>?82/j=g:Ac#)G1a*q>]HK[L=QU<Mhi0[aZqn/jWpfJ4<pc:<YDC_[0_RcpH"rs#RZ[K:D,Fg%B_dQ?07$;VQ&OlMk_WNt*TCM=IEsI:=*JbMcc**TtG%H]mIHIhh0faVO)G4BN$25dUNkjC]5ts'(=8(S1>N&Ttl$b&H!b3N\\iL`@M'=Kcmh;B@="t93Q&*+[o0f8>Y17hoXAqS27B&J@!KCPuYG*1G'"ul_4^sd+D7pkp)Q2'Ye!ns=oPpTtr1gV[H48O_a/.Dmr9.pLj7Cuo46"a8@u?k8ko=7ChpYn*atLVr\ed+_hoY#+S=-$DklEFM57i$;fkuB"&W+kUKL)Nh6aq5YWeoXg%tjjjl3$*?X9K?tElIibld^"U;^'dJ/2B/7WY"M5`D;g:k4T-;=4O7n=7FNM<a#CEf!Xnjl?Qk2npHG.<N(+N!*LHOfMK"C^qZ7W0j-*3iL?gP):[O5Gin.sZu0LS?_%+:1\8f/dX(tXGC(OP<WUZdc!`_sr>^RM&#Cr_qg[dWd@.R-6agq<en:NumMA!Q,sOrtBaRGI#LX<<^&>URJpHC[gN0g0Q<38Xcr$(lW6Pu66p(uX^T;$Z3C*3=TEc+,qb1%<A?t!2!ThITh%d]n[f2"r<a+ghqr2QMCb4Ok0I0B'FDGQG?oY8G&0VaI'0qV4$gaNpa]nWT#sqdRL;Y%&GA)$9Fn8c.Bi\ih)Hpn!`W`,IJX_-R:Z5#fLK34J7G]ZrBC7Nf?sb!aoG]-"Mqn~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1255
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1281
>>
stream
Gatn&?#SIU'Sc)J/'`J&'f@4bkf^b<UmSK29q/67eskLB1@<MU.@64b2;!uI&LBqR&ksi`#`&o[OF6jfj>dPN%j1*9VAk%E$!^e8TF?te:dG402_[rY^0]/515H2H(rLPc6kh?qG(DjZ(A^95$\_B-`%2Z`LL"4'2p;eaWf9=^'-;I+"BN@u,u.W,)!9C\\>!1d+AoHt[N?=*q#]9/:,Xg&I$MLlFSZSuS&-2[hHdgeIXM)=,V)Dl<b$W/::6.0$CRhl5MON<ri`6#iuY4#W>"Eink(B?ETHX,#c0Xok^X`]Nk;Mg(ql+PAV!V7N@]/Yag\B%i%MGCSB/lh^R8V3f^s2i7FUe.MChA%r+@8#8bM,B958Pu'qOrr<BU"T_QNRh:neki`oNsIF`%Q-\$23"bF-M;+`O2QH`PVrFd?'tA^bsqGP(/lSCqakb4`m#niTk!4:VL*5[#\Q'i(VLrEb/ILd2)K=FJTjT*Hm932#cl/<j-]<BXmG=<Aam_56+Ii$5gG.(?Y8HPkRVQ>2EKjpJQ/qg4T&@'Ao_YNDcE8)SI%<6j*iTmXXc:q`q.HqLNtQ?>.Ei!$2._H:>@c\I#^0dFOG_CcBDB`$*XJkqIoWU)sK^"8/eKOa3W$A&L1C;5P@Vt>ntX$pl6].!%;*%B*4.P2`[)3T<NcgBiu?9eU6=\>'\_G.6Vj>j&)?n1U8S4@GrGrulV\D:2"6Y::Mo<#<pmPS@1o4H2hn%%n2WU<>r^SmRVl1L6VoD2%MEAY,k#3K*?dAd>;Q$?_&8omi'H3s=l2#mdB*_Q.c:(UadIi]1#"TC+=l(I%qO*=Uj+SW.$G=crFO$2KB_)r(HiI=[O<d"ok1pehdcK'sX:q"J5Yhidm1="uQ8'um;TW>)')QssK6oW_u]!<3T+8GrX4](`"V;;"`0bKr)/p*&\rT?H_1^2qie$A1K(Q;oG2Zt1]H'd1?X.pHoh52[;R#A[,l`SZ05+no`4so>1s+$m&-Qn()-:He*P\XAM89<!tR+ZMTD#\XPpd6a-FYTT&m6;,FE!"WQPIb!q_(e)P_XX?,+,#?R'><:%:#&%6QbpAKPnk1V1QR=IWcDasn`Zb+<+afWVJ@aN5O/r(J`U0@,[!I-XB%E!aR-Y<TiZ,b?Q_2dNW%*d#-h+g4a`l380$Om3@9[2o?ge"_ZosN?Wcsd"=T'rrQrq0h4Vl!Er+<@/Q.G)36sOkmiQHIHjd^ObGlE=QX\cq-^WOXn5&\o`P7B60,a~>endstream
Gatm;hf%7-&:Vr4EU]/n`/7)_^C1N>M`G!GYK[uhHeAcY;\!Z4E[f[EKDk=%dW4F$(8M@nJB6[iGose^G=N6anKIUEZ3#88JX]jFKYeu<_AicD0oRG\hlfT4)+9K$:l=__@8TF4g(jSmB]t(9BukZ':/#/PCZtSZZIA^o0Ok7&f^V;[NBdkBhr(6k5rTaL@RM.oE2"g7Npd)1H3(!W:KGjBleM]`UX<0<3.a5>I<tYbkj-uq@'Y=))`.5LF/T2m+cM'Yoi\n.gV4h"a$#fg_ud8A4Tj6.ETDF#3.uN"70`[GH+45FL]/[8&fSh&hXa&\0_qsh"oO>oJH^=?lW9#:$_?'(NAh_KNGmd?`J]_GL$GrjW6lbfABm&0>l,@G\+1Uh&Ii]Unrf=p<s@Z#fQ&S><+J<U0G]JX`p)o1Q<L;627d/(Isp=S2QNh(m=*MHcNI=iNZ]$TXne$p_Gj8\(Od&ZJJUdMZMK#e^Po79BEa)#.MZ-c7'n@8#BK:d79n^p#CuQsSPcqpD7><"[p7-#=63D>V,/sfWcXZhs'.k$;+W3*;2P7-53?^jFe<?X0(%-iSj\m]&h!0*'6;Yq&)o[h6NS5!?r:dUN#pgeV'q$mH.&-m*bqs(m32c+ircZl&`Z'#cuP9:.'/'<2U=8AdtL?8k@?rZHJ;].HC0HpB"[1Y'noq$gP'^gW"[OfR<)9\0>fdh_O%Yj;`o[oSZd`*a/Oc?2TVpZS[eEgTF#a6Q2=QTogUQ0MU't?Eb!X%f,L)K!D84^GZMu+bTtB,G`m,mOpb74Ash:_)'f_:=k\ODQNnIr6YOiBNlRa9QP%7[_Oh0\maQIY_8c7n;Zs9-kE31Ynr$$bdYc[nYc>9BX\Z2Dj_?ERR@FYd6X7qU>EGt!pK5Qr"oJclljbgf8NlhbHGtMAYJmSeXDb^/1rf5SBp1G3GW&nuRpZM3M8]JdVMdLO+PV0cdYBSUUO>kA\pY_h'lks_d:ms_=qY.t1]=*i,/s+K,18k=M/3!"(_<Zjr>G&=q'2Ju(<XOGg3\#%S-3gFi;ZrVbW4"uF#Tk(c_XUI'>LP&jf93.<ao@@gZm3_Ll_LdYf<ae['j=2UhC%Cp'c)F>1"IpgcHfO*#Q?c$hJ-i@il$b3EK=3Zm`*I.gCK&ls:WN&tWZ5Sh08j\Lb5]*+@eR/nI,9Wt2Ge7X(K>K,dA?!'biN`^89I&q(Q$a:\E%m[K'n&jN`YoZ,#t)]i>O7Rb0+?&ki#')_RK[h%t44u]q[]TMXen+^-;%fQKX\G?_HAVh'~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1087
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1224
>>
stream
Gatm9?$"IU&:O#N=Pa*X<9mcV$POY1B'?(9Gj:4QW34V%16s^)r;+$g'B>CC>\<C`'.;MaqWun1@gWNK1k$5u^rm/3hGBjRZ5]Hg#fr=9aA0I?cs<M(KIb&-Mj,&m3,ZV^m\=8@$!@_eKWSm*(f292&XJl=Va)ZlRRnXM#YkPF--SR8[I(c"_b9/U>WPs('k8/R6FOlHE`Z`4]Ai00jI58`Qq;TG1U:Woib,n6^gqfo1PsM^]T:YI]WpiL[N1LV?FfpDC+KFU0:.SMk>4%Lm;DL3qGZ$)Q##l\DlVTJKpupXoTf(P(:Je2ea:B1*rHQ+>?aJpUU:OkMH4F[W9,Y(4VX6^,01r;E?^?N)MpkZKVUk2Q3nTOG1XZL;Q6li6)TD%,VdYK\9Wft__mMek9FHf=Eujl#ru*`dG8[cC%gt(!SQT4eOTD=#bS::(RNh0q5:\Tme(`'\RIrG/KX:QLbX?h:@,feDJa@uCC@l&qlPXS=_CC>WUmhR@HD=sjQc"*;<B07PNS-/"u;<>WJoh]=$e9Li0K(f3['WrJ^Oiu[b&82oIhAInu%F=($)P2D$3UnL=/ru9j9uo7Fd&dN[%7X&]0pUQkW#Mn\8$#l`,`s\Y_l"D_,Ftfe5cq[6<0FF.J(%%mrI/"Pu-HFk*R4AI+mE/M=0*iYG;r>ZY]C=kHc'R5a5kUJ2&Oj4@6pg([Uur9r]$&$87H*aFW?E5$f&QUj'pZbFLbHX%SCMTOoYK/t8Ts0k#nX@JuT'Z\N)M`;R*HAm>85iCM.pY+8@4ieib>D:ARTdH3.Wt-S]mG-D24b<FK8*tQ2gUO[VKtFKkQQZOShRWH25-pq+/@i'ud/KH>j+?6AP/JdAYY?*:0Mi6Aq3?IT$XP://5p$q&,/KOYJ"(rM-E0a5Ju8q5N^sQAO9=Lo4C*r</oHh*Zt+1>8$*lfq63_*h5J1`Oa:b8Eb^FpfSchGolXiEeQs)j3)FrWKe"IfCjR!p:k\B3F4eG;.h^'9qDXW$!eRb674N/S%K7G?$f(#8Z:-9]<`g/=.E(b@1i$Qj,<f``Rjit&LI-jleH)Sr$O.u-3lEE#q,`S^9.[@~>endstream
Gatm:?$"IU&:O#N=C.B1UoM.GkCj3RTTGiY4?W;UFjsB)D!,!I0YP7#Y2mMYD0&Ea(j4h-d`Q4K3P,V#MM$W/]E!2G_V#.uN'hn#9G.Zr*+nHW5::9^;o_:>c68>WM,c)\$s,0DF[mlO$Xg4M4o<$G$%EfN8PDPSKJ='Y"[p=-0F@CZ7UNI<e<!A^]PL:/9]"F%KU8nsfs3(dGAtZL"KrqRj+1rpi5jIEfr^</:2MYE1bS,g?[L_gEBF8nMdt]*5'UUiqKL=@4Oj,fg6U;4h#9C/\_A#-KkYi]6fdImIc>=r#YYQ4EEeCoa#r-amrNpl[]QU4W>fUm):":46mrrDHYQhVpZm<!4@U^f[W2in<E0Ik`TZP$DZ(6BZsl1k#8s+0m%10lqrh<c>hRO]4Isf25:3L=f3*"H(j73_L<hWUlfsfg=(=5Z5)/B/R0T0hL/R,5Z/$62G5CSGgmpg[MBI+'/;"]ND+<Va)&O_P')^Cn&tC*5bc_ZtCE=Yl8?Gi"8AZ$sFc(""+\ZV;k86Sn&J:ioV?GMSnPDGRdXn2KYbXk'YO*C!b[]?s,H7G6(GeSuds#0Wo?@75]XV(>+/`I1^A0AT-]h9n*D/iB1;dY6o`8@o"'oh=ILBonY0!-'K1i;5Q.ZjS@oon99_m]=eRVJoWFbPs,Z1.,jAbAG$[c\,;;H99I:P`;@'6?5#Ep5Z7^a&,+:/4h3)eNIN2BH';)RKs%L#dK$`J:5/>($<;Q6nc@Wmu-*d-epX-!(aJYoHi#-uLn5`204Ok4d2JAJ7a*FQL1:p:'d<<`e7/odao*5`4\/T#rh<<ZHI%8-dbgT7]$ojTU(OeDW8-HD8^;;A9<cQ6#W8c\7Og7g;Of=ZY<dZ+$\bHn;gG)&o*p\I*nLEcS5];*>+$rrtuPTO`sJgK7L1N,=]LMqVW]3jBI>AK"#_90ZbBT9J;Ja(/Xml7i5<82!n\$]MFr"^Q)hu4/_N7rsSaJF0,[ga:2a(Pa]*d(*QM;fJQ!&!_!TObc<8o0eb*-:)2<8i=^Agr)_neAjG\e46%U:uMj7&/UD,b4,%F`ula+8mTSI2W&LZg%c1W.S79=V_;1;$6i%A3.nc?^Fc1ORd@[/odq7IeT-'A_6?)BA^VZ6Oc4hZto5#@SYS+dFSt18XJN,5W.H27M<jC'YHMrUV:csmglaT\*e/1Y89FLYsk`JO.30'[$0CQPq=f=4e-K^+7nrR4o~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1062
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1075
>>
stream
Gaua=?Z4[W'ZJu..ILLH\&2=2U*Qcm"nJQaclgFKD$,O,W:I>i>9Xf>8[*[??."\$H8/qp%0e)GQfL1'ktmTE:-T>NAHQn7_n1s2KS4^R688Z>'qFHNqh`u!9P,hJUnmU@WS'tnYR+S\16$iVRAs?hb@IJ`%Tc@u(`"Jjh^0Jj=Hmhi",F+*99]'6l5"If.0eU]c*k45r[^b7-"8qsDd]#@hVQ)pOFOu,rH.c<neL5(q]W_hAi?Mj8Yt^s^06S\Tmf5bTt54i#!bI3R,l(oYW*@+0'jfD#R>#*#i5H3Y,WDP+&'?t/,5gDi^_]"T,-lfVcs\-l`p/f6>.C*)EW0n4=oiO#Mh,E6>@ZJkaZeB@+Q,.r\WmlT#FAL.bTS.:=>hb*&@?G;gre2"/TiQ1k$EGXMgfq41NT%213Bg(M6G*8&DU^U^5Z_l3trpDIM+0HCR,f3].[9bUj_kmZ=0DLs/%r.[pn?j.JgL:7#(cC#N#n2hfl6W#LWF$ap"lCo71Ale)eB5<ftje%bbsDi7QC2W1OIltpQscQf>q$G^s^bk11iY-b/Fr,(C!0d*i]E,>Da?<O)a'3=`NN#u]4'EsKQ1jbY5CoAGr<"W'tl<d;%9jl)6Vc>Sq6cGff"a=GNWRa??.:9b(fEOIT2bj==(1BeA.\a8r7O,Ki=MGXd>$\t>V:F=Epkk?cX-SK&(1rbFl@BZ13KDRI;G*VAffkj%_M@u-1h-_;85C9fh6^:F4,MI\9"PpuL>K>t=%VIRL4DH&XpL6&7VqpsH+fZa]I4bITe-QP*E-G5M;$ZA4aqJ`<PSP!Jh@YdrLD[Er&CN)MSq]-[)idMj7jo/r^+)KNguJ6+#8Z`e_-X?JfX4l\>HANXA+aE;k^JI1WX:H6JHRfe?fNo6V/PhVGHUS[NRZE!I;+tV\Lsu4PJ<bQd:DE5H\n_qW@/8[gs]*%9^WZUoVbBCqkq7TYq']VF6`se.h#/`u2+K;hN_J(5^hD7klW*]&hej(%$S;4hTFicb$Jrd91cIK\Tnkr-:l+(DV,Tdb9#rG[&N=.j09!*[^mg/:^lq~>endstream
Gau`R_/A9k&A@rkVO,0UU0NkU,cd758N5I5K5A+r>G126.utG!P,M2t]Zo&P8D.n3N?S0u(n`/banPH"Ajuh]rBqK\)W2fp[oWWaKR<ee0a7@^]ZV[`'<ti2bZL*#8cLTDAi!$5-?PBk1^5:<WaYu[PF8.$>WHN8[Q$kVS:EG+;9J\C*5^\E*U8-]/Ds,e%(HV5D/j9=#>F;,@_4_*@@CQ\h8?^`J%EAlE;ps>9??#c/\f"h2Pru9V=J/(NCV)Sr]&j[3j96DJHBR/aTKXME@Mg\+EfG/5j,5$)S8b2fR<?+V,oqd58@.F%@[=QidKcZgVX]9*ig?J#d)"]&=&7C@:@bNR#]"09.M+n7+#-U]#V3-bDK>h"tnN1Z]:$.NFstEM+WdW(anQ3I\).S_;tb7/$_R"<NUUHNjDUB"knGlL84e!D8umYT@OI)j541^gYfGQCN)+;GT,>pDW_]P%9T_D2J2c$9%0"5rj8mq%&2KF(gF1]Bp$CEQLGc]rWGs;k_b$TVQ%^MqtPH*7\jbeWp_Sk6c'JJSfH4=&gB9+$@(LM&![G\LeYYd>=4J@D!:l7,!:rj-)n4)NBq[X_4/$(RhD;DV2\jO),"V./T#d]%Gc0?VA0@?\TV4PV9"3eOcHFjZ[Ma62&1`OUc/gTWEZIDRIKE>UdH#B9o1?KOJL[;+[J'@e\Q:8Lp_G1DjGI3C[L%5.^fo<BaS[';Jfu\00d$jku54BFG[6mB6N>374GcAna)Wl;8W(toCRqYr./_h*AM9JGsppsS-/F:YbN+m/#pE/2ga%sM2eo+K:EPi>-FElWPOrZqPm(e,M.sBl\LaSU.?9QLYCKHI\&_0K.HNs$JcGCf13iE<@778GR$:cgUd!9KC\G5lW$_R6bi,:be7Ql"p6AOM#>`jqO@5I=o)[,9O;YWI3gmR3M%8Y]gATT7mm3(q2fk$gW^boIf0$<Q_-`2,I3,F8QWg)Cql7@JAh1Q9Sk<oUWLG'O#(/uV[7A4DA'L&Q""":oZ3WG%J=9),J7^Yn&Q-DIoH$%n$aBg^S[Sth\X7a[FHsVVl;Xi87/TVaZnfX@.F~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1035
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1269
>>
stream
Gau`RgQ(#J%"4bO@%WStKuT;;>@37o1>=F5?FI\C"m/6n(ro3!AL1osZUOo-PiI!MQSb(9:^@=<;PXd5W?ZJ?B^tf*C]L&%i:Ms1(GM6ZQV>8,%&:qdh^?(kRH?%qKn)i4/5Pi(_T5Ai]MFg1U]I'Bd*o<(GSH<0mBRH8-^Pd*_>=8_%^bc!S?"0BVJ?<Q^pi/2KDR?*m]a(W6g+qmB@u;f?AKl?[^G_BT*Q0,mC$>H=J1G!d&mrI%,3SlTZMg_gfmsk'Gs=kiC+Lh][b=sn%TeBDlhWbop76:1P7b-n71iJ)#Y*ckRVDpl<;(<l.;]$X7A4NBb<.mF+/pq1eW/p*=5PC2+T]MA'f&CF]nV7,qP8_V.$%hjl$.kr=B4s"tMt*B_;Qqd0VW4RluSf6mN)^V+;(/&SJfWDFD./0!Y'-,&@\DJgjPNf]j@"CQ5Zm0EBE7=K]H`g\IJ9o1F;$UA()LLkbRB/qtH$m_^Elf]V:Rn=1e%>T@9"ML,<3(u[Cj)3kT@WQP%WhpH2><#'$#UM;:.`qY=nWg>4kfPLdq.2t>:f(BI-_n_3%LoV)m:jN=XF73f,M<%@m;1,<aR0prZ,_DXHVBSZ`$'E>_""Qb_8R$%SU>)mt9nSEm<j4QAU)Y`$quJ)QMmg$>/4bWP3`D>gHMeMS4LClA->J=^8)os!UVEB_*KT&_WTFtt,%jar%h?&/;=D9J7WGMjVe>R8F7b$T>ouJJBu"UDP[STN>_!0$mcak]el[.'=)fjA(OgJ&\ja?G1"qD.g\IO"Zf=@l-h:?*nZK6pO`"UEjjSDX.+2H3E#b*X\\eOLeYMB!N6b1Sg)l"snr.-85;_UK/b\UNl'8cP=0H@:;mfr\,Hg1fM'Y^bkOFPWrt4*1k:J_"(Sh;_-q2"[f[_4/_=K1ALF7YQS`7EWd]mT<FWY<db>6W'!gC8f8HbEF=d0Uc$;?2fN:[jCY%KRi4<\\ORP?/ane$O.1R`uCj'gGHZ,SU^['*f.^P!E#,)cn>eiVn2<Rk8=rY7UVq#[fdHN!~>endstream
Gau`S?#SIU'Re<2\9j*;FGrbIqUeHGY0Pb<kdo:M2BXZ3F=k\%8<9L/V8%JGP,pM_NghrG+=+1:7Im$k%hGj("SD3OJ)11<R`!5r#;0Du!$8BNjtiCMO8_?&_jbYg_H7(7U5Yp(%;?_LBc\@_8n$QV:]8e,;[GSUMZqpH9:3r,2[IHR*^eJ02@`^@<i&h521dXB]'*MAcX=KPF6TPt?l4]TJU[R>\WH?`=,.'o6R_KADQC0t,s&P(]10.2G'e=pk0H3#hs%ZV?l1T>ZbusY2pTAZIiJL(..)"-.U@R*9h344_gOl`4g:,B41#B\,@"XBP]F]_$+cl+\I]&]839nkKP0D%K07(TaEYl'4s&]ONq(0[nA1P&>'IXn6W9*[FSR2I2]"!gSR8ZQr2-XfV*k)](T7jSQBJ),OF&^;aV]LQAn@t$<1k:SitJIuI18j"K$nV>o_%a(!rXGac4$"hW;AC4%"b2o8H5/b&r2>%.?d&bm3Nq^`qWUN^G+=;!<2#:Ab6Y0-P81i1f$GVqia=$c!#0W'(_S6<e3q)j0=R:JX;K=j0ug^pkp\r[m"_W?4H'2MlQR"kkb/Rfb9Slm8^PD88:RpIJWNVA+*+,n)QEZ2+aP6L9c<&/9\["Y)N=B_]f7)((mHkBaU"Xr]#n-DKY@s;FIYXFUP,UT>*h([2R-?*&abO'-<BHBn')7NNo/Ag8h0j\lu3JEn+WGOH1ea)?#D?HpgO[`ZO2mhr/';*X7t2FYCK2UK+=I'e'/`;kc1i]mquVU&hm&qa%2V-uE6)fY.?l+GLK[I`bR99k?5<mtupB(<'.`%sf'"(1^;*S&@TD&daNX(eG2g'3R__LFnR/2bh;s+6/Gn/51X<V=MF]3A'M<HECs`"LT[!cBtFqZgo!1gfrDbS#DmjT$H2a:&k"aeSP*P#+E07MC`=)P4$$`72h-T)eB_=l?I=fh(4TC;mTnWG;(@idt$7haHr9tn&W42>WCF<alMesR0kDBYPbZMeNF]:N7#r:XcGheMSj;"&CL6,nM.L`%dh^>_l"TG+OB'9S`%^NH)j+Npf*B<%2XG$nsr'1R%d8V&rLY#e9RF@nV8)Wgi9Firat[Q9$PT^W\,aso(#QCJ+`Ni3f@k0?Vl_[A516aj_$>nI=lY!/ZsJN9350;DW)O`_djcT_<;4WU.ULVY_W]iAjCbP%IB$aKId>*,#o4,Z?)!CV'#X=reCkqU"VJnG.C^bRQfL#^R`&l2fqp(q9kYA+8heS8h3Fsb7BX4"`0r(C&~>endstream
endobj
xref
0 27
@@ -209,17 +209,17 @@ xref
0000002457 00000 n
0000002738 00000 n
0000002846 00000 n
0000004133 00000 n
0000005111 00000 n
0000006330 00000 n
0000007504 00000 n
0000008851 00000 n
0000010030 00000 n
0000011184 00000 n
0000004139 00000 n
0000005004 00000 n
0000006271 00000 n
0000007458 00000 n
0000008831 00000 n
0000010147 00000 n
0000011314 00000 n
trailer
<<
/ID
[<41768306a4f0aa20cd68431a7206cfcf><41768306a4f0aa20cd68431a7206cfcf>]
[<feffbf100a2b13f97aa0ee56386d0d22><feffbf100a2b13f97aa0ee56386d0d22>]
% ReportLab generated PDF document -- digest (opensource)
/Info 17 0 R
@@ -227,5 +227,5 @@ trailer
/Size 27
>>
startxref
12311
12675
%%EOF

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,672 @@
# Les 05: Next.js — Het React Framework - Docenttekst
**NOVI Hogeschool | Instructeur: Tim | Duur: 180 minuten**
---
## VOORBEREIDING
**Checklist voor Tim (30 minuten voor les):**
- [ ] Cursor openen en `create-next-app` commando klaar hebben
- [ ] Terminal groot genoeg zodat klas kan zien (terminal font minimaal 16pt)
- [ ] Browser met localhost:3000 open
- [ ] Vercel account ingelogd (voorbeeldproject gereed)
- [ ] `.cursorrules` bestand voorbereid
- [ ] Slides open in presentatiemodus
- [ ] WiFi stabiel en npm packages cached
- [ ] Backup laptop met dezelfde setup klaar (plan B)
**Hardware check:**
- HDMI/USB-C verbinding stabiel
- Scherm groot genoeg voor klas (10+ personen)
- Audio werkt (voor eventuele demo video's)
---
## BLOK 1: HET PROBLEEM & DE OPLOSSING (0:00-0:20)
### Slide 1: Titel
**Tijd**: 0:00-0:02
*Tim staat op, enthousiastisch, maakt oogcontact met de klas*
> "Welkom bij Les 5! De afgelopen weken hebben jullie HTML, CSS, JavaScript, en TypeScript geleerd. Vandaag maken we een grote stap: we gaan van losse technologieën naar een echt framework. Next.js."
*Tim loopt even heen en weer*
> "Na vandaag kun je een complete webapplicatie bouwen. Met routing, API's, middleware — alles. En het mooiste? Het is gebouwd op React, dus alles wat je al kent werkt gewoon."
*Tim duim enthousiast met twee duimen omhoog*
---
### Slide 2: Planning Vandaag
**Tijd**: 0:02-0:05
*Tim wijst naar het scherm met een pointer*
> "Dit is het plan. Eerst: waarom zou je Next.js gebruiken? Wat mist er in gewoon React? Dan duiken we in routing en project structuur — hoe organiseer je een Next.js app. Daarna TypeScript in Next.js, server en client components, en hoe je data ophaalt."
*Tim loopt langs de slide*
> "In het laatste blok: API routes, middleware, environment variables, hoe je deployed op Vercel, en Cursor workflow. Flink programma, maar het is allemaal praktisch."
*Tim klapt in handen*
> "Na de pauze gaan we SAMEN de setup doen. Ik loop het voor, jullie volgen mee. Zo heeft iedereen een werkend project staan. Daarna gaan jullie zelfstandig verder bouwen."
*Tim knikt stellig*
---
### Slide 3: Terugblik Les 4 — TypeScript
**Tijd**: 0:05-0:08
*Tim leunt tegen het bureau*
> "Vorige week hebben jullie TypeScript geleerd. Types, interfaces, unions, narrowing — en natuurlijk die escaperoom. Wie heeft alle 10 kamers gehaald?"
*Tim steekt hand op, kijkt rond*
> "Nice. Die TypeScript kennis gaan we vandaag direct gebruiken. Next.js is volledig TypeScript-first. Alles wat je schrijft in Next.js is getypt. Dus jullie zijn al voorbereid."
*Tim wijst naar zijn hoofd*
> "Je hebt het juiste mental model al."
---
### Slide 4: Het Probleem met Pure React
**Tijd**: 0:08-0:14
*Tim opent Cursor, navigeert naar een browser*
> "Oké, stel je voor: je bouwt een webshop met React. Klanten moeten producten kunnen vinden via URL — `/products/laptop`. Je wilt dat Google de shop indexeert. Je wilt snelle laadtijden. Je wilt een API backend."
*Tim telt op zijn vingers*
> "React kan pagina's renderen. Maar routing? React-router nodig. Server-side rendering? Niet standaard. API? Aparte Express server. Images optimaliseren? Extra library. En alles moet je zelf configureren."
*Tim doet alsof hij achter een puinhoop zit*
> "Je bent meer bezig met setup dan met je app bouwen. Dat is het probleem."
---
### Slide 5: Next.js = Het React Framework
**Tijd**: 0:14-0:18
*Tim opent de slide met Next.js logo's*
> "Next.js pakt al die problemen en lost ze op in één framework. Routing? Maak een folder. API? Maak een `route.ts` bestand. Server-side rendering? Standaard aan. TypeScript? Zero configuratie."
*Tim slaat op het bureau voor nadruk*
> "Het is gemaakt door Vercel — dat is ook een hosting platform. Dus van development tot deployment zit het allemaal erin."
*Tim kijkt direct naar de klas*
> "En het belangrijkste: Next.js is gebouwd OP React. Je schrijft gewoon React components. useState, useEffect, JSX — alles wat je kent werkt gewoon. Next.js voegt er alleen superkrachten aan toe."
---
### Slide 6: Wie Gebruikt Next.js?
**Tijd**: 0:18-0:20
*Tim toont een logo wall op het scherm*
> "Netflix, TikTok, Nike, Notion — ze vertrouwen op Next.js. Waarom? Performance, SEO, developer experience. Snel itereren, snel deployen."
*Tim wijst naar de logos*
> "Als jullie straks solliciteren en Next.js op je CV staat, dan weten bedrijven: die kan een complete webapplicatie bouwen."
---
## BLOK 2: APP ROUTER & PROJECT STRUCTUUR (0:20-0:40)
### Slide 7: create-next-app (Live Demo)
**Tijd**: 0:20-0:28
*Tim opent Terminal in Cursor, groot zichtbaar voor de klas*
> "Laten we het meteen proberen. Ik typ dit commando."
*Tim typt langzaam: `npx create-next-app@latest demo-app`*
*Wacht tot het begint en loopt door opties*
> "TypeScript? Ja, Les 4 was niet voor niks. Tailwind? Ja, maakt styling makkelijker. App Router? Ja — dat is modern. Src directory? Ja, houdt je code netjes."
*Tim wacht totdat het installeert, ongeveer 30 seconden*
> "Nu: `cd demo-app && npm run dev`. Boem. Werkende app."
*Tim opent browser op localhost:3000*
> "Dat is alles. Geen webpack config, geen babel setup. Het werkt gewoon. Dit is Next.js."
*Tim toont de default welcome page*
---
### Slide 8: App Router — Folder = Route
**Tijd**: 0:28-0:32
*Tim toont de slide met de gouden regel*
> "Dit is het meest elegante aan Next.js. De gouden regel: elke folder in `app/` is een route. Wil je een `/about` pagina? Maak een `about` folder met een `page.tsx` erin."
*Tim maakt live in zijn project een nieuwe folder `about/page.tsx` aan*
> "En klaar. Geen router config, geen complexe imports. Folder = route."
*Tim navigeert in browser naar `/about` en toont dat het werkt*
> "En nesting werkt hetzelfde. Dashboard met settings subpagina? `dashboard/settings/page.tsx`. Next.js snapt automatisch de structuur."
---
### Slide 9: Speciale Bestanden
**Tijd**: 0:32-0:35
*Tim wijst naar de slide*
> "Next.js heeft speciale bestanden. `page.tsx` — dat is je pagina. `layout.tsx` — dat is je wrapper. `loading.tsx` — loading spinner. `error.tsx` — error handler. `route.ts` — API endpoint."
*Tim opent de file explorer en wijst files aan*
> "Wat heel krachtig is: layouts nesten. Je root layout heeft een navbar. Je blog layout voegt een sidebar toe. Alles composeert vanzelf."
---
### Slide 10: Layouts in Actie
**Tijd**: 0:35-0:38
*Tim toont de code slide met RootLayout*
> "Kijk. De root layout wraps alles. Elke pagina krijgt deze nav en footer. En het mooie: als je navigeert, wordt de layout NIET opnieuw gerenderd. Alleen de `children` wisselt."
*Tim highlight de `children` prop*
> "En `Metadata`? Dat is type-safe SEO. Title, description, Open Graph — alles is getypt."
---
### Slide 11: Dynamic Routes
**Tijd**: 0:38-0:41
*Tim wijst naar vierkante haken op de slide*
> "Stel je hebt een blog met 100 posts. Je gaat geen 100 folders aanmaken. Je maakt één folder met vierkante haken: `[slug]`. Dat is een variabele."
*Tim maakt een `blog/[slug]/page.tsx` folder aan*
> "Alles wat je in de URL typt wordt beschikbaar als parameter. `/blog/mijn-eerste-post` — slug is `mijn-eerste-post`."
*Tim loopt de TypeScript typing uit*
> "En weer TypeScript. `params` is een Promise. Dat is Next.js 15. Je await't params en dan heb je `slug`."
---
### Slide 12: Route Groups
**Tijd**: 0:41-0:44
*Tim toont de slide met ronde haken*
> "Route Groups met ronde haken. `(marketing)` en `(dashboard)`. Ze verschijnen NIET in de URL — het is puur organisatie."
*Tim maakt folders aan met ronde haken*
> "Dus `(marketing)/about/page.tsx` wordt gewoon `/about`. En elk group kan zijn eigen layout hebben. Marketing met hero en footer, dashboard met sidebar."
*Tim knikt*
> "Hoe grote Next.js apps georganiseerd zijn."
---
### Slide 13: Project Structuur Best Practices
**Tijd**: 0:44-0:47
*Tim toont de folder tree*
> "Hier is hoe je een project organiseert. `app/` is ALLEEN voor routing — geen componenten. Componenten gaan in `components/`. Types in `types/`. Business logic in `lib/`."
*Tim wijst naar elk onderdeel*
> "Dit is geen harde regel van Next.js. Maar het is wel de conventie. Als je in een team werkt, weet iedereen meteen waar dingen staan."
---
## BLOK 3: TYPESCRIPT, COMPONENTS & DATA (0:47-1:07)
### Slide 14: TypeScript in Next.js
**Tijd**: 0:47-0:52
*Tim toont de code met PageProps*
> "Hier komt Les 4 echt samen. Alles in Next.js is getypt. Page props? Getypt. API request bodies? Getypt."
*Tim highlight de interface*
> "We definiëren een `PageProps` interface met `params` en `searchParams`. Beiden zijn Promises. SearchParams kan optioneel `query` en `page` bevatten — die vraagtekens zijn optional syntax."
*Tim tipt op het scherm*
> "Nu weet TypeScript precies wat er binnenkomt. Typ je `body.titel` in plaats van `body.question`, krijg je direct rood."
---
### Slide 15: Server Components vs Client Components
**Tijd**: 0:52-0:58
*Tim toont twee code blokken*
> "Dit is misschien het belangrijkste concept in Next.js. Standaard is elk component een Server Component. Het draait op de server, niet in de browser. De browser krijgt alleen HTML — geen JavaScript."
*Tim maakt een gebaar van versturen*
> "Waarom is dat goed? Performance. Je stuurt minder code naar de browser. En je kunt direct data fetchen — `await fetch()` in je component."
*Tim wijst naar het tweede blokje*
> "Maar: server components kunnen geen interactiviteit hebben. Geen useState, geen onClick. Daarvoor heb je Client Components nodig. Je zet `'use client'` bovenaan en dan werkt het zoals je gewend bent."
*Tim benadrukt*
> "De truc is: server components voor alles wat kan, en client components alleen voor interactiviteit. Meeste code is server."
---
### Slide 16: Data Fetching in Server Components
**Tijd**: 0:58-1:02
*Tim toont de server component met async function*
> "In gewoon React fetch je data met useEffect. useState voor loading, useState voor error, useState voor data. Drie states voor één ding. In Next.js? Niet nodig."
*Tim wijst naar `await fetch()`*
> "Een server component kan async zijn. Je schrijft gewoon `await fetch()`. Component wacht op data en rendert het. Terwijl het wacht, toont Next.js automatisch je `loading.tsx`."
*Tim highlight het `revalidate` object*
> "En dat `revalidate: 60`? Dat is caching. Eerste keer fetcht Next.js. Volgende 60 seconden serveert het cache. Dan opnieuw. Nul configuratie."
---
### Slide 17: Server Actions
**Tijd**: 1:02-1:05
*Tim toont de form met `action={createPoll}`*
> "Server Actions zijn relatief nieuw en ze veranderen hoe je met formulieren werkt. In plaats van `onSubmit` met fetch naar een API, gebruik je een `action` die direct een server functie aanroept."
*Tim wijst naar `'use server'`*
> "`'use server'` in de functie body. Dat draait op de server. Browser stuurt form, server verwerkt het. Geen API route nodig."
> "Waarom? Minder code. Geen API route bestand, geen fetch, geen response handling. En het werkt zonder JavaScript in de browser."
---
### Slide 18: Loading & Error States
**Tijd**: 1:05-1:07
*Tim toont `loading.tsx` en `error.tsx`*
> "In gewoon React: eigen loading states beheren. `useState(true)`, dan `if (loading) return <Spinner />`. In Next.js? Maak een `loading.tsx` en klaar."
*Tim wijst naar error.tsx*
> "Hetzelfde met errors. `error.tsx` vangt fouten op. Gebruiker ziet nette foutmelding in plaats van crash."
*Tim slaat handen in elkaar*
> "En TypeScript typing overal. `error: Error` en `reset: () => void`. Les 4 comes back!"
---
## BLOK 4: API, MIDDLEWARE, DEPLOY & CURSOR (1:07-1:27)
### Slide 19: next/image, next/link & Metadata
**Tijd**: 1:07-1:11
*Tim toont drie imports*
> "Drie ding die je altijd gebruikt. Een: `next/image`. Gebruik dit in plaats van `<img>`. Next.js optimaliseert automatisch — lazy loading, juiste formaat."
> "Twee: `next/link`. Gebruik dit in plaats van `<a>` tags. Client-side navigatie — pagina herlaadt niet, veel sneller."
> "Drie: metadata. Exporteer een `metadata` object. TypeScript helpt met autocomplete — title, description, Open Graph. Alles voor SEO."
---
### Slide 20: Route Handlers — Je Eigen API
**Tijd**: 1:11-1:16
*Tim opent een `route.ts` bestand*
> "Route Handlers zijn API endpoints in je Next.js app. Bestand: `route.ts` — niet `page.tsx`!"
*Tim toont de GET handler*
> "Je exporteert functies die GET, POST, PUT, DELETE heten. Dit bestand op `/api/polls/route.ts` betekent: GET /api/polls."
*Tim navigeert in browser naar `/api/polls`*
> "En ik krijg JSON terug. Dat is mijn API. In hetzelfde project. Geen CORS issues, geen aparte server."
*Tim highlight de TypeScript*
> "En alles is getypt. `Poll` interface, request body getypt, return type getypt."
---
### Slide 21: Dynamic API Routes
**Tijd**: 1:16-1:19
*Tim toont `/api/polls/[id]/route.ts`*
> "Net zoals pagina's kunnen API routes ook dynamisch zijn. `/api/polls/1` geeft poll 1 terug, `/api/polls/2` geeft poll 2."
*Tim highlight de error handling*
> "En error handling is essentieel. Poll niet gevonden? 404 status. Alles getypt."
---
### Slide 22: Environment Variables
**Tijd**: 1:19-1:22
*Tim toont `.env.local`*
> "Environment variables zijn hoe je secrets beheert. Database URLs, API keys — niet hardcoden. In `.env.local`."
*Tim benadrukt*
> "Twee regels. Een: `NEXT_PUBLIC_` prefix? Beschikbaar in browser. Dus NOOIT secrets daarmee. Twee: zonder prefix? Alleen op server. Perfect voor secrets."
*Tim wijst*
> "`.env.local` staat automatisch in `.gitignore`. Komt nooit in Git."
---
### Slide 23: Middleware
**Tijd**: 1:22-1:25
*Tim opent het middleware bestand*
> "Middleware draait VÓÓR elke request. Eén bestand: `middleware.ts` in je `src` root. NIET in `app/`!"
*Tim loopt door het voorbeeld*
> "Usecase: logging, auth check, redirects, rate limiting. Elke request gaat langs de beveiliger bij de deur."
*Tim wijst naar `matcher`*
> "Met `matcher` bepaal je op welke routes het actief is. Wil je het alleen op API routes? `/api/:path*`."
---
### Slide 24: Deployment op Vercel
**Tijd**: 1:25-1:27
*Tim toont Vercel dashboard*
> "Vercel = makers van Next.js = beste hosting. Push naar GitHub, import project, KLAAR. Automatische deployments bij elke push."
*Tim toont wat je gratis krijgt*
> "HTTPS, global CDN, preview deployments per branch. Environment variables in dashboard. Serverless functions voor API routes."
*Tim knikt*
> "Voor het huiswerk: deploy op Vercel. Dan heb je een echte URL."
---
### Slide 25: Next.js + Cursor — AI Development
**Tijd**: 1:27-1:30
*Tim opent Cursor*
> "Cursor kent alle Next.js patterns. Zit je in `route.ts`, suggereert Tab-completie GET en POST handlers automatisch."
*Tim waarschuwt*
> "Maar: AI is tool, geen vervanger. BEGRIJP wat Next.js doet. Cursor genereert Server Component met useState? JIJ ziet dat is fout."
*Tim toont `.cursorrules` bestand*
> "Pro tip: maak `.cursorrules`. Vertel Cursor hoe je wilt coderen. Next.js 15, TypeScript, server components standaard. Veel betere suggesties."
---
### Slide 26: Samenvatting — Next.js in Één Overzicht
**Tijd**: 1:30-1:33
*Tim toont de tabel*
> "Dit is je cheat sheet. Bewaar deze slide. Alles wat je straks nodig hebt: pagina, layout, API route, middleware, loading, error."
*Tim loopt de tabel door*
> "Print dit in je hoofd. Server Component standaard. Client met `'use client'` voor interactiviteit. Server Action voor forms. Route Handler voor API."
*Tim klapt*
> "Alles getypt met TypeScript. Alles op Vercel. Dit is je complete toolkit."
---
## PAUZE (1:33-1:45)
*Tim geeft pauzeaankondiging*
> "15 minuten pauze! Naar het toilet, koffie, stretch. Over 15 minuten gaan we SAMEN de setup doen. Iedereen een werkend project."
*Tim loopt rond, praat informeel met studenten*
---
## BLOK 5: SAMEN OPZETTEN & BOUWEN (1:45-2:45)
### Slide 27: Opdracht — QuickPoll App
**Tijd**: 1:45-1:48
*Tim herstart na pauze, energiek*
> "Oké! We gaan QuickPoll bouwen — een poll/stemming app. Alles wat we net geleerd hebben: routing, API's, TypeScript, components."
*Tim wijst naar de slide*
> "Aanpak: eerst SAMEN de setup. Ik loop het voor, jullie volgen mee. Daarna gaan jullie zelfstandig verder."
---
### Slide 28: Samen Opzetten (Tim + Klas)
**Tijd**: 1:48-2:03
*Tim opent Terminal, grote font, klas kan alles zien*
> "Open je terminal. We gaan dit samen doen. Typ mee. Stap 1: create-next-app."
*Tim typt langzaam: `npx create-next-app@latest quickpoll --typescript --tailwind --app --src-dir`*
*Pauzeert bij elke vlag om uit te leggen*
> "TypeScript: yes. Tailwind: yes. App Router: yes. Src-dir: yes. Klaar?"
*Wacht tot iedereen dezelfde output ziet*
> "Nu: `cd quickpoll && npm run dev`. Check of localhost:3000 werkt op je laptop."
*Tim wacht 30 seconden*
> "Iedereen groen licht? Top!"
*Tim opent zijn VS Code*
> "Stap 2: types. Dit is de TypeScript mindset. We beginnen ALTIJD met types. Maak `src/types/index.ts`."
*Tim typt langzaam en klas volgt*
```typescript
export interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
export interface CreatePollBody {
question: string;
options: string[];
}
```
*Pauzeert na elke interface*
> "Dit zijn je core types. Alles in je app bouwt hierop. TypeScript helpt nu met autocomplete."
> "Stap 3: folder structuur. Nog geen code — alleen bestanden aanmaken. Zo weet je wat je gaat bouwen."
*Tim maakt folders aan op scherm, klas volgt in hun eigen IDE*
```
src/app/
├── page.tsx (al aanwezig)
├── poll/
│ └── [id]/
│ └── page.tsx
└── api/
└── polls/
├── route.ts
└── [id]/
├── route.ts
└── vote/
└── route.ts
src/components/
└── (leeg voor nu)
src/lib/
└── data.ts
src/types/
└── index.ts (net gemaakt)
```
*Tim loopt langs elke folder*
> "Oké, iedereen klaar met setup? Mooi. Nu gaan jullie ZELF verder. Cursor Tab UIT. Zelf typen. Ik loop rond en help waar nodig. Begrenzing: 60 minuten."
*Tim geeft de opdracht sheet*
---
### Slide 29: Individueel Bouwen (~60 min)
**Tijd**: 2:03-2:43
*Tim loopt rond, klas werkt zelfstandig*
*Tim helpt bij veel voorkomende problemen:*
- "Vergeten `'use client'` in component? Dat is een Server Component — je kan useState niet gebruiken."
- "API route geeft 404? Check de folder path — `/api/polls/route.ts` geeft `/api/polls`."
- "TypeScript error bij `fetch`? Zet het in een `try/catch` en type de response."
*Tim monitort voortgang:*
- Na 10 min: "Iedereen verder dan stap 4? In-memory data? Top."
- Na 25 min: "Wie zit al aan stap 6? API routes? Nice!"
- Na 45 min: "Stap 7 is poll detail pagina. Daar is de meeste complexiteit. Zit je vast? Vraag hier."
*Tim helpt met rondlopen:*
**Tips voor Rondlopen (Tim geeft deze hints als nodig):**
- Stap 4 (data): Array met 3-4 polls in `lib/data.ts`. IDs als strings.
- Stap 5 (homepage): Import data, `map()` over polls, toon vraag + aantal opties.
- Stap 6 (API routes): GET terug alle polls. POST voegt nieuwe toe. GET met `[id]` terug single poll.
- Stap 7 (detail): Fetch poll data. Map options. VoteForm client component met onClick handler.
- Stap 8 (middleware): Log alle requests. `matcher: ["/api/*"]`.
- Stap 9 (loading): `loading.tsx` met skeleton. Tailwind `animate-pulse`.
> "Cursor Tab mag nu AAN als je echt vastloopt. Maar geef het eerst 5 minuten zelf."
> "Vragen? Ik ben hier. Vraag buurman/buurvrouw ook — samen is sterker."
*Tim circuliert, geeft high-fives aan wie stap 7 afmaken*
---
## BLOK 6: AFSLUITING (2:43-3:00)
### Slide 30: Huiswerk — Next.js Mini Blog
**Tijd**: 2:43-2:50
*Tim verzamelt klas*
> "Oké, huiswerk! Jullie bouwen een Mini Blog. Waarom een ander thema? Omdat ik wil dat je het ZELF kunt, niet dat je QuickPoll kopieert."
*Tim loopt langs de eisen*
> "Homepage met blog posts. Dynamic route `/post/[slug]` voor elke post. API routes: GET alle posts, POST nieuwe. Middleware voor logging. Loading en error states. Minimaal 3 pagina's."
*Tim benadrukt*
> "EN: deploy op Vercel. Ik wil werkende URL zien. Push naar GitHub, koppel Vercel, stuur link."
> "Inleveren: GitHub repo + Vercel URL. Deadline: volgende les. Zorg dat `npm run dev` werkt."
---
### Slide 31: Preview Les 6
**Tijd**: 2:50-2:55
*Tim geeft sneak peek*
> "Volgende les: verdieping. Database — Prisma of Drizzle. Authentication — NextAuth. Caching en revalidation. Forms met Server Actions verdieping."
*Tim wijst*
> "Zorg dat huiswerk af en deployed is. Les 6 bouwt hierop voort."
---
### Slide 32: Afsluiting
**Tijd**: 2:55-3:00
*Tim staat vooraan, serieus maar met lach*
> "Vandaag hebben jullie een COMPLETE Next.js app gebouwd. From scratch. Routing, API's, middleware, TypeScript — het hele pakket. Dat is impressive voor één les."
*Tim loopt*
> "Onthoud: Next.js is React met extra's. Alles wat je al kende werkt. Nu heb je een framework dat je leven makkelijker maakt. En Vercel zet het online in twee minuten."
*Tim maakt oogcontact*
> "Vragen? Nee? Mooi. Dan zie ik jullie volgende week. Vergeet huiswerk niet — ik wil URL zien!"
*Tim geeft enthousiaste groet*
---
## POST-SESSIE CHECKLIST
**Tim controleert (30 minuten na les):**
- [ ] Alle aanwezigen hebben werkend `npm run dev`
- [ ] Typefiles sync'd met GitHub (voor huiswerk)
- [ ] Vercel accounts actief (student weet hoe te deployen)
- [ ] Huiswerkopdracht duidelijk — geen vragen open
- [ ] Slides online gedeeld met klas
- [ ] Feedback verzameld van moeilijke momenten
**Voorbereiding Les 6:**
- [ ] Prisma setup docs klaar
- [ ] NextAuth.js voorbeeld project
- [ ] Database schema voorbeeld
- [ ] Caching strategieën voorbereiding

View File

@@ -0,0 +1,296 @@
%PDF-1.4
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 7 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
17 0 obj
<<
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
18 0 obj
<<
/PageMode /UseNone /Pages 20 0 R /Type /Catalog
>>
endobj
19 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260311071405+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260311071405+01'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
20 0 obj
<<
/Count 12 /Kids [ 5 0 R 6 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R
16 0 R 17 0 R ] /Type /Pages
>>
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1904
>>
stream
Gat=,a`?-*&A@ZcqVi4V:nsGp@+-E5/+Q`$(6`<2--cMo7heL1!^P2b+lAk4llZ<P:2(s"Dn=i!hfLfqJDqBgJ%G`sRg#=sA\^8VmXYZg@.=Q+(6ccF/!5QT1kTj'3@?l<Cg8m/la'MngE.e2=9^^,+InofB0^9N$]L7M-Q&#s/C"Z+"A"(G[hK!fKEN-i6kdd0^g[Jr:GN+P!q(6']=j2Os!93SY]p=a>o_;,l_S3\`rD_Z$>LJe!;1F@C:Hu4ZR]d/U>5jG$mA)HB20HDT'KpYKKs-g/\bq/P;[MECPV.pRonIM:7a9qm(-T4rGhC;.G%l3RBuN3,gsrQot>S_K"seU#Qsh(,BjJY?<G-&\?ZC((AA\$/^%n3"h;g2-.Jskarm3'8EE=CX8M!j>c@YsPu%=TocgE+\LK?9BE]jbd%%3G&+K6,KTd3->b15;fUBp)baWGl,6K"nAh6o1>O9]*,*?jEC-ian&_Nm^XVQ)Jk$7Pc8S^ie9IO-EF?>B,/Yr_bWWc##K`dcn-#8lsFY[&\Y`Z.X=ig*A4S)8q-%b@rMT(R,fU&!g;9cpsP!DWAb@S!N[PCEq8.+*W(uiE/gj5Hl$tkZ61'lA7]Gl/0_\L6bCfA@\!sbM&51]_=$[<]4n2]3617>s`-@;/r06$0&1<eYhBgijc95!bjp-XDKc[U2?PqNYC,%n=\`K+-Hh8G5XSAN/5r.Bc-]:@2="f`;d2kY8`$XtjV[&0'[f6!dr5C7@7%%,Gn!O$U\<'kAZTR@Yd>?DrVOqooPV@?k2IDdMmJ&\=&N-aR2D^:Z&5F-4\WG#^$<EbsMKI`IlV"@kOBd5JK_q;'1s"6+OAD0@OnfBbJ7q#Er7Q1]nZPna(6N=l\#%!*X$fTjiQg(uGn1/#[4"gd#5*tbh-OP$'U8M@0Z9<H%-TMgFKRX8/hVMK(L7\eV,O\:SH0-As<(?eO@-"J_4fTio+7$:99:id52n"SRTcZ9FfK4=]U_DZM#GZ'8nJ%hs!(ar^NY!W/A\Z&lcCfSLgP9i(-7]4<\P(@aCr7Rm:#:0p]Xe%?l8'+^1"MN'XcnA*i8t'@d$)[-\*]o_%f/]nfh@3e*tpa_<4=E$=uat?)a;$+&028R\IMgAb"s*jSssEJ5WcGh$eU%>3)XmmD%_ga<<Cl$r2EA7XFm9S3Np_QbCEZZJTnQ>.ftZr%p#s60.MQ@eItFZXH%+1+Ob:;/'X-G;qs2dZPpR#6_/EZ-2KTe@I0ae,A[oeT)4UTS.tYV@Vo=!*9I4"=EE%BK;,hWgUn%Q>cbOHT\lXY5Ne6Z;4i%#<X)iu]t<QS'YPG'oZ2CRFPJP9Tt5(AVlV@lEW=EF">MT_XG5`9duW"37@9+'YE856>4/G"W5#5m6]IME8>gQ_=,!-eC_pGD(^JuQ=FtsNQ<toL&6<g9_R]5XR1EtO@#N)uf5_iM@o_NkS\;L5'Ah1-\)h^fr3/`k^_r&@l1ZoAV5(TegttX$5]]A'gs0neN*(p`*sFECf(,Q*1G^ogNH$E!NcLh>Nml"XPdl&#]RY3--k%Ksc?!%LIsJ*0a%2Ij`^j6d.)cIi'%We!HS[nFFM<gAJYAhc<dG\EV&."t7sL:QobItTX9P%U06BZcPj?=kBSFTf&Jc\Z$h""2PQWB&%jPVlY*O(_HgpO$@u+*X2KOOMJbk$EQ5?]:E0V]2HF:;D13ac*_LX;jS-C;<F2Nn#C[DVXN2*AJ\^Nd.*3\QDDcC([:F(*>OZYEMWQs*5ELLl927^/P2mM_pG3jsQg#6<s'L[_-BF+*&HsVNI=1f`.MF2g>49eO,,"'LZ?W?`.2X7X]9!#=\&AbSN>!N4FeM2&)`>e/W%'!Ym"eiKq>Xq<aEe83ha2%AmQOiRL>0GEDLnaJ&D1;JWh$RQ\0;i'+p]~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 395
>>
stream
GarVJb>,r/&A7ljjp75+I`LS?&PlER6H"^R<Ojd2"Sr\EO)9j+<n/\)G%Po!Zcg0OnFW?SJ:<L$3t<l>:VdcGW!Bi7aZ&0]B`s\j#ZODoX/ueAM2ed3OZ&#KJuT[<3jcF'akC7Y,:k\XP!$XY&L%_ki;Wp!W>3_T?+\j]UL'\_*D)3a`X0\lnn.:0>E<4E2#&!8(@Cm=R8fJm?0PU;h)*utQnR1\rg`37.==cTXg:\^:rfrN,.rMCj7%@jW5_UgbHUiLgu'aa`H]<o;J:60ZN:BN.D7XmBIkZ*Tqu4JL*Bod9TN'kCn":kpIuBHL07=fL?`/DZN0KBTAsES79O@Gf/E+;kUVU8MofU8q1fLrSmU=rn^,PYAhrhd$kC`@+SN5OndASRMY[~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1962
>>
stream
Gat=,gN)%,&:N_CCo<I7`Jl<s!cNPP`+bZ$P=b+CD-lc7PE81h,&*.E@t/oN_2B^=ZK$n,OMG<A#D[Xn3&(8Mo5Cu?31n(_D[tQRkmY)+"\_.sql+^RnReU6fFuSN^^Gk@?/J(.b8,Zli2c6)^2Q*bTb@d?U$#Hg#uk,5_XhTq`An,O%KbDZ*0LT"eaqSiK-8g,"[lBMn3YlBbR[:SSb'B4EZTt^=n=#cWc#.\#&S2^WRYh9c*4<-[bWL?&0J&e/\'q/ZUgFjY\#9($iTRjL,qGP0[tAkcqoMh/U&Uah8]53%AW7RgIi6.8(um)<L_u*9U@Fr+gn@<S-P&pn/s"%&'@>fa`%%q1I-8*8+%$=o7\'uAEKfQ0`"F.=\2mJ%?F']2sO.=TMu+m^+N<[.ep)o<;*VN6K^uV<NsJ'RAL>CU1P2$>+'u,fj(YU>ZE\0<3XHoXBqd0p2Ga4Zi):&erXF@Su+ug75f`i6*-nVcn><#.TgPn$9+G%=f*s$LT3^P\.65pAD!SK5)HH,H@\#kVh^l))&EJ:Q9mE'_ms9hTG?D:^"VS1Uo\0keeg"C"S=_WRlfEu6@=+'%g5?(VnEMP^Dj[-W#u%OaRRR"6<-,-L1E*5;iWa<PuO%:l0TgS<^iUWVp3i"gLWi)MK`#(X1OX/oe+=DqR`\<<BD5%6IEi6&lig._@O2`WrGqWfY8a=H`$OlG:Ljq[E<ZZPtKs,TF^bIRr%!-o?P3"4_NH)*<59=q1<]Rj1Lpi0<2$3)irH1P3F/DX0W+BCuF9/EU+R7hrBhm\3))T<&)\?1+>L)`Udu-W>B($4dH]`B!XsSCUL)O*,l;"53h>N`rBP#3C$MF,Lj6H&:ZkS8r2l;04>OCcs<6gahrFMjY9mJ3GoMZJ6#XBZpWV\`$_E-8g]6C99l%ga6qI0-`i(D/"%nAUr*arfr(o[6JH#[<15f$Jous^FjfL&9RoJtX#$g^=Aa<N.`$CYefM?X15C.`E*T;F:#BPKSru%Qmo1\U4gA;V)?!H'j6E=7jZ#PTJ(Rc3DhT:Bd\1B6?",u@cJN=^.n-6)4FVf0g:rYi;H2jBkVP(5WPLJgK(VP;2GJ)/Z)m">,&Jl:A>a4NcLh:$/Q9G35j>M+5EO3u5[U.#ZD^B%L_R=3Ofjm-:=[&-l5-]`[_Acr%Jg4'q&Be!*2=_00U>-_4s@5P"<1]j^8YEEG/?;EQsbkE_nk!XqF/nnd1>]DF\gAiHMQZK8l<+/YNK5F[!$USg?:<DHBSgLqD6K5/+8E/lt3P+1>cc%hmhgUBkr$\.4'A[^Int+!![fNFtfWX!M=6@?mkX?K7tWO^<6QC?5ng?'X!FOo`]/ERe7HiQ5hWtq6#NlNF=p&PMiASG[=a-UK/L*?\"nPi9VH%Bt*86:_QD;\j6_ml:;(t64XGR+-`TKaZ>I:MJoUuVQ#2?hOs%Fl7Jbf.>XC\P2=^iIe)dN41S-j]o.Q]Bh<M]i4[kH-Ui?WX\GG>(BUAMaB3!,rtLJu$=Enl:*?lL@0mmC#g3M(&*THZ5@B]%C!-fkl)G%"l=seg*4d0!P>lk4F(M`m[e6IKHsI;"fuEmnB*X;JDV_A1cCbN:X&ZH1@Jd6$duMqS[d'.=htF(QBuKkMOD)i-O]o(mYRbI8-iH1CXSl]>/lnip>%9FcqghM."QWmc/(KCIW6OXMKP6LLDPYd)?h&P$<u%"&ndREok78C!)b^cb3I:j*=5(C'2H^,jRF[Aq[MnjQ7tZDrL!1W.73gu;^MDn+hVX?BZ92J'\2qRq':HV=C2Tn'G(J;*WWRZeG<$pHDNmcRJVcJT^.@qaj[EIW3k@fUc/oY,U^[Z6krd:r`RJp._P9_9;!"]pg8Os<a"+B!j$;rE^/GA@GO`+1PJ%,g&2nM\_[4'/s)r7OYaP=e%&t)8cr&RS\>&k`l#-tBT>1;J!WV5%nt8*`-8-\AJ:pK)'3P8:b;i*W~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1741
>>
stream
Gatm;D/\/e&H8h>E<oCV/LjqgWOH9fcu(Gh+m>*7BYX>#/CCa9>')/R:,;1Nmmp*Wjd3IZ$3QT=8)?E':/UNM8*tf7!-g__iC=7)L[bd$,7MRnn6t\=41*ZY`afe[5(j4e1l)2qjq&;`5/%ZeO'"Yu,_!U,L+8_9@<cVIYV7=gJ2@c>/n,HI^>Z<;gX%C+i],et).ZLO#SRhr?Bgh`/M3\BTeo?6P89.H?(J9]K!MfmiAA\<MRSl5D3\ZBiksSY$a+al._<fk40pm0(\:!eXm==DAmH49\8`P;>MgSW#TD[^"UtAO^Ulu+"Pj8hdYG,!FigP\\:^bcXa0RX`j[XNA#.m"$UO-CR)Xsh_,5XuFSu$&K!Yp5LY,uRIUnOB9-RldTN9`">qUmVI?H*TFT'hoG>s!@)8_R+,WsuMq"MB;4MY>KL5fr;O.Lp^fq0a%n-=_#&1D>83@H=6X;-?J$6IV&DE5qOi.)LCmtBUVXA`uX-#S"j)CC*:Fo,C1T`D5BYMk&53h)JFPm4d8c*Xa`0Ete;EEK),=f%Fr75uE?cl+*DM>s5&OXjc:]>#AP8hM(\)hDm0c/gF1]]?:Ll^A2Uc?n7Q0tQ6*Pc'-nl1k!Jlc-Z^UJ96B,Fboe7u&ZA[pI6(YC8Qk,m`1blCr]4hmgP-'c8b:R9V:fV:/C0=ma4TJP=?>W-6AGniWr"Xp9-cp^N\@\Mg-YAE==%5+q':kl.kr,,!3(Y6RA5,W*M_f[ltNR$QDFe#cHPaoR>tWH,/sWRP*(cJ(_HT4-&G=l%Vp+ClXN]$t!#`QbF,m]NAt(H)L%ma107%)fm=_m:ao')`6'?TG^?^dK_,6.tr,A8kt9W%YBHNs'<e.@t:'m+@YfA+'TeK$!?M2T?=e=aEnjI`!4d>5#Z$C<Eg$6)RXt98[ALkFu3BQ(991fU\/oK/7P[#jeO.jV+8T/ZkCjD<2#rTQu>oAmc=\LHHe4ZZAmWM<P7dq#1IIk7u&8h7&1cUW<sbeg)C_$9&'m132%`U5KDu!G<gS1r>7,d:?"YUmnAiU[Ses,LhJ?g][-Xo'8FTfP=H3gGFuI,UiFeXie7SQ#;KA<2l@tZ[](9O*W5.6bBrb(R?7>Fa`meUFn^+9;b3QC^HR;Op]-5/s]CLc;i``m9hCc37^c7gZk$T3j%tjM/mYe]D[8=]]_&_>J:ntr;S]Y4s>rli%Ro!Yq@bd16LS+X3^N\+)<CpFHRn'pD?T3+H438QZ>"CCb@NpUcf_e,\U*DVOoPsle>&Mol6$e)@!HlSH5EJ%Di7?TbkZVe!(bV2NNU1VFtbIh(>d"bp8=TZJs"ZD-/EfKtEqC]"bKj!lp+N8tTAGo6S&qT&t(\er,G]Zk^=\lZ:T+57XZh:'LmJE)\9M6>q4P7JbY,KmRjbRh!!6$2:rFQT^6*a[\9qC"]75+'mGF4RE_mK(g"SY84N]d-ZCu?.HNV4h4Lof/R)Dp;@]+]"SDlGq4&ScrdF<Scpol(/AJ(pS`8uEi#(R]:nE$XS;Z#kB_8;F%^e?Z>Wo&N-[<BR/2ZH"`Xh;0Zf@NhX;&@>ZEf1>uC78F)3/fEiUj.NIJ-9Z08XHHIs'0\*2`44?Lu^^[dfPp"\=nVb4+-XpFsIiE<;n08B2gMT"Zjs31/Z[,H>GrA?BCo=<n(+)ZbZ,kPOg$l_fhjWA_<TR6X>*V:RG4Sq9X54#HA>9gY2mOm%;ir/Q;1OP>ha0,k")re9=p&j'c"O;LEhU&)~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1879
>>
stream
Gatm<lYkN9&HD15iXi2APLd*Ie0A0UFBseWbIV:Cg3rIGaq?;,Q7,7tdM>.KpDp5gc,?e"K`TYMh&UuhpES!2)#,i9GE`4-5(W5S'2/c\F"eWo^uPi/:'NWZZX)hl`-*^?gqY'[odF\s&BAuHr(/H_F/TBjYQ>&@Pf\Fd*8Hhj*7lL$ig9`aG'r;[QCMqWUDZ"IkPP7imm&_n4)R;G_sd`<[i=p4&t!8B5VLBggJtqmIKX5a45$9,erLY`BuX=Z1dLqW[F#lD2KBMj)Cu*a1W2_2n>$CP4GT])F,Ad@LZ9Cu3(I[5I$2D:h;nB9Y[E?m_<W:XX"[uFYUZr[!`<pV:LWf#->-6#oemoe(@b*`O?V;Xn'Z[aAtPDJD7%*F`f4=c&?)@KnfYiMN5jgk23p-5;16hcgT$cQJ2i+ldl>P7kAN710E?NX(,UnK3#^3V6[d)[L4:mZZuA<7!Fm+63=:8DG5.)_\#b6!7D=pMMKZ^t22(3JOo%o=MF!K'\E!d;&Z<bCPe\StA^(CsBuqQ8==<"jp*WPHQ%#/3Vl09"92pZ;RR\Q5&;IhKfEsJb0.WT7-DJJcju9;i3M3(0[\":CSFQe4&ILu;K5A']<l<bt1A-9KrasM#W2&`;md?``rV[lRO;CU+-O+S,phpCSTrt_ql_03lr\OrZM]b.ejInsWSJYsp>Dc0."_2-!Y1m`hI5E4H%Z*X4mkA"HG[%CgL%3^(q6h\D9N_t%4dBE#cM73D9/`;qko!<cO-l6_oj&M@(V.gk,RTDg*N,_kMaGMcalch4>1+fO;.*V6]i&P?o&h!KBpmH[Y]UE$GE<&'WC_@X.X9M)aKY3O:`^s1?O0\H_\ZKp3g=/tY)Q#69i(F:?KX+FDA2ZCC/i?+2D55t>H+6Z/?U4P5qoD*r0b"Y[WV@GWSJ]bdeZa^&\:A?HM[5!cT/`8SM=Zu(g93KaU)[XiHgG1;&$[",*jekLQ4aXmC?gL9X^qbH0pFhM=e4b_ZUY;IdSZ_De`=0[,C\Bmt`fAD#,A-Hld<e6RZ!&AJI=m=YJ>dG1[.C#:DYQVq-G2h6kI/ar-!hWQ)7u:>-E8B87M[56Qrs6Q6[3\\tP9`K9Z8bHLZr0?s?oPV6HBZMDJqa0G:"pnp48ehmC"n_7ZQlo!V.nI]3t\m]E&CUMWP]3m8KrJ,8AlU]NJ$&b%+_dRId-F#XDO!%-Fp;e<#["@b>9Eeurno-I'H12Y'C(kFliGCPK)dfnS(VSS1RJj;EU-`dm/?Gj1@EpK#Am#`1>%bt@o-+oC)_?o9a"/9[an2u0);erue]?98Q%XA</0(eWEg<nlD&=Y4/DYS'CrL4!m5gVB?c4?c[-;kP`Md6k3\.N27!$eI`d6N!-0%bDj(sY&gV(U,qV@seOtuqrU)cA:.b'sM.$D"-C*snZM-0!#dkI43SX/U+;V'0E0#i#WRm_WT,i]r0,>WJA51B@lU:l`dn4N/g?d'!#'o?0+i\cjB6YGGMRFId<SZNi$R@\RMD<3ORiA-T$CG'rh@gS=/#\P"W<HDPBZ9fU2eMD#BcZ!Ff]^AV(X7'34@WG$fL,s,G<,Ijof#a^c;7FZ/_BMt\B<\-`L';ra"$UtHGl6Jf=EIFG#rN!M8j1;LMHCU+)p4Djc4P4<`[6FO,Lln$%I3g+ZET=ADYHSDs2;2ar**U047Q.$Bg&tJ?S#=/5/T9oNm!F!;nglen/CZe'piF8[&A9I;BYI1Q7eMK43+Xai^Eo2>uo=J&\e33QtCl3;BB9ZgP*gI0$G/rAa"aX\lPHsbY<(po`$]Ya$_V0+:^_/&M7bUJ9gTAm\8$NLnXg^OoIdRiM2!r_f_M>9DNsRC=P9C]`/XO(RPrbU+"a3[uJ4G__rA0&+g<D\G~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1670
>>
stream
Gau`SCN%rc'`G1AEEf)d'6H&[Oa;6hL3Dl-?-t^8Yl^Eh6'3!gM(C]NS?s1-hfhXT/6Qt_[MT2nECbtRLZb7MF2`[hLRI&I$K1^g`\/2`)eS9K38lQ<$SQ,CQ,W#9?(X*ZD1`dH_Ls:URmjjUgA!&#D3#&/,d7B21%L$`=WFoQ/FL);#b7ZOikQq5C[2`'d;#6bchID*QpYDl"#h>DV-]hh2KKoa!FnCqD)BS<T5"mPB/%n[OBV<cm0qf?UYE[1$h<3)ZiO6Xkujh*>$C+0aG^&U9au5i%rS=eM-j2:o*ooR-:Al%[XuoIfs<+e0LsjTg%5D2W(e]gnERt$A9KD#$#>g'7khQ*fs-Q[H<2;<S+t2<F<^-XG=B,mF^M/[5]-!J_XU8/J[pi>D_im6BH42,plgXoZsrrG')si72/Z;-4f!:2&L\P=\9kL7ZsCH3!ht5@76hRt,>kI\;=hmG0R=sP&V_S_AI7\,ba$?/3m>BIdeB[uN4.)[W4*p?0H]NfKP>2b0BPc$._-%I6=unA+CuMBDYD"UZ>c'Cd8CIK@:C0^7b*XjWuSq'4:\eH)8m05<>s%C$c<_7W&u[AJ9@67.p:#b*%%5fR!%[&8@PXBiJ;[Ub'J(T(O0g8StZ(&)3kc0akFmrk#]q&0g*h'gO\\T>od#$9L$rDR!#LRoLAeE2DI?q\^PAh3`oq*R)0o"_@f$Q6Y8KD=Zj2+#oq8,k_-`?6uHSqb`m]2`#ir[@g\5J_O-)hDN1E8o:U5TP;QW,G=j2<=WU2(Z`^+kJY[cg/hSm3Gu4WT5<%$)<[p=t16!CCW5[udrE9[G5;_b6jl>bb=d5Bm:PGX.>(505kF4"P'+$TQ^FT\5]QPgSo2rF&Dk3fSL!6.*9&krZL8`Wi\inlE?X^Pl5jn@]%7,VirQ8m=lVm1NeZ@`>/n^gG%P*7S0(>`1Aj7C0')-\7XPjcglOtN43XerN`8(hJnK8KIB/W=ad8aRC8o^>(:X&O.0N:egKdB5QRNG66j*'sBH&J-p6<AT"^2SMPBe<^$@(aI(D*qnK;*3^CIq.MUI:`A1\MOOus*5*-fhAmVI_4,$3I6GEK+&9Qb)4X**E>4\rpGb\/t1``^!TWh;0Wa$rD45759?]>#s*0U9c@jMIjfTANM4$rFM40hKe0.3pAE**4NI?CNt12Yh=TJQ@FgdL6f;*NZuE;RnRr<*4eH3?b"\HFZ#IhrX2GV<\#DdBr>K*J'43>T$@4A9n)W[&K.PS%+A"0mQ-Dg2_%[s#7oD+g<BfYA4e=Qk<AUD<lV/qk[E;sH4mqY+l,1fb%$[6UIjjI;%W.\3o:s3tFWC+7,2ms4grOT1Oel;ka&BdCO71sh5U_S7b2E;&:*-8GII2A1Y$3'=qji*4Vlc&Rh,[)!40=V&g\AX%e6d&C,kOCI-E;]H?RoYj)%m(nXJbZ&Gcpoe%AiHIE)?9re93P5fEY:4q_fe?@?0Mu:I!?InV@(i02!0g:'U_J?\ML,Zs/9OA\"Cka/Os5FK(.<i_W6/>nFWKho8FL/6=b62CQ/]\Q'SY$.C6BXR1(H'3XubkUFuS<S#mUeC9FVY>V`U2'e\8eNOP)":O9g:n1oTY&;2BE'heg[siII.gln%38Gk-V64C[9B<_Z^Rr;W[?1/1U/X99%8k(f\/fBnr<lX@a0b~>endstream
endobj
27 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1595
>>
stream
Gat=+lYkN9&HAoJib"j^'/VNp;_J[#OKRu6+ipE5dK.NT0JXY!OX45+qH/jZI7NR2.>m:H&/(C$o'U_j'*F+&1]P?=^q2%S4u-M(9]s2Ni@mEG%o[7\Z0J.NPHeoXi!,]*a)ga=7C_g?plj@=`uP@0YQKnJZ<Jia3*ofmgi\J6+H8^H$_M[HX`q9F90+;hn,&S3LX9cODEp<V]g1JWBc\K"@mL)(2fDrlC4nGYjL&./<[Fi@cj_qF&JeQ+%o:Xm>kQNE1mbVSk5/g.rLg%-FJoF'VK7T$R1AJhm;UBk.d#2+$nnN6kK)s-bm&sda7(N0PaoPbg#Wog-'rs4]n>R`k&U!^$[eH=bR_&gT&"U]Aq0c/:23/'_:R=/+#sHn'2RpKbd276<\+*+7]SA+o?lICm%;[l<68"7@2"oMi'%Oi_3j24]+qPES,IIK'a8\4U+4!H#c/FZ2,AB'_tW_/#mLYd6o't21)=-;"N6hGHO4+#a[B'c,#]V(_q95`'c>k4j-&`t!Rd6jdc;5_I@EK"/8o&'O4CL=p^iP-9\*AX[0MW?9H]"VCnsSt$m8Q3h=dNpHARZkfuNW8Aq+[Jb'+<VQ/8htOHo],$O+]f<P:aNWD%cqM8aI[93,E<G#p@!7mb-WX%'Fe!K0Lb8n;DA4*I.a&*qNP]%[?_Us-4c1:]#JY2CXtI@ojAaa=^X,"^_VKYb<@>i-R^gVm@WRN`!6Y]4!7*G,riAHA*PMluSD@FNfV-]J!`q"j.n#d]rZ#5EA@kB"jGV?Ij$L1uf$K6LFdFh#hlVHdOZ^-cXjppRLA?U`IU;]18Dj_8`u]\`O.Vbq[mDAhPKiaPU>9#T+Nb%q,VhMVoH&nrXu5M\#ab$<^B^7<T;q9Ia_3Sd\Ai4Na[1&:K=I:(*<hK*=*pNlFt[p:p&8g=bIpH%@?o.m-JSiBjf=jeIab&?R/bZ>W_Z?#rQg[L/`H6omKc+7=CfVV*%GEuTH[Ygi!T`uP.j-uCnaH]Rjh?CbFm;h('\;kmAoscf``kM&]"Q0DE]Nk+2?T6ARm&AT=,2^2b:oM%^:';`'h(W.26C876_`3DTG)K&V7BD1+\&\WEc(d]#CO24@TStkgPjcUPDO^61lFMm5&Oj-jLDBeh<\7Da.3MHJ5;1>XGGDBZ11mW>GjTP+*qO])D8!NC\(K5r\;>>u$'cP`<`ho:U^QQ](Og>Oh(5dR`3#;_Kg[#^g$!c?J,G9Bodd1jRc1Wf6oD<t9WKmaR:0M)TD'M6?HHa/dte-XY%H$[:CmC<P[hDhhU:?_(M28F898`=Cg@=OeERbmY:&DNM0A@:Jskifa/e70"4llDlkDN`4jp\^;%I2L,g=MtB?%X^,1/2C&g<qgc#0iZCQ@GG10b+_+`FY1]%<jEba)*Mi^m&joNs*V_BOosi3/=:6E/LG1lBaY`/?HRP;/o@2)$gB/]5C:%":tcPbekefEn!,A;Kh7<^`W;J#$lN!"NW1qOq1l#8<bkYC#5U@@kWUjk*=t%hQSqq@Pm-[;3!:3>;ed8X@o#Nfd)]GFe^u3C2+S'=]Hbn6T1pnl+ZBm-5FJdT`,pM_qPlk5pi6JChXBepg>&C2a~>endstream
endobj
28 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1594
>>
stream
Gb!ks>Ar7S'RnZ;3%.V=$"fX%86rND6^ouLLFc)!,6^!oJWUgSN@[(FSYdA&hfhUsHr4--!VS>U0[8?4+$Y3:4Cg_IA*u:D!PB>fi.M-k0Y@T:Lk+G5]GZ%)*ihd8&b,tK+c$;116acC,9dISr["(i68BZ<Ns45c-XQ3IS2Tt,&.g:qNC&AbLkGf<4X-\%4!S/J7B="M`+pkh9L;@hfZn?f6:PG(iYYj7TjNjqS[C%_CK8Khg0*/s=4ZTX=lLSg*).AkF^\L:/oU#\+!NB3_j*:JE#lem(QbSm%bhrjYYWEqpi$GNpVW?\+CTr1_0M/Sn6-crAh[;M1fjo9r!:2-CZFEJ%>[!#=;9L!Gd'DoD?'"T[V-[(LWCYYS-Xr,3^UH9hP*[JC-E``&`hlrFSs_j+_kI%pDY4sKHX2?+qAe5jL"<h?,o84[hQ+>Z(H'Q!uQKenMlA'MiYHQS3ZPN#)`u2lP)[s!3L<].,F=$=hXR@][J/H(UJCLleL"gNR])/;iR(mMoC(D<+Z-t@+/[qoLVp*@+79`;OkA)1-0+$TGD=HeG345L;:!KBrm8GUGA5#;GVBm-Mto[!H&nMGEF8J\F%'()DX3R%''hSem@h/#A%:DBEK>rTnUdAg5AB(i`gi99ja7.[`TtXcqGa3>%G\^UlJ6Oqf<Gb>VG]eV#"ah&:],U?2qMo_?dfnX_d&@RdEkj*<?jq=Jc:6<3"V\59qSI#`05iUX1s%fOhW>g[HQD=I*9AIQ&;kes4I&iXsR:.ImeXbSDe#4@MA6H=<$n7ibu_@8;\kNi6\dlO58.Q\D$sHp?=4.n^HKh&S6Ypo[S2Z?8e6X3$^ERWB*7)](4karh%=/-EP(!dBiZ]=ilrmhSA)--Gr.RGA:G0dG-:b\_Xj3/d.\CESQl)8oV]1lMPAA7f+-nk3hcA]7`S,.ff!\^E+(Br[(5Wa_LTD)<W.=tItK9YYg1;tp5dI=@f*%%,lFe^>/XWnNNGbWk<-b'*S9qVH=!>o1'9?f>_VBQZZiV>Au..G]Z#eIN^VCU<fVC6mX-;6O\rIsKPl#0kjCa%.G^NBRa1K`gpXUOBAe6Na1@e$Y=5KCAPM=kJshF`%RnFn7"r\o5:F<uQ!J0^:.:W8kf]P,35!\]`T44^Wh[bh#l1l"7=\NZtr")Db9mlq9:b(0P-G>"?g_C5jMlkK71&-3dEDE!X#K*+M9I73qRo/nKBo=`p+26d\0cA#261KWu'nXqE$B>JtB5f1$n>8WB,GN4\YJ-KakobWWo^>Ki-h&bJrZ[Ri&K9L_XLFZBZ=FLsI#n-sD\QVNc.,MQh1k`7a?'J\,MgjB@(^lo1>31##/Do8.U,;V0_8>Zo+m9a)%D3DEsV5*+0D+-CHou6r/Z;,EcNq%,qX`E5tnTEYcmc3,WHDNBS39o+.Id$fu\ilYMjJFH^T,$EZPoWFiNL:jt#$I(eCg&C6O\[F.SeS9,_d>7uD3L$_Mg[a>2#:fm=uBKk2pll:^hP3rV[[L8BLFPM3!(:c=G\"iM@FAP$#)5QP,.O-CS,RDH3*RB/s^3lj.:5X0mE?GjZ@]k9JdfMU8T$WX!^KGL(1eF!HC0!ir~>endstream
endobj
29 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1885
>>
stream
GatU4gN)%,&:N/3Cc%OiSOO;bOVFeEm5RkiUm&enXmm&H%$?W>M^2sKoHbFUHPa=dOEG=oRLt5-mb(n(/64fFpsI9'56c?sLAj[`F9BN]cqW&DnJCtAgpub7oh*t9#I\&km@(%m%5$Ad;,,(hn>C"<6dsILFUcqj);2*TFA1I*G>-al/ITTULp5p<6e`/.k"r+uF`ln-cDkf7he>0\Wt%"(k_d'ufMGXT?$BRfV%!\&W`E7aK!JFJW)G3^8$nO<D5GJF+?j&42;mahN2!pFo?Qt;qF,eYdaE8OLKV46<h7re38XE?k:EYL5)qq>)P:;e%3E(iXr4,Z=Vd&@TTF456^Vh]9o2YA@!i@B2aZ0+D38cGe^5C=6t5JpD7/tZqdQsne,W!(m!gk./I`U*F-JfYq`]p:QI(<Jr<BXOo1',K<ZDmH(t5m:i\\#crb`a8"rLSJ;dh*#V^=*3jX6OKnfd'*jtN\Q@S1\O7E>QdUC$d[iE$dk)QE4W[Vcl(.8?I'?,iu\WLQI%=pF/LNV.[o;F1*u_a?D;QmAUSDAJr1$47J6cRqG7:SLHk[J6>(+dp\JX@Nn9PgTu;7H3p#k3tOH9n5WNcWCC\'G_9(fo1BX>WqFSc,Q,cK3`B&*4MKRlSLehrB%)ih0>V'p'5[XS;lGN/O=F9&GQu\[)0kY8X"eZ/DFFYrDMbFM#Kb>@/&l[3WNb*!5HTO1V[Ja?A?6tP.ueb_Mee(TGtefKOj;mKV^+l7L&r)*.8:#l<<1fi#T(=`u\W(,Q67@Y<g9AVHG^7N`QQ&+X)5s/JW>YN_4@%d=*OhEQ$0PW4J[,[Yq8i%36+cV(QkT5$2^-<K0T8.@sF[hj+HqQ'7FD:\pi1,)r-oc0s)RVj)%DA3l[[X_K0da(XpF,W?,-FLj#'59I@c-$[Q0`T\$&3!/HI3Q]7B%A_BYUo#3W.DMkmB5g`Ea(p'C,d\]<aX!Usl*=04j@2?XH2a)IoQ5BMd$keJ,Y`@VaEnMiEbRAtR8Q7Z"elHPd7bIM0VknRB1RZYQcB,Z%0s"^8+uoGl-%!:,<FoJ&#n0_D+TX:NG-Yd1tg=1C8Un"\jotIcSG(@1H%)9"kS]DF9Co4&C39sO3-k28Z;)`Se=%5X)lg^Z@qs])MCQ7Q0uc"idtf7;L^!--\Q*rg212(m%$!-AhRXtp<TP$pER0\Nuc'`Qs6$'H!%2_1UA'I/6jtlX'PthWPtmu-IR'=+F2M>Y]*&T#jI9F1FGa*;8E\nIBBCg6B[;TPcJb/6pgn@r@dlqc[kdI&K[(=oH-h1`h%j[s&d//0gWnqQdHf1;*!H%o6$*VOAOdjV;.\ib"\M(@6_`lQhOi[\M+G`qtK<j)`drcUdTU:buq8:#F?6.j6kZ->NV1.!Qdi9'XSLunO=^uhY8Orh$rTo9eja%EA@@"3D6q=@I2K*@DS-YlL1d3YA2@h0U[XD-Z9)TFn\*.ZBhueU&&EjA5,;^hQ;+_<*JRp&QIFm,?sVYpWI&CU]EZ2!tdX5V&9#8B;:/*S18F7]3QiS0lXif1Yt0IrOY8DWS^X!<`!Mt@62nfNXjB*n1W%E8CS&>rSmO8KTr<mW!]fbW5GhMKf^hOkZc[OfjQNqSjiGjcDiu.pdbDDPYLFhMbmOX!D0:(L.7)i=\I[TlhD;%XHli\Mb^Q%>\p1T)<I4Pat;s`L7fm8P+/^c$BnB*"H,Gh<`*ZFjBb[oMNUb7H-"'GbDN=BVN6NZ'K$guqA6QpR"Wih5'C]!U3'dZNJ&BmiJcR@<sP;a%'[t,25;Y>l@2^3i`U's%8]dT[5A1g'8bC/)RVCAco97+D:D_6<429m(@c=Z(JIcY#eh&KGS[3eZKUVnok4'<Ai08FN7EQIA/=0uERum3k<EoHKgb~>endstream
endobj
30 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 564
>>
stream
Gar>CbAQ&g&A7<ZV?$b.7a$l-.8`+6B[@M\)`L*0l,Ae:ZD*[1bK0-ba[gEuJ,gD_R+^:='dOk9m\0m!jLD]t:`B-Fap<Xt)W1,L0iCq]U.(ScNE!m6=VilQZfh'^<ZT3QI7"jM(f<-?53cMmeA<JfYa/E<)KqWA'C@S&PqR,';WliW2^hF]%B*?($a"fu9Ol>(hZf`4c-0mMUDY=?=lDG`JXf@[,K$klG5/[;(S+L6H+/pWZo&^hQ>'o/[Y[\K_,0^*15n!7q3de)Y['^blI]hU`R6fF+`sgF2"mSFD]/2un'kCXkCUWWWa9%jf?RU!e,q>+oL'h>)+lHom\m';59bT%_nWu_6@J2i&9[42@mr@R_n4;]h'\6@luo2u_\qa:WbW^:_N!IQE,<PeaPKi[CtOt/p3jC^pd892mR!t%M$6uLfDHH9*r7S`lh=[Ra!Vjo]qnKJe\(4*cXXIdZPlsX6OGco+cKlu*J@j@/aI'r#LoR,ruX$T6LrT-0@8^I%^;:%*NPpKl'hnWZqg/WpY?@-mn5\U]=snh2_S)[q.;TSc`'US[f~>endstream
endobj
31 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1710
>>
stream
Gb"/'>Ar4d'Roe[39"B.<+5:Kj'/osBm\ZA7C`]40;T0`-8<:035-Z7<WDrf);B[/#YC6#'bY-(gqNBNX.`q[L[+>B!q:@9K9$_F%AQRhN%c*>^k\%lD9umk(D+WnD=Bk]W9_(*boeS,D#?k@gLi,B'&"MH1\/)=)@X)$#4jt:\gXm-`^)k;cW2jG[q[oLILlXO0nZI1*dAju/^"[ThH&c;&lKX&YWg<URT8[see1)\1Ph%QZF.X)S7ZUjRi7(n='I8dk+SSAQa&4s?Qh)fK:6Q?:_0G&o175)L[p`mS9*NKn508LE:i<Rpb51ki'^$jOj9$H6_;I@IY1R%_FIb*ZQg;m_<@.Flf()q"?$R"&1U'&47B`Gq.mmrZSc"!2SQBQBZ"$m]s"Z0ml='d;+Bf/Vh!)AKd_[e*(;k5]S&jUL]Tu-`B<*n)3odi"?uF)_FpK<G@%IN"-37[1$\MEUWpJs?(4Y+H@,=iKD#.b<t.[!6+,$R;c';!@?"2`f*a\G8DmZ[J/!MOf:8&1pf8>B\E#@tAI<1,k6J9'_-S\VNs^hQK5$`jkm%l9\r"iqoM4nQPRu9lCiMmdMde"m`8YN7K$7WBJPR(.LT*s?Umo.I505=[":]GAOYq?M;(@a-jSc0;$f4Rc!).ga4;V)VH)mMsM</d"e&/V&ke8!a8a*06a)Gsa%6]YF:PV*s:hQj96u'>a:4VkTd3VE0@Bd81/6b85ZmpY'M8l;KROuIUBDDSG%&9r')>mdX2.tpGr_`8p:?/9$:8<KWl0T,2/h4/?b585'ldb#[oL"fCSV;hP=nI?le7@F.kL$;J>?47cX'"MjNpO2M#>VHI)S?%mp+/IP8!6RB_:&tppd1$'!kUO9^60ba`obnfj.joDS*cDDiRlq0dA)M#o$s3Oio`M2OIY'Z./A;8oY8@N,%]UXKiXH2]gEk5$^a#qFIuu8B@(c)2!,J-mCDGQkDJUd?lO?C286d$`^f'aT:<qc)]qb4%Xk=N`)ZcnPbbl2QmdQ\I!$9^[C7@U%i;#b(20kON4q$)js#e,[2;=V^(omk!-SKJ0!bVJT\M"XHIkos_.n.;@Eo6ehH`*)K(%\kpD7mWY-O31eC4fW92V;'Z@AhjhU'P!ekg<&Lpq6uV`%YgLJlA3f)?Z^`s,0,H.sr;.643(iO\,bnH`#4&$b'_H^Z>:ZO(,Bpj^oplWsDBZdXDZ[?nPL3VF8.0&u^jDn1XQ(Oa7hXnUMh@I*aTLFgqlTn;"3(kc,M<TMUpP\#KH#!"qr>l"+[+0obU&]^Sl0j=!krb;B2<`Z]@_nD:[;T/\X?7(a@?JT("5nXQ%0,_iE&n+"^q*pU!iM!]m_rYS=mpqU'<4BXBE*2U$$)sGO8%_pM^tOCRL"8GT)TUA%Rt@o/cDlI,n^V9]cb>O/7B\4hJJ'_$`XZ<<<TYjT*$b@(fC@7nb)`86<g,q9,mk/5iJ'"Z`!:F'RT3pY&sN;h*^u."h?dNJqY.$I8jkC>n/=_m_b_%e`D@aVY`I0<^4FnCL_$5Sr_&nF59#XJ>^P)B(e.u-rH$$%W]hVT=-Y2\D's@I\3u+D6>QDCKR4V';,*t$-slAn=BXTU2p&(/0fFB2h@>Ces#bG8Ygus<0H,7<J@-"'[Yq=pY(Zq'-bQ^=eA[.S9E,0kZJl0oc@$-<Q'a1H51Bo0o^)2,n<]Yd1q&X.n@WWj.,=q.`d]erCE<~>endstream
endobj
32 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2067
>>
stream
Gat=,=``=U&:WfGfXd9'P8Q^f$"!%AHKD@E2g/rdm7c=@MLm*W,U&A5SR_6J]AN^Q[Ugd+ND2#@HXO,RYY,07J#rJgB)o\A@If9VZXLEF`X.Fb$+ud3f+r.6H3&"JgBK=lKXngT@M[pRBB(MID4eEK88*buFVIJ![lB7V(ABrN\i</&73WkgB58"OBg'at*s=l"F,2D8GTlT+og:=cNqnWeN.iDlD8+KC1<.NDlKX2TC5ePmg1d=QZ.MDCE&MR(CE@d9_e#sO`H-urro$JMd,@$1'fqBY:$?Q/N?.Rp[o#?6@/oH:P;@H-qVq;C:X<Ep"k2X<i;H$j%Vuc^+OnefORK`7Ir3Sj>Pl@_%f=sljW^g3oX0>K(IFoV;AO&9M0pp`+h&p7krd(S,!R'Woq5aF-B*`:4icoXcS;XUs4SZ*kYXR["Q:N3gP8N;/JPF,q[[KO.XKPOE&G]%V9FL#2fg%lCBkBl%.p9Hhl>8fUKS]86*21>7bo2kc*L'8J@O.lYl!,6iTbj9`lr_Ym8@k\qPiU<KN$Hg3L.R]-sAhthk?8;e7&=ORoG^8cpBt'1f-=&0)$8aHeVF`S0fF]H]W);"Z$1AHIrrHFPK>^XTU&_!P&4/\k#^R[U*GXD4[e!#tT(J,):5:gk]E5gmJ(8AfI/5a(#-+"tucS_I0?8mVoY!2YLbR7c[F2_b0.^PUai=3s1/_>YAq.,0EMK9$!i\7#<+Vm>;Lu;5]P?"dDTEUsWj@m[D93oPZ/gU>%(6NK"O1AWFD%)3R"-JKLQuL0EUJ]=^SVCUPG0p$*;a5)2hW\%<H4B[^MIcP,tk,Kd]^jV]#X\D)^\Bb'H.DH+$hOl^]qY!fo,<T5JEac]no1.P&R<K8I]mUCXK7"K]F0$M9DP`On*#;0l339>[gj-R)^-kI!1cA%I^-50Bo@C3?VU6puJ4(4T`W>G^LA#Km]6DegT$)`YtXL?mCE(&KKMDgqqZq2S`RAT*,T4IriUih)uM?f.-'Xo/P;97`.c3h)oL&/;8D&ShQ'_Oa)+-LZbmWU%RDs9A:&kj47F-%\Z(9)O9FuY(>OKLE:iV?bIZ$mY8S/*5iP#(RTL_%Crkc=Ifra"J7L0'=deX;2ZOW0bP+0R7qEbk+"XB1?(RDO6gU4!4d%+F(OhNT%<o\J>9H(?^=7JBqU[Of(ZHQ8hb#2[*X##[#T0A7HLWLCZ5?gjKX_@W8j=L-MOeU0`kr:`;<+7<SUbd!Jf;4)@D2uISEb`387P9o'31<]/e4tXgU1#!4cek##i3ZRH$f*a\GT6f\6\iQg6MY!]AAhIr#'<S^8mCK$/:l87lGCm!gp>RIDB!-nL`H7flYleCf(7KdPmjiVVTmjWc2ZpXHE+VYYT;XPQ069A8A:k3-2BU<;7]2h_#n0OOkAi6rlf0l?\_R31nH9<7C!a45BA3PT/AHS`GONFGU5T?L_*K\\B$DX4_afCJi=ZTYGCGcn:thU:gf`%583=9heZr7KD%7qW<5qeHP,13-'VQPMrdW#rHURScmf@]WH9V9ch3/e'-PVo6SNRf.3uX3-^Ia+,>TN#B3#o0l8kG4+;MiUFqOZR@=psp&q=O8"(($r#=@;Q6cj'I>FSr(Pi,G4`*F7,R0ul3MC[9)9/?t.6]"#Y1)Co*&DD^_2Gh_Pei@DP+*g:<Jm@c2PA#q&cMO4UReq>AH\9b$L1UcZX`Z%KQl&;Z<3JALP1T]N<kE&nDYj]661(lX^mCEM.\dm*T2cZ;]\gk^ISD&CDd.0<codUE^g@mdF?sCoi*TmGULN\AtIZ8V)4LZ.;qep&CDf)RlWg\1%W4IuWN)b/A#eWm7ept_fJ]/XoBem^aj&,tVUnCs!'ZNJ@)@,$oY)b:qJVt_0QJ0[o9..@Z-Mi"?5]POQF4t?W,TFVF)Z_H<(3MbJ;4=4nbTsQkh]hEfE.XJ%6r)ZaC_*esg*RQp(7dgn[G1i7Kb-mt0<a]"YmGDP7@9N^++uH:TWs(1:$LV7*+3u.^c(9p:2';l8Ja94kad&+n$bm$5!qSc9AEWoGC"6;7rg'$GU6oWGS]8kLi@!Rc@PX1q[\;G~>endstream
endobj
xref
0 33
0000000000 65535 f
0000000061 00000 n
0000000122 00000 n
0000000229 00000 n
0000000341 00000 n
0000000456 00000 n
0000000661 00000 n
0000000866 00000 n
0000000971 00000 n
0000001176 00000 n
0000001381 00000 n
0000001587 00000 n
0000001793 00000 n
0000001999 00000 n
0000002205 00000 n
0000002411 00000 n
0000002617 00000 n
0000002823 00000 n
0000003029 00000 n
0000003099 00000 n
0000003380 00000 n
0000003518 00000 n
0000005514 00000 n
0000006000 00000 n
0000008054 00000 n
0000009887 00000 n
0000011858 00000 n
0000013620 00000 n
0000015307 00000 n
0000016993 00000 n
0000018970 00000 n
0000019625 00000 n
0000021427 00000 n
trailer
<<
/ID
[<4a95945443b3266c457c35d632c7f433><4a95945443b3266c457c35d632c7f433>]
% ReportLab generated PDF document -- digest (opensource)
/Info 19 0 R
/Root 18 0 R
/Size 33
>>
startxref
23586
%%EOF

View File

@@ -0,0 +1,974 @@
# Les 5: Next.js — Het React Framework - Slide Overzicht (Part 1)
> Versie 2 — ~45 minuten theorie + 15 min pauze + ~120 min klassikaal bouwen
>
> **Dit is Part 1 van 2.** Part 2 wordt behandeld in Les 6 (Blok 4: advanced features, Stap 4-7 QuickPoll, deployment).
---
## Slide 1: Titel - "Les 5: Next.js — Het React Framework"
### Op de Slide
- Titel: **Les 5: Next.js — Het React Framework**
- Subtitel: "Van React naar Productie"
- **Les 5 van 18** (progress indicator)
- Next.js logo + Vercel logo
- Dark background, modern feel
### Docentnotities
Tim opent enthousiast en maakt connectie met vorige lessen.
"Welkom bij Les 5! De afgelopen weken hebben jullie HTML, CSS, JavaScript, en TypeScript geleerd. Vandaag maken we een grote stap: we gaan van losse technologieën naar een echt framework. Next.js."
*Pauze voor effect.*
"Na vandaag kun je een complete webapplicatie bouwen. Met routing, API's, middleware — alles. En het mooiste? Het is gebouwd op React, dus alles wat je al kent werkt gewoon."
---
## Slide 2: Planning Vandaag
### Op de Slide
- **Blok 1 (15 min)**: Waarom Next.js?
- Het probleem met pure React
- Wat Next.js oplost
- **Blok 2 (15 min)**: App Router & Project Structuur
- Folder-based routing, layouts, dynamic routes
- Route Groups & best practices
- **Blok 3 (15 min)**: Server vs Client Components, Data Fetching, Server Actions
- Server Components: async, geen JS naar browser
- Data fetching patterns
- Intro Server Actions
- **PAUZE (15 min)** ☕
- **Klassikaal Bouwen (~120 min)**: QuickPoll App Part 1 (Stap 0-3)
- Tim + klas werken samen
- Studenten volgen mee in hun eigen project
- Cursor Tab-completion mag gebruikt worden
**Totaal: ~3 uur. Part 1 → Blok 1-3 + Stap 0-3. Part 2 (Les 6) → Blok 4 + Stap 4-7 + Deployment.**
### Docentnotities
Tim loopt door de planning.
"Dit is het plan voor vandaag. Drie blokken theorie: waarom Next.js, hoe routing werkt, en de kern—server vs client components en data fetching. Dan pauze."
"Daarna doen we het ANDERS dan vorige keer. In plaats van dat jullie allemaal zelfstandig werken, gaan we SAMEN een app bouwen. Ik code op het scherm, jullie volgen mee op jullie eigen laptop. Dat voelt beter dan solo werk, toch?"
"We bouwen QuickPoll Part 1 — dat is de eerste helft van de app. Part 2 met deployment en ingewikkelder features doen we volgende les. Zo hebben jullie vandaag meer tijd om echt te begrijpen wat er gebeurt."
---
## Slide 3: Terugblik Les 4 — TypeScript
### Op de Slide
- **Les 4 samengevat:**
- TypeScript = JavaScript + Types
- Interfaces, Union Types, Type Narrowing
- Escaperoom: 10 kamers, 49 errors
- **Key takeaway**: Types voorkomen bugs vóórdat je code draait
- **Vandaag**: TypeScript in actie binnen een framework
### Docentnotities
Tim doet een korte terugblik.
"Vorige week hebben jullie TypeScript geleerd. Types, interfaces, unions, narrowing — en natuurlijk die escaperoom. Wie heeft alle 10 kamers gehaald?"
*Pauze voor reacties.*
"Nice. Die TypeScript kennis gaan we vandaag direct gebruiken. Next.js is volledig TypeScript-first. Alles wat je schrijft in Next.js is getypt. Dus jullie zijn al voorbereid."
---
## Slide 4: Het Probleem met Pure React
### Op de Slide
- **React is geweldig, MAAR:**
- ❌ Geen ingebouwde routing (react-router nodig)
- ❌ Geen server-side rendering (slecht voor SEO)
- ❌ Geen API routes (aparte backend nodig)
- ❌ Geen file-based structuur (je moet alles zelf organiseren)
- ❌ Geen image optimization
- ❌ Geen built-in performance optimizations
- **Het resultaat:**
- React app = SPA (Single Page Application)
- Alles draait in de browser
- Google ziet een lege pagina
### Docentnotities
Tim legt het probleem uit met concrete voorbeelden.
"Oké, React is fantastisch voor UI bouwen. Maar als je een échte website wilt maken — eentje die Google kan indexeren, die snel laadt, die een API heeft — dan loop je tegen grenzen aan."
"Stel je bouwt een webshop met React. Je hebt routing nodig: react-router. Je wilt dat Google je producten vindt: SSR, maar React doet dat niet standaard. Je wilt een API voor je producten: dan moet je een aparte Express server opzetten. Je wilt images optimizen: weer een extra library."
*Tim telt op zijn vingers.*
"Dus je bent meer bezig met configuratie dan met je app bouwen. En dat is precies het probleem dat Next.js oplost."
---
## Slide 5: Next.js = Het React Framework
### Op de Slide
- **Next.js biedt alles out-of-the-box:**
-**File-based Routing** — folder = route
-**Server-Side Rendering (SSR)** — HTML op de server
-**API Routes** — backend in hetzelfde project
-**Middleware** — logic vóór de request
-**Image Optimization** — next/image
-**TypeScript-first** — zero config
-**Built-in CSS/Tailwind** — styling out-of-the-box
- **Gemaakt door Vercel** — het bedrijf achter de hosting
### Docentnotities
Tim bouwt enthousiasme op.
"Next.js pakt al die problemen en lost ze op in één framework. Routing? Maak een folder. API? Maak een `route.ts` bestand. Server-side rendering? Standaard aan. TypeScript? Zero configuratie."
"Het is gemaakt door Vercel — dat is ook een hosting platform. Dus van development tot deployment, het hele verhaal zit erin."
"En het belangrijkste: Next.js is gebouwd OP React. Je schrijft gewoon React components. Alles wat je kent — useState, useEffect, JSX — werkt gewoon. Next.js voegt er alleen superkrachten aan toe."
---
## Slide 6: Wie Gebruikt Next.js?
### Op de Slide
- **Grote bedrijven op Next.js:**
- 🎬 Netflix — marketing pages
- 🎵 TikTok — web app
- 📝 Notion — website & docs
- 🛒 Nike — webshop
- 📊 Twitch — dashboard
- 🏢 Hulu, Hashicorp, Auth0, ...
- **Waarom?**
- Performance, SEO, Developer Experience
- Snel itereren, snel deployen
- Logo wall van bedrijven
### Docentnotities
Tim laat zien dat Next.js geen speelgoed is.
"Dit zijn geen kleine startups. Netflix, TikTok, Nike — ze vertrouwen op Next.js voor hun websites. Waarom? Omdat het snel is, goed voor SEO, en developers er productief mee zijn."
"Als jullie straks solliciteren en Next.js op je CV staat, dan weten bedrijven: die kan een complete webapplicatie bouwen. Dat is wat deze les zo waardevol maakt."
---
## Slide 7: create-next-app — Snel Starten
### Op de Slide
```bash
npx create-next-app@latest mijn-app
```
- **Opties die je krijgt:**
- ✅ TypeScript
- ✅ Tailwind CSS
- ✅ App Router
-`src/` directory
- ❌ ESLint (optioneel)
- **Project structuur klaar:**
```
mijn-app/
├── src/app/
│ ├── layout.tsx ← root layout
│ ├── page.tsx ← homepage
│ └── globals.css
├── public/
├── package.json
├── tsconfig.json
└── next.config.ts
```
### Docentnotities
Tim toont hoe snel je begint.
"Create-next-app zet alles voor je klaar. TypeScript, Tailwind, App Router — nul configuratie. Je typt het commando en binnen tien seconden heb je een werkend project."
"Straks gaan we dit samen doen. Dan zie je: `npm run dev`, dan staat je app op localhost:3000. Klaar."
---
## Slide 8: App Router — Folder = Route
### Op de Slide
- **De gouden regel:** Elke folder in `app/` is een route
```
src/app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/mijn-post
├── dashboard/
│ ├── page.tsx → /dashboard
│ └── settings/
│ └── page.tsx → /dashboard/settings
```
- **Geen react-router nodig!**
- **Nested routes** = nested folders
### Docentnotities
Tim legt het kernprincipe uit.
"Dit is het meest elegante aan Next.js. Wil je een `/about` pagina? Maak een `about` folder met een `page.tsx` erin. Klaar. Geen router configuratie, geen `<Route path='/about'>`. Gewoon: folder = route."
*Tim maakt live een nieuwe folder `about/page.tsx` aan.*
"En nesting werkt hetzelfde. Dashboard met een settings subpagina? `dashboard/settings/page.tsx`. Next.js snapt automatisch de structuur."
"Dit is waarom developers Next.js zo fijn vinden. Je ziet de folder structuur en je weet meteen welke pagina's je app heeft."
---
## Slide 9: Speciale Bestanden
### Op de Slide
- **`page.tsx`** — De pagina zelf (verplicht voor een route)
- **`layout.tsx`** — Wrapper om pagina's (header, footer, sidebar)
- **`loading.tsx`** — Loading state (automatisch getoond)
- **`error.tsx`** — Error boundary (vangt fouten op)
- **`not-found.tsx`** — 404 pagina
- **`route.ts`** — API endpoint (in plaats van page)
```
app/
├── layout.tsx ← wraps ALLES
├── page.tsx ← homepage
├── loading.tsx ← loading spinner
├── error.tsx ← error handler
├── not-found.tsx ← 404 pagina
└── blog/
├── layout.tsx ← wraps alleen /blog/*
└── page.tsx ← /blog pagina
```
### Docentnotities
Tim legt elk bestand uit.
"Next.js heeft speciale bestanden die automatisch iets doen. De belangrijkste: `page.tsx` — zonder die is er geen route. Dan `layout.tsx` — dat is je wrapper. Denk aan een template: header bovenaan, footer onderaan, en daartussen wisselt de content."
"Wat heel krachtig is: layouts nesten. Je root layout heeft misschien een navbar. Je blog layout voegt een sidebar toe. Alles composeert vanzelf."
"En dan heb je `loading.tsx` en `error.tsx`. Die zijn magisch. Als je pagina data laadt, toont Next.js automatisch je loading component. Geen `useState(true)` en `if (loading)` meer. Het framework doet het voor je."
---
## Slide 10: Layouts in Actie
### Op de Slide
```tsx
// src/app/layout.tsx — Root Layout
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Mijn App",
description: "Gebouwd met Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
<footer>© 2025 NOVI</footer>
</body>
</html>
);
}
```
- **`children`** = de pagina die in de layout wordt gerenderd
- Layout blijft staan bij navigatie (geen re-render)
- **`Metadata`** type uit Next.js voor type-safe SEO
### Docentnotities
Tim toont hoe layout en page samenwerken.
"Kijk, de root layout wraps alles. Elke pagina in je app krijgt deze nav en footer. En het mooie: als je navigeert van Home naar About, wordt de layout NIET opnieuw gerenderd. Alleen de `children` — de page — wisselt."
"Let op de TypeScript: we importeren het `Metadata` type van Next.js. Dat zorgt ervoor dat je editor je helpt met de juiste properties. Titel, description, Open Graph — alles is getypt."
"Dit is een groot verschil met een gewone React app waar je hele component tree opnieuw rendert bij elke route change."
---
## Slide 11: Dynamic Routes
### Op de Slide
- **Dynamic segments** met vierkante haken: `[param]`
```
app/
└── blog/
└── [slug]/
└── page.tsx → /blog/mijn-eerste-post
→ /blog/nextjs-is-cool
→ /blog/wat-dan-ook
```
```tsx
// src/app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
return <h1>Blog post: {slug}</h1>;
}
```
- **Meerdere params:** `/shop/[category]/[productId]`
- **Catch-all:** `[...slug]` → vangt alles op
### Docentnotities
Tim legt dynamic routes uit met een concreet voorbeeld.
"Stel je hebt een blog met 100 posts. Je gaat geen 100 folders aanmaken. In plaats daarvan maak je één folder met vierkante haken: `[slug]`. Dat is een variabele. Alles wat je in de URL typt wordt beschikbaar als parameter."
"Dus `/blog/mijn-eerste-post` — slug is `mijn-eerste-post`. `/blog/nextjs-is-cool` — slug is `nextjs-is-cool`. Eén page component handelt alle blog posts af."
"Let op de TypeScript typing: `params` is een Promise met een object. Dat is nieuw in Next.js 15 — je moet params await'en. En kijk: we definiëren een `PageProps` interface. Alles wat je in Les 4 geleerd hebt, gebruiken we hier."
---
## Slide 12: Route Groups
### Op de Slide
- **Route Groups** met ronde haken: `(naam)`
- Folder voor organisatie, maar NIET in de URL
```
src/app/
├── (marketing)/
│ ├── layout.tsx ← eigen layout voor marketing
│ ├── page.tsx → /
│ ├── about/
│ │ └── page.tsx → /about
│ └── pricing/
│ └── page.tsx → /pricing
├── (dashboard)/
│ ├── layout.tsx ← eigen layout voor dashboard
│ ├── dashboard/
│ │ └── page.tsx → /dashboard
│ └── settings/
│ └── page.tsx → /settings
```
- `(marketing)` en `(dashboard)` verschijnen NIET in de URL
- Elk group kan zijn eigen layout hebben
- Handig voor: verschillende layouts, code organisatie
### Docentnotities
Tim legt route groups uit als organisatie tool.
"Stel je hebt een marketing website en een dashboard in dezelfde app. De marketing site heeft een heel ander design dan het dashboard. Hoe los je dat op?"
"Route Groups. Je maakt een folder met ronde haken: `(marketing)` en `(dashboard)`. Het verschil met vierkante haken: ronde haken verschijnen NIET in de URL. Het is puur voor organisatie."
"Dus `(marketing)/about/page.tsx` wordt gewoon `/about`. Niet `/(marketing)/about`. En elk group kan zijn eigen layout hebben. Marketing met een hero en footer, dashboard met een sidebar en topbar. Allemaal in hetzelfde project."
"Dit is hoe grote Next.js apps georganiseerd worden. Je scheidt concerns zonder de URL structuur te beïnvloeden."
---
## Slide 13: Project Structuur Best Practices
### Op de Slide
```
src/
├── app/ ← Routes & pages
│ ├── (marketing)/
│ ├── (dashboard)/
│ └── api/
├── components/ ← Herbruikbare UI components
│ ├── ui/ ← Generieke components (Button, Card)
│ └── features/ ← Feature-specifiek (PollCard, VoteForm)
├── lib/ ← Utility functies & helpers
│ ├── utils.ts
│ └── api.ts
├── types/ ← TypeScript type definities
│ └── index.ts
└── middleware.ts ← Middleware (altijd in src root)
```
- **Regel 1:** `app/` is alleen voor routing — geen componenten
- **Regel 2:** Gedeelde components in `components/`
- **Regel 3:** Types in `types/` — herbruikbaar across het project
- **Regel 4:** Business logic in `lib/`
### Docentnotities
Tim deelt praktische structuur tips.
"Nu je weet hoe routing werkt, is de vraag: waar zet je de rest? Components, types, utility functies? Hier is de conventie die de meeste Next.js projecten volgen."
*Tim wijst naar de structuur.*
"De `app/` folder is ALLEEN voor routing. Pagina's, layouts, API routes. Geen losse components. Die gaan in `components/`. Onderscheid tussen generieke UI components — Button, Card, Modal — en feature-specifieke components — PollCard, VoteForm."
"Types in een `types/` folder. Zo kun je ze importeren vanuit elke plek in je project. En business logic — data fetching, formatters, validators — in `lib/`."
"Dit is geen harde regel van Next.js. Maar het is wel de conventie. En als je in een team werkt, weet iedereen meteen waar dingen staan."
---
## Slide 14: TypeScript in Next.js
### Op de Slide
- **Next.js is volledig getypt** — alles heeft types
```tsx
// Page props zijn getypt
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ query?: string; page?: string }>;
}
export default async function SearchPage({ params, searchParams }: PageProps) {
const { query, page } = await searchParams;
// ...
}
```
```tsx
// API Route met getypte request body
interface CreatePollBody {
question: string;
options: string[];
}
export async function POST(request: Request) {
const body: CreatePollBody = await request.json();
// TypeScript checkt nu of body.question en body.options bestaan
}
```
- **`Metadata`** type voor SEO
- **`NextRequest`** en **`NextResponse`** voor API/middleware
- Alles uit Les 4 (interfaces, union types) komt hier terug
### Docentnotities
Tim maakt de connectie met Les 4.
"Hier komt Les 4 echt samen. Alles in Next.js is getypt. Page props? Getypt. API request bodies? Getypt. Middleware? Getypt."
"Kijk naar dit voorbeeld. We definiëren een `PageProps` interface met `params` en `searchParams`. Beide zijn Promises — dat is een Next.js 15 patroon. SearchParams kan `query` en `page` bevatten, maar ze zijn optioneel — de vraagtekens. Dat is de optional syntax uit Les 4."
"En voor API routes: je definieert een interface voor de request body. `CreatePollBody` met `question` en `options`. Nu weet TypeScript precies wat er binnenkomt. Als je per ongeluk `body.titel` typt in plaats van `body.question`, krijg je direct een rode lijn."
"Dit is waarom we vorige week TypeScript hebben geleerd. Niet als losstaand iets, maar als fundamenteel onderdeel van hoe je Next.js apps bouwt."
---
## Slide 15: Server Components vs Client Components
### Op de Slide
- **Server Components** (standaard):
- Renderen op de server
- Geen JavaScript naar de browser
- Kunnen direct database/API aanroepen
- Kunnen async zijn
- ❌ Geen useState, useEffect, onClick
- **Client Components** (`"use client"`):
- Renderen in de browser
- Interactiviteit: useState, useEffect, event handlers
- ❌ Geen directe database/API server-side
```tsx
// Server Component (standaard) — async allowed!
export default async function ProductList() {
const res = await fetch("https://api.example.com/products");
const products: Product[] = await res.json();
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
```
```tsx
// Client Component — interactiviteit
"use client";
import { useState } from "react";
export default function LikeButton() {
const [likes, setLikes] = useState<number>(0);
return <button onClick={() => setLikes(likes + 1)}> {likes}</button>;
}
```
### Docentnotities
Tim maakt het verschil heel duidelijk.
"Dit is misschien het belangrijkste concept in Next.js. Standaard is elk component een Server Component. Dat betekent: het draait op de server, niet in de browser. De browser krijgt alleen HTML — geen JavaScript."
"Waarom is dat goed? Performance. Je stuurt minder code naar de browser. En je kunt direct data fetchen — de server component kan async zijn. Gewoon `await fetch()` in je component."
"Maar: server components kunnen geen interactiviteit hebben. Geen useState, geen onClick. Daarvoor heb je Client Components nodig. Je zet `'use client'` bovenaan je bestand en dan werkt het zoals je gewend bent van React."
"De truc is: gebruik server components voor alles wat kan, en client components alleen waar je interactiviteit nodig hebt. De meeste code is server, en kleine interactieve stukjes zijn client."
"En kijk: `useState<number>(0)` — de TypeScript generic syntax uit Les 4!"
---
## Slide 16: Data Fetching in Server Components
### Op de Slide
- **Server Components kunnen direct data fetchen** — geen useEffect nodig
```tsx
// src/app/polls/page.tsx — Server Component
interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
async function getPolls(): Promise<Poll[]> {
const res = await fetch("https://api.example.com/polls", {
next: { revalidate: 60 }, // Cache voor 60 seconden
});
return res.json();
}
export default async function PollsPage() {
const polls = await getPolls();
return (
<div>
<h1>Alle Polls</h1>
{polls.map((poll) => (
<PollCard key={poll.id} poll={poll} />
))}
</div>
);
}
```
- **`next: { revalidate: 60 }`** — cache + automatisch verversen
- **Geen useState, useEffect, loading state nodig!**
- Next.js toont automatisch `loading.tsx` tijdens het fetchen
### Docentnotities
Tim toont het verschil met klassieke React data fetching.
"In gewoon React fetch je data met useEffect. useState voor loading, useState voor error, useState voor data. Drie states voor één ding. In Next.js? Niet nodig."
"Een server component kan async zijn. Je schrijft gewoon `await fetch()`. De component wacht op de data en rendert het resultaat. En terwijl het wacht, toont Next.js automatisch je `loading.tsx`."
"En dat `revalidate: 60`? Dat is caching. De eerste keer fetcht Next.js de data. De volgende 60 seconden serveert het de cache. Na 60 seconden fetcht het opnieuw. Zero configuratie."
"Vergelijk dat met React: daar moet je React Query of SWR installeren, loading states beheren, caching instellen... Next.js doet het allemaal voor je."
---
## Slide 17: Server Actions
### Op de Slide
- **Server Actions** = functies die op de server draaien, aangeroepen vanuit forms
- Geen API route nodig voor form handling!
```tsx
// src/app/create/page.tsx
export default function CreatePollPage() {
async function createPoll(formData: FormData) {
"use server";
const question = formData.get("question") as string;
const options = formData.get("options") as string;
// Dit draait op de SERVER
console.log("Nieuwe poll:", question);
// Database insert, API call, etc.
}
return (
<form action={createPoll}>
<input name="question" placeholder="Stel je vraag..." required />
<input name="options" placeholder="Opties (komma-gescheiden)" required />
<button type="submit">Maak Poll</button>
</form>
);
}
```
- **`"use server"`** in de functie = draait op de server
- Geen `e.preventDefault()`, geen `fetch()` naar een API
- FormData wordt automatisch doorgestuurd
- Werkt ook zonder JavaScript in de browser!
### Docentnotities
Tim introduceert Server Actions als moderne form handling.
"Server Actions zijn relatief nieuw in Next.js en ze veranderen hoe je met formulieren werkt. In plaats van een onSubmit handler met fetch naar een API route, gebruik je een `action` op het form die direct een server functie aanroept."
"Kijk: `'use server'` in de functie body. Dat vertelt Next.js: dit draait op de server. De browser stuurt het form, de server verwerkt het. Geen API route nodig."
"Waarom is dit handig? Ten eerste: minder code. Geen API route bestand, geen fetch call, geen response handling. Ten tweede: het werkt zelfs zonder JavaScript in de browser — progressive enhancement."
"In de opdracht van vandaag gaan jullie nog gewone API routes gebruiken, want dat is belangrijk om te begrijpen. Maar bij het huiswerk mogen jullie Server Actions proberen als alternatief."
---
## Slide 18: Klassikaal Bouwen — QuickPoll App Part 1
### Op de Slide
- **Uitleg:** Tim codeert op het scherm, jullie volgen mee in je eigen project
- **Dit is niet solo werk** — we doen het SAMEN
- **Stap 0-3 vandaag:**
- Stap 0: Project Setup
- Stap 1: Layout & Navigatie
- Stap 2: Homepage — Polls Lijst
- Stap 3: API Route — GET Single Poll
- **Tempo:** Tim pauzeert regelmatig — "Heeft iedereen dit? Steek je hand op"
- **Cursor Tab:** Mag gebruiken (Tab completion), maar NIET Cmd+K (generator)
### Docentnotities
Tim start het klassikaal bouwen.
"Oké, nu gaan we bouwen. Dit keer anders dan vorige keer — we werken samen. Ik code op mijn scherm, jullie volgen mee. Als je achterblijft, steek je je hand op en wij wachten."
"Waarom samen? Omdat jullie vorig keer veel te veel alleen zaten, en dat was niet naar jullie voorkeur. Dit is beter voor iedereen."
"Cursor mag aan voor Tab completion — dat helpt je typen. Maar Cmd+K mag nog niet. We willen dat je het zelf begrijpt, niet dat Cursor het schrijft."
"Laten we beginnen."
---
## Slide 19: Stap 0 — Project Setup
### Op de Slide
- **Unzip starter** (of create-next-app)
```bash
npx create-next-app@latest quickpoll --typescript --tailwind --app --src-dir
cd quickpoll
npm run dev
```
- **Folder structuur klaarmaken:**
```
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── poll/
│ │ └── [id]/
│ │ └── page.tsx
│ └── api/
│ └── polls/
│ └── [id]/
│ └── route.ts
├── components/
│ └── PollCard.tsx
├── lib/
│ └── data.ts
├── types/
│ └── index.ts
└── middleware.ts
```
- **Types definiëren:**
```tsx
// src/types/index.ts
export interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
```
- **Bezoek `localhost:3000`** — controleer dat het werkt
### Docentnotities
Tim doet setup live, klas volgt stap voor stap.
"Stap 0: we zetten het project op. Iedereen typt het create-next-app commando mee."
*Tim typt het commando, wacht tot iedereen het draait.*
"Terwijl dat installeert, maken we de folder structuur aan. Niet in code — gewoon de lege folders. Klik op app/, nieuwe folder 'poll'. Daarin '[id]'. Daarin 'page.tsx'. Vervolgens 'api/', daarin 'polls/', daarin '[id]/', daarin 'route.ts'."
*Tim maakt alles visueel aan.*
"Nu types. Maak `src/types/index.ts` aan. Dit is waar alle TypeScript interfaces leven."
*Tim typt de Poll interface.*
"Klaar? Open je browser op localhost:3000. Je moet de default Next.js pagina zien. Mooi — je project leeft."
---
## Slide 20: Stap 1 — Layout & Navigatie
### Op de Slide
- **Update root layout met navbar:**
```tsx
// src/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "QuickPoll — Stem op alles",
description: "Democratie in je broekzak",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body className="bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold text-blue-600">
QuickPoll
</Link>
<div className="space-x-4">
<Link href="/" className="text-gray-600 hover:text-gray-900">
Home
</Link>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
</body>
</html>
);
}
```
- **Test:** Navigatie zichtbaar? Styling oke?
### Docentnotities
Tim bouwt de layout.
"Stap 1: layout. Dit is je wrapper — header, footer, sidebar — alles wat op elke pagina hetzelfde is."
*Tim typt de layout.*
"Let op: we gebruiken Tailwind classes voor styling. `bg-white`, `border-b`, `max-w-4xl` — allemaal standaard Tailwind. En we gebruiken `next/link` voor de navigatie — geen `<a>` tags."
"Metadata staat ook hier — Google gaat dat zien. Titel en description."
*Tim opent localhost:3000.*
"Zien jullie de navbar? Prima. Dan gaan we verder."
---
## Slide 21: Stap 2 — Homepage & Polls Lijst
### Op de Slide
- **In-memory data aanmaken:**
```tsx
// src/lib/data.ts
import { Poll } from "@/types";
export const polls: Poll[] = [
{
id: "1",
question: "Wat is je favoriete programming language?",
options: ["TypeScript", "Python", "Rust", "Go"],
votes: [15, 8, 6, 3],
},
{
id: "2",
question: "Voorkeur: Dark mode of Light mode?",
options: ["Dark", "Light"],
votes: [22, 8],
},
];
```
- **Homepage component:**
```tsx
// src/app/page.tsx
import Link from "next/link";
import { polls } from "@/lib/data";
export default function HomePage() {
return (
<div>
<h1 className="text-4xl font-bold mb-8">Alle Polls</h1>
<div className="grid gap-4">
{polls.map((poll) => (
<Link key={poll.id} href={`/poll/${poll.id}`}>
<div className="bg-white p-6 rounded-lg border border-gray-200 hover:shadow-lg transition">
<h2 className="text-xl font-semibold">{poll.question}</h2>
<p className="text-gray-500 text-sm mt-2">
{poll.options.length} opties {poll.votes.reduce((a, b) => a + b, 0)} stemmen
</p>
</div>
</Link>
))}
</div>
</div>
);
}
```
- **Test:** Zie je 2 poll cards? Kunnen je erop klikken?
### Docentnotities
Tim bouwt de homepage.
"Stap 2: we maken data en tonen die op de homepage."
*Tim maakt eerst data.ts.*
"In-memory array met poll objects. Dit is waar al onze data leeft. In de echte wereld zou dit een database zijn, maar voor vandaag: JavaScript array."
*Tim update page.tsx.*
"Homepage component — geen `'use client'`, dus het is een server component. We fetchen polls, we mappen erover, we renderen kaarten. Link wrapper maakt elke kaart klikbaar."
*Tim opent localhost:3000.*
"Mooi! Je ziet twee polls. Ze zijn klikbaar. Maar de poll detail pagina bestaat nog niet — je krijgt 404. Dat doen we straks."
---
## Slide 22: Stap 3 — API Route GET Single Poll
### Op de Slide
- **Dynamic API route voor 1 poll:**
```tsx
// src/app/api/polls/[id]/route.ts
import { NextResponse } from "next/server";
import { polls } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const poll = polls.find((p) => p.id === id);
if (!poll) {
return NextResponse.json(
{ error: "Poll niet gevonden" },
{ status: 404 }
);
}
return NextResponse.json(poll);
}
```
- **Test:** Ga naar `/api/polls/1` — zie je JSON?
### Docentnotities
Tim bouwt de API route.
"Stap 3: API route voor één poll. Dit is belangrijk om goed te begrijpen."
*Tim typt de route.*
"Map erover die array, vind de poll met het gegeven ID. Niet gevonden? 404. Wel gevonden? Return JSON."
"Let op: `params` is een Promise in Next.js 15 — je moet het awaiten. Dit is nieuw!"
*Tim opent `/api/polls/1` in de browser.*
"Je ziet JSON terug. Mooi. Dit is je API. Nu kunnen client components dit aanroepen en stemmen vastleggen."
---
## Slide 23: Huiswerk & Volgende Les
### Op de Slide
- **Huiswerk vandaag (voor wie achterblijft):**
- Zorg dat stap 0-3 volledig werkt
- Test alle routes
- Zet je code op GitHub
- **Volgende les (Les 6) — Part 2:**
- Stap 4: Client-side VoteForm component
- Stap 5: POST /api/polls/[id]/vote
- Stap 6: Poll detail pagina /poll/[id]
- Stap 7: Middleware & loading states
- Blok 4 theorie: advanced features
- Deployment op Vercel
- **Preview:** Volgende keer maken we het interactief — stemmen opslaan, real-time updates
### Docentnotities
Tim vat samen en geeft huiswerk.
"Stap 0-3 zijn klaar. Je hebt een homepage met polls, en een API route die single polls teruggeeft."
"Huiswerk: maak het af als je nog achterblijft. Zet het op GitHub zodat we volgende les kunnen doorgaan."
"Volgende les is Part 2. Dan bouwen we de stem-functie — VoteForm component, POST route, alles interactief. We deployen ook op Vercel."
"Tot volgende les!"
---
## Slide 24: Afsluiting
### Op de Slide
- **Wat we vandaag gebouwd hebben:**
- Next.js project van nul
- App Router routing — folder = route
- Server Components en data fetching
- API routes met dynamic parameters
- TypeScript interfaces overal
- Tailwind CSS styling
- In-memory data management
- **Key moments van vandaag:**
- Layout wraps alles (geen re-render bij navigatie)
- Server components: gewoon async/await
- Route handlers: [id]/route.ts = dynamic API
- **Klaar voor Part 2:**
- Volgende les: client interactiviteit, voting, deployment
### Docentnotities
Tim sluit enthousiast af.
"Jongens, wat hebben we gebouwd vandaag! Een echte Next.js app met routing, API's, TypeScript — allemaal samen. Dit is wat developers doen."
"Je snapt nu hoe Next.js apps werken. Routing via folders, server components standaard, kleine stukjes client code waar je het nodig hebt."
"Volgende les: we maken het interactief. Dan stemmen mensen echt, en we zien live hoe Vercel deployment werkt."
"Goed gedaan vandaag! Tot volgende week!"

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
You are a Next.js 15 expert using App Router with TypeScript.
Use server components by default.
Use "use client" only when needed for interactivity.
Always define TypeScript interfaces for props, params, and API bodies.
Use Tailwind CSS for styling.
Use the @/ import alias for all local imports.

View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "quickpoll",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPollById } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
// GET /api/polls/[id] — enkele poll ophalen
export async function GET(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return NextResponse.json(
{ error: "Poll niet gevonden" },
{ status: 404 }
);
}
return NextResponse.json(poll);
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
interface VoteBody {
optionIndex: number;
}
// POST /api/polls/[id]/vote — stem uitbrengen
export async function POST(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const body: VoteBody = await request.json();
if (typeof body.optionIndex !== "number") {
return NextResponse.json(
{ error: "optionIndex is verplicht" },
{ status: 400 }
);
}
const updatedPoll = votePoll(id, body.optionIndex);
if (!updatedPoll) {
return NextResponse.json(
{ error: "Poll niet gevonden of ongeldige optie" },
{ status: 404 }
);
}
return NextResponse.json(updatedPoll);
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPolls, createPoll } from "@/lib/data";
import type { Poll, CreatePollBody } from "@/types";
// GET /api/polls — alle polls ophalen
export async function GET(): Promise<NextResponse<Poll[]>> {
const polls = getPolls();
return NextResponse.json(polls);
}
// POST /api/polls — nieuwe poll aanmaken
export async function POST(request: Request): Promise<NextResponse> {
const body: CreatePollBody = await request.json();
if (!body.question || !body.options || body.options.length < 2) {
return NextResponse.json(
{ error: "Vraag en minstens 2 opties zijn verplicht" },
{ status: 400 }
);
}
const newPoll = createPoll(body.question, body.options);
return NextResponse.json(newPoll, { status: 201 });
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function CreatePollPage() {
const [question, setQuestion] = useState<string>("");
const [options, setOptions] = useState<string[]>(["", ""]);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
function addOption(): void {
if (options.length < 6) {
setOptions([...options, ""]);
}
}
function removeOption(index: number): void {
if (options.length > 2) {
setOptions(options.filter((_, i) => i !== index));
}
}
function updateOption(index: number, value: string): void {
const newOptions = [...options];
newOptions[index] = value;
setOptions(newOptions);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
const filledOptions = options.filter((opt) => opt.trim() !== "");
if (!question.trim() || filledOptions.length < 2) {
setError("Vul een vraag in en minstens 2 opties");
return;
}
setIsSubmitting(true);
const response = await fetch("/api/polls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: question.trim(),
options: filledOptions,
}),
});
if (response.ok) {
router.push("/");
} else {
setError("Er ging iets mis bij het aanmaken van de poll");
}
setIsSubmitting(false);
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
Nieuwe Poll Aanmaken
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="question"
className="block text-sm font-medium text-gray-700 mb-2"
>
Vraag
</label>
<input
id="question"
type="text"
value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setQuestion(e.target.value)
}
placeholder="Stel je vraag..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Opties (minimaal 2, maximaal 6)
</label>
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={option}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateOption(index, e.target.value)
}
placeholder={`Optie ${index + 1}`}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"
/>
{options.length > 2 && (
<button
type="button"
onClick={() => removeOption(index)}
className="px-3 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
</button>
)}
</div>
))}
</div>
{options.length < 6 && (
<button
type="button"
onClick={addOption}
className="mt-3 text-sm text-purple-600 hover:text-purple-800 font-medium"
>
+ Optie toevoegen
</button>
)}
</div>
{error && (
<p className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? "Bezig met aanmaken..." : "Poll Aanmaken"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-red-600 mb-4">
Er ging iets mis!
</h2>
<p className="text-gray-600 mb-6">{error.message}</p>
<button
onClick={() => reset()}
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors"
>
Probeer opnieuw
</button>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "QuickPoll — Stem op alles",
description: "Maak en deel polls met je vrienden",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-purple-600">
🗳 QuickPoll
</Link>
<div className="flex gap-4 items-center">
<Link
href="/"
className="text-gray-600 hover:text-purple-600 transition-colors"
>
Polls
</Link>
<Link
href="/create"
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
>
Nieuwe Poll
</Link>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
<footer className="text-center text-gray-400 text-sm py-8">
© 2025 QuickPoll NOVI Hogeschool Les 5
</footer>
</body>
</html>
);
}

View File

@@ -0,0 +1,24 @@
export default function Loading() {
return (
<div className="space-y-4">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
</div>
{[1, 2, 3].map((i) => (
<div
key={i}
className="animate-pulse bg-white rounded-xl border border-gray-200 p-6"
>
<div className="h-5 bg-gray-200 rounded w-3/4 mb-3" />
<div className="h-4 bg-gray-200 rounded w-1/4 mb-3" />
<div className="flex gap-2">
<div className="h-6 bg-gray-100 rounded-full w-20" />
<div className="h-6 bg-gray-100 rounded-full w-24" />
<div className="h-6 bg-gray-100 rounded-full w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="text-center py-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<p className="text-gray-600 mb-6">
Deze pagina bestaat niet (meer).
</p>
<Link
href="/"
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors inline-block"
>
Terug naar home
</Link>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import Link from "next/link";
import { getPolls } from "@/lib/data";
import type { Poll } from "@/types";
export const dynamic = "force-dynamic";
export default function HomePage() {
const polls: Poll[] = getPolls();
const totalVotes = (poll: Poll): number =>
poll.votes.reduce((sum, v) => sum + v, 0);
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Actieve Polls</h1>
<p className="text-gray-500 mb-8">Klik op een poll om te stemmen</p>
<div className="grid gap-4">
{polls.map((poll) => (
<Link
key={poll.id}
href={`/poll/${poll.id}`}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-md transition-all"
>
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{poll.question}
</h2>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{poll.options.length} opties</span>
<span>·</span>
<span>{totalVotes(poll)} stemmen</span>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{poll.options.map((option, index) => (
<span
key={index}
className="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm"
>
{option}
</span>
))}
</div>
</Link>
))}
</div>
{polls.length === 0 && (
<div className="text-center py-16 text-gray-400">
<p className="text-lg">Nog geen polls</p>
<Link href="/create" className="text-purple-600 hover:underline">
Maak de eerste!
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import Link from "next/link";
export default function PollNotFound() {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Poll niet gevonden
</h2>
<p className="text-gray-600 mb-6">
Deze poll bestaat niet of is verwijderd.
</p>
<Link
href="/"
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors inline-block"
>
Bekijk alle polls
</Link>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { getPollById } from "@/lib/data";
import VoteForm from "@/components/VoteForm";
import type { Metadata } from "next";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return { title: "Poll niet gevonden" };
}
return {
title: `${poll.question} — QuickPoll`,
description: `Stem op: ${poll.options.join(", ")}`,
};
}
export default async function PollPage({ params }: PageProps) {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
notFound();
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{poll.question}
</h1>
<VoteForm poll={poll} />
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Poll } from "@/types";
interface VoteFormProps {
poll: Poll;
}
export default function VoteForm({ poll }: VoteFormProps) {
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [hasVoted, setHasVoted] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [currentPoll, setCurrentPoll] = useState<Poll>(poll);
const router = useRouter();
const totalVotes: number = currentPoll.votes.reduce(
(sum, v) => sum + v,
0
);
async function handleVote(): Promise<void> {
if (selectedOption === null || isSubmitting) return;
setIsSubmitting(true);
const response = await fetch(`/api/polls/${currentPoll.id}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionIndex: selectedOption }),
});
if (response.ok) {
const updatedPoll: Poll = await response.json();
setCurrentPoll(updatedPoll);
setHasVoted(true);
}
setIsSubmitting(false);
}
function getPercentage(votes: number): number {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}
return (
<div className="space-y-3">
{currentPoll.options.map((option, index) => {
const percentage = getPercentage(currentPoll.votes[index]);
const isSelected = selectedOption === index;
return (
<button
key={index}
onClick={() => !hasVoted && setSelectedOption(index)}
disabled={hasVoted}
className={`w-full text-left p-4 rounded-lg border-2 transition-all relative overflow-hidden ${
hasVoted
? "border-gray-200 cursor-default"
: isSelected
? "border-purple-500 bg-purple-50"
: "border-gray-200 hover:border-purple-300 cursor-pointer"
}`}
>
{hasVoted && (
<div
className="absolute inset-0 bg-purple-100 transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
)}
<div className="relative flex justify-between items-center">
<div className="flex items-center gap-3">
{!hasVoted && (
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
isSelected
? "border-purple-500 bg-purple-500"
: "border-gray-300"
}`}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
)}
<span className="font-medium">{option}</span>
</div>
{hasVoted && (
<span className="text-sm font-semibold text-purple-700">
{percentage}% ({currentPoll.votes[index]} stemmen)
</span>
)}
</div>
</button>
);
})}
{!hasVoted && (
<button
onClick={handleVote}
disabled={selectedOption === null || isSubmitting}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors mt-4"
>
{isSubmitting ? "Bezig met stemmen..." : "Stem!"}
</button>
)}
{hasVoted && (
<div className="text-center pt-4">
<p className="text-green-600 font-medium mb-2">
Bedankt voor je stem!
</p>
<p className="text-sm text-gray-500">
Totaal: {totalVotes} stemmen
</p>
<button
onClick={() => router.push("/")}
className="mt-4 text-purple-600 hover:underline text-sm"
>
Terug naar alle polls
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Poll } from "@/types";
export const polls: Poll[] = [
{
id: "1",
question: "Wat is de beste code editor?",
options: ["VS Code", "Cursor", "Vim", "WebStorm"],
votes: [12, 25, 5, 3],
},
{
id: "2",
question: "Wat is de beste programmeertaal?",
options: ["TypeScript", "Python", "Rust", "Go"],
votes: [18, 15, 8, 4],
},
{
id: "3",
question: "Welk framework heeft de toekomst?",
options: ["Next.js", "Remix", "Astro", "SvelteKit"],
votes: [22, 6, 10, 7],
},
];
let nextId = 4;
export function getPolls(): Poll[] {
return polls;
}
export function getPollById(id: string): Poll | undefined {
return polls.find((poll) => poll.id === id);
}
export function createPoll(question: string, options: string[]): Poll {
const newPoll: Poll = {
id: String(nextId++),
question,
options,
votes: new Array(options.length).fill(0),
};
polls.push(newPoll);
return newPoll;
}
export function votePoll(
pollId: string,
optionIndex: number
): Poll | undefined {
const poll = polls.find((p) => p.id === pollId);
if (!poll || optionIndex < 0 || optionIndex >= poll.options.length) {
return undefined;
}
poll.votes[optionIndex]++;
return poll;
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest): NextResponse {
const start = Date.now();
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
const response = NextResponse.next();
response.headers.set("x-request-time", String(Date.now() - start));
return response;
}
export const config = {
matcher: ["/api/:path*", "/poll/:path*"],
};

View File

@@ -0,0 +1,11 @@
export interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
export interface CreatePollBody {
question: string;
options: string[];
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,6 @@
You are a Next.js 15 expert using App Router with TypeScript.
Use server components by default.
Use "use client" only when needed for interactivity.
Always define TypeScript interfaces for props, params, and API bodies.
Use Tailwind CSS for styling.
Use the @/ import alias for all local imports.

View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "quickpoll",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPollById } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
// STAP 3: GET /api/polls/[id] — enkele poll ophalen
//
// Wat moet je doen?
// 1. Haal het id op uit params (let op: params is een Promise!)
// 2. Zoek de poll met getPollById(id)
// 3. Als de poll niet bestaat, return een 404 JSON response
// 4. Als de poll wel bestaat, return de poll als JSON
//
// Hint: kijk naar /api/polls/route.ts voor een voorbeeld van NextResponse.json()
export async function GET(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
// Jouw code hier...
return NextResponse.json({ error: "Nog niet geimplementeerd" }, { status: 501 });
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
interface VoteBody {
optionIndex: number;
}
// STAP 4: POST /api/polls/[id]/vote — stem uitbrengen
//
// Wat moet je doen?
// 1. Haal het id op uit params
// 2. Lees de request body (request.json()) en cast naar VoteBody
// 3. Valideer: is optionIndex een number?
// 4. Roep votePoll(id, body.optionIndex) aan
// 5. Als het resultaat undefined is: return 404
// 6. Anders: return de geüpdatete poll als JSON
export async function POST(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
// Jouw code hier...
return NextResponse.json({ error: "Nog niet geimplementeerd" }, { status: 501 });
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPolls, createPoll } from "@/lib/data";
import type { Poll, CreatePollBody } from "@/types";
// GET /api/polls — alle polls ophalen
export async function GET(): Promise<NextResponse<Poll[]>> {
const polls = getPolls();
return NextResponse.json(polls);
}
// POST /api/polls — nieuwe poll aanmaken
export async function POST(request: Request): Promise<NextResponse> {
const body: CreatePollBody = await request.json();
if (!body.question || !body.options || body.options.length < 2) {
return NextResponse.json(
{ error: "Vraag en minstens 2 opties zijn verplicht" },
{ status: 400 }
);
}
const newPoll = createPoll(body.question, body.options);
return NextResponse.json(newPoll, { status: 201 });
}

View File

@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
// BONUS: Maak een formulier om een nieuwe poll aan te maken
//
// Benodigde state:
// - question: string
// - options: string[] (start met ["", ""])
// - isSubmitting: boolean
// - error: string | null
//
// Wat moet je bouwen?
// 1. Een input voor de vraag
// 2. Inputs voor de opties (minimaal 2, maximaal 6)
// 3. Knoppen om opties toe te voegen/verwijderen
// 4. Een submit knop die POST naar /api/polls
// 5. Na success: redirect naar / met router.push("/")
export default function CreatePollPage() {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
Nieuwe Poll Aanmaken
</h1>
<p className="text-gray-400 italic">
Bonus: bouw hier het create formulier
</p>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
// STAP 7: Error boundary
//
// Dit bestand vangt fouten op in de route.
// MOET een client component zijn ("use client" staat al bovenaan).
//
// Props die je krijgt:
// - error: Error — het error object met .message
// - reset: () => void — functie om de pagina opnieuw te proberen
//
// Bouw een nette error pagina met:
// - Een titel "Er ging iets mis!"
// - De error message
// - Een "Probeer opnieuw" knop die reset() aanroept
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-16">
<p>Er ging iets mis: {error.message}</p>
<button onClick={() => reset()}>Probeer opnieuw</button>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "QuickPoll — Stem op alles",
description: "Maak en deel polls met je vrienden",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body className="min-h-screen bg-gray-50 text-gray-900">
{/*
STAP 1: Bouw hier een navigatiebalk met:
- Logo/titel "QuickPoll" (links) die linkt naar /
- Een link naar / ("Polls")
- Een link naar /create ("Nieuwe Poll")
Tip: gebruik <Link> van "next/link", niet <a>
Tip: gebruik Tailwind classes voor styling
*/}
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
<footer className="text-center text-gray-400 text-sm py-8">
© 2025 QuickPoll NOVI Hogeschool Les 5
</footer>
</body>
</html>
);
}

View File

@@ -0,0 +1,17 @@
// STAP 7: Loading state
//
// Dit bestand wordt automatisch getoond terwijl een pagina laadt.
// Bouw een skeleton loader met Tailwind's animate-pulse class.
//
// Voorbeeld:
// <div className="animate-pulse">
// <div className="h-8 bg-gray-200 rounded w-1/3 mb-4" />
// </div>
export default function Loading() {
return (
<div>
<p>Laden...</p>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
// STAP 7: Not found pagina
//
// Wordt getoond als een pagina niet bestaat.
// Bouw een nette 404 pagina met een link terug naar home.
export default function NotFound() {
return (
<div className="text-center py-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<p className="text-gray-600 mb-6">Deze pagina bestaat niet.</p>
<Link href="/" className="text-purple-600 hover:underline">
Terug naar home
</Link>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import Link from "next/link";
import { getPolls } from "@/lib/data";
import type { Poll } from "@/types";
export const dynamic = "force-dynamic";
export default function HomePage() {
// STAP 2: Haal alle polls op met getPolls()
// Dit is een Server Component — je kunt gewoon functies aanroepen!
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Actieve Polls</h1>
<p className="text-gray-500 mb-8">Klik op een poll om te stemmen</p>
<div className="grid gap-4">
{/*
STAP 2: Map over de polls en toon voor elke poll:
- De vraag (poll.question)
- Het aantal opties en stemmen
- De opties als tags/badges
- Wrap het in een <Link> naar /poll/{poll.id}
Tip: maak een helper functie voor het totaal aantal stemmen:
const totalVotes = (poll: Poll): number =>
poll.votes.reduce((sum, v) => sum + v, 0);
*/}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import Link from "next/link";
export default function PollNotFound() {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Poll niet gevonden
</h2>
<p className="text-gray-600 mb-6">
Deze poll bestaat niet of is verwijderd.
</p>
<Link href="/" className="text-purple-600 hover:underline">
Bekijk alle polls
</Link>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { getPollById } from "@/lib/data";
import VoteForm from "@/components/VoteForm";
import type { Metadata } from "next";
interface PageProps {
params: Promise<{ id: string }>;
}
// STAP 5: generateMetadata — dynamische pagina titel
//
// Deze functie genereert de <title> tag voor SEO.
// Haal de poll op en return de vraag als titel.
// Als de poll niet bestaat, return "Poll niet gevonden".
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const poll = getPollById(id);
return {
title: poll ? `${poll.question} — QuickPoll` : "Poll niet gevonden",
};
}
// STAP 5: PollPage — de poll detail pagina
//
// Wat moet je doen?
// 1. Haal het id op uit params
// 2. Zoek de poll met getPollById(id)
// 3. Als de poll niet bestaat: roep notFound() aan
// 4. Render de poll vraag als <h1>
// 5. Render de <VoteForm poll={poll} /> component
export default async function PollPage({ params }: PageProps) {
// Jouw code hier...
return (
<div className="max-w-2xl mx-auto">
<p>Implementeer deze pagina (zie stap 5 in de opdracht)</p>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Poll } from "@/types";
interface VoteFormProps {
poll: Poll;
}
// STAP 6: VoteForm — de stem interface
//
// Dit is een CLIENT component ("use client" staat bovenaan).
// Hier mag je wel useState en onClick gebruiken!
//
// Benodigde state:
// - selectedOption: number | null (welke optie is geselecteerd)
// - hasVoted: boolean (heeft de gebruiker al gestemd)
// - isSubmitting: boolean (wordt het formulier verstuurd)
// - currentPoll: Poll (de huidige poll data, update na stemmen)
//
// Wat moet je bouwen?
// 1. Toon alle opties als klikbare knoppen
// 2. Highlight de geselecteerde optie
// 3. Een "Stem!" knop die een POST doet naar /api/polls/{id}/vote
// 4. Na het stemmen: toon de resultaten met percentages
//
// De fetch call voor stemmen:
// const response = await fetch(`/api/polls/${currentPoll.id}/vote`, {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ optionIndex: selectedOption }),
// });
// if (response.ok) {
// const updatedPoll: Poll = await response.json();
// setCurrentPoll(updatedPoll);
// setHasVoted(true);
// }
export default function VoteForm({ poll }: VoteFormProps) {
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [hasVoted, setHasVoted] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [currentPoll, setCurrentPoll] = useState<Poll>(poll);
const router = useRouter();
const totalVotes: number = currentPoll.votes.reduce(
(sum, v) => sum + v,
0
);
function getPercentage(votes: number): number {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}
async function handleVote(): Promise<void> {
if (selectedOption === null || isSubmitting) return;
setIsSubmitting(true);
// STAP 6: Doe hier de fetch call naar de vote API
// Zie de beschrijving hierboven voor de code
setIsSubmitting(false);
}
return (
<div className="space-y-3">
{/*
STAP 6: Bouw hier de voting interface
ALS de gebruiker nog NIET gestemd heeft:
- Toon elke optie als een klikbare button
- Highlight de geselecteerde optie (purple border)
- Toon een radio-achtige indicator (gevuld/leeg rondje)
- Toon een "Stem!" knop onderaan
ALS de gebruiker WEL gestemd heeft:
- Toon elke optie met een percentage balk
- Toon het percentage en aantal stemmen
- Toon "Bedankt voor je stem!"
- Toon een "Terug" link naar /
*/}
<p className="text-gray-400 italic">
Bouw hier de voting interface (zie stap 6)
</p>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Poll } from "@/types";
export const polls: Poll[] = [
{
id: "1",
question: "Wat is de beste code editor?",
options: ["VS Code", "Cursor", "Vim", "WebStorm"],
votes: [12, 25, 5, 3],
},
{
id: "2",
question: "Wat is de beste programmeertaal?",
options: ["TypeScript", "Python", "Rust", "Go"],
votes: [18, 15, 8, 4],
},
{
id: "3",
question: "Welk framework heeft de toekomst?",
options: ["Next.js", "Remix", "Astro", "SvelteKit"],
votes: [22, 6, 10, 7],
},
];
let nextId = 4;
export function getPolls(): Poll[] {
return polls;
}
export function getPollById(id: string): Poll | undefined {
return polls.find((poll) => poll.id === id);
}
export function createPoll(question: string, options: string[]): Poll {
const newPoll: Poll = {
id: String(nextId++),
question,
options,
votes: new Array(options.length).fill(0),
};
polls.push(newPoll);
return newPoll;
}
export function votePoll(
pollId: string,
optionIndex: number
): Poll | undefined {
const poll = polls.find((p) => p.id === pollId);
if (!poll || optionIndex < 0 || optionIndex >= poll.options.length) {
return undefined;
}
poll.votes[optionIndex]++;
return poll;
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest): NextResponse {
const start = Date.now();
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
const response = NextResponse.next();
response.headers.set("x-request-time", String(Date.now() - start));
return response;
}
export const config = {
matcher: ["/api/:path*", "/poll/:path*"],
};

View File

@@ -0,0 +1,11 @@
export interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
export interface CreatePollBody {
question: string;
options: string[];
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,6 @@
You are a Next.js 15 expert using App Router with TypeScript.
Use server components by default.
Use "use client" only when needed for interactivity.
Always define TypeScript interfaces for props, params, and API bodies.
Use Tailwind CSS for styling.
Use the @/ import alias for all local imports.

View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "quickpoll",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPollById } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
// GET /api/polls/[id] — enkele poll ophalen
export async function GET(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return NextResponse.json(
{ error: "Poll niet gevonden" },
{ status: 404 }
);
}
return NextResponse.json(poll);
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
interface VoteBody {
optionIndex: number;
}
// POST /api/polls/[id]/vote — stem uitbrengen
export async function POST(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const body: VoteBody = await request.json();
if (typeof body.optionIndex !== "number") {
return NextResponse.json(
{ error: "optionIndex is verplicht" },
{ status: 400 }
);
}
const updatedPoll = votePoll(id, body.optionIndex);
if (!updatedPoll) {
return NextResponse.json(
{ error: "Poll niet gevonden of ongeldige optie" },
{ status: 404 }
);
}
return NextResponse.json(updatedPoll);
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPolls, createPoll } from "@/lib/data";
import type { Poll, CreatePollBody } from "@/types";
// GET /api/polls — alle polls ophalen
export async function GET(): Promise<NextResponse<Poll[]>> {
const polls = getPolls();
return NextResponse.json(polls);
}
// POST /api/polls — nieuwe poll aanmaken
export async function POST(request: Request): Promise<NextResponse> {
const body: CreatePollBody = await request.json();
if (!body.question || !body.options || body.options.length < 2) {
return NextResponse.json(
{ error: "Vraag en minstens 2 opties zijn verplicht" },
{ status: 400 }
);
}
const newPoll = createPoll(body.question, body.options);
return NextResponse.json(newPoll, { status: 201 });
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function CreatePollPage() {
const [question, setQuestion] = useState<string>("");
const [options, setOptions] = useState<string[]>(["", ""]);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
function addOption(): void {
if (options.length < 6) {
setOptions([...options, ""]);
}
}
function removeOption(index: number): void {
if (options.length > 2) {
setOptions(options.filter((_, i) => i !== index));
}
}
function updateOption(index: number, value: string): void {
const newOptions = [...options];
newOptions[index] = value;
setOptions(newOptions);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
const filledOptions = options.filter((opt) => opt.trim() !== "");
if (!question.trim() || filledOptions.length < 2) {
setError("Vul een vraag in en minstens 2 opties");
return;
}
setIsSubmitting(true);
const response = await fetch("/api/polls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: question.trim(),
options: filledOptions,
}),
});
if (response.ok) {
router.push("/");
} else {
setError("Er ging iets mis bij het aanmaken van de poll");
}
setIsSubmitting(false);
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
Nieuwe Poll Aanmaken
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="question"
className="block text-sm font-medium text-gray-700 mb-2"
>
Vraag
</label>
<input
id="question"
type="text"
value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setQuestion(e.target.value)
}
placeholder="Stel je vraag..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Opties (minimaal 2, maximaal 6)
</label>
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={option}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateOption(index, e.target.value)
}
placeholder={`Optie ${index + 1}`}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"
/>
{options.length > 2 && (
<button
type="button"
onClick={() => removeOption(index)}
className="px-3 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
</button>
)}
</div>
))}
</div>
{options.length < 6 && (
<button
type="button"
onClick={addOption}
className="mt-3 text-sm text-purple-600 hover:text-purple-800 font-medium"
>
+ Optie toevoegen
</button>
)}
</div>
{error && (
<p className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? "Bezig met aanmaken..." : "Poll Aanmaken"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-red-600 mb-4">
Er ging iets mis!
</h2>
<p className="text-gray-600 mb-6">{error.message}</p>
<button
onClick={() => reset()}
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors"
>
Probeer opnieuw
</button>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "QuickPoll — Stem op alles",
description: "Maak en deel polls met je vrienden",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-purple-600">
🗳 QuickPoll
</Link>
<div className="flex gap-4 items-center">
<Link
href="/"
className="text-gray-600 hover:text-purple-600 transition-colors"
>
Polls
</Link>
<Link
href="/create"
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
>
Nieuwe Poll
</Link>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
<footer className="text-center text-gray-400 text-sm py-8">
© 2025 QuickPoll NOVI Hogeschool Les 5
</footer>
</body>
</html>
);
}

View File

@@ -0,0 +1,24 @@
export default function Loading() {
return (
<div className="space-y-4">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
</div>
{[1, 2, 3].map((i) => (
<div
key={i}
className="animate-pulse bg-white rounded-xl border border-gray-200 p-6"
>
<div className="h-5 bg-gray-200 rounded w-3/4 mb-3" />
<div className="h-4 bg-gray-200 rounded w-1/4 mb-3" />
<div className="flex gap-2">
<div className="h-6 bg-gray-100 rounded-full w-20" />
<div className="h-6 bg-gray-100 rounded-full w-24" />
<div className="h-6 bg-gray-100 rounded-full w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="text-center py-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<p className="text-gray-600 mb-6">
Deze pagina bestaat niet (meer).
</p>
<Link
href="/"
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors inline-block"
>
Terug naar home
</Link>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import Link from "next/link";
import { getPolls } from "@/lib/data";
import type { Poll } from "@/types";
export const dynamic = "force-dynamic";
export default function HomePage() {
const polls: Poll[] = getPolls();
const totalVotes = (poll: Poll): number =>
poll.votes.reduce((sum, v) => sum + v, 0);
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Actieve Polls</h1>
<p className="text-gray-500 mb-8">Klik op een poll om te stemmen</p>
<div className="grid gap-4">
{polls.map((poll) => (
<Link
key={poll.id}
href={`/poll/${poll.id}`}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-md transition-all"
>
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{poll.question}
</h2>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{poll.options.length} opties</span>
<span>·</span>
<span>{totalVotes(poll)} stemmen</span>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{poll.options.map((option, index) => (
<span
key={index}
className="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm"
>
{option}
</span>
))}
</div>
</Link>
))}
</div>
{polls.length === 0 && (
<div className="text-center py-16 text-gray-400">
<p className="text-lg">Nog geen polls</p>
<Link href="/create" className="text-purple-600 hover:underline">
Maak de eerste!
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import Link from "next/link";
export default function PollNotFound() {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Poll niet gevonden
</h2>
<p className="text-gray-600 mb-6">
Deze poll bestaat niet of is verwijderd.
</p>
<Link
href="/"
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors inline-block"
>
Bekijk alle polls
</Link>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { getPollById } from "@/lib/data";
import VoteForm from "@/components/VoteForm";
import type { Metadata } from "next";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return { title: "Poll niet gevonden" };
}
return {
title: `${poll.question} — QuickPoll`,
description: `Stem op: ${poll.options.join(", ")}`,
};
}
export default async function PollPage({ params }: PageProps) {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
notFound();
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{poll.question}
</h1>
<VoteForm poll={poll} />
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Poll } from "@/types";
interface VoteFormProps {
poll: Poll;
}
export default function VoteForm({ poll }: VoteFormProps) {
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [hasVoted, setHasVoted] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [currentPoll, setCurrentPoll] = useState<Poll>(poll);
const router = useRouter();
const totalVotes: number = currentPoll.votes.reduce(
(sum, v) => sum + v,
0
);
async function handleVote(): Promise<void> {
if (selectedOption === null || isSubmitting) return;
setIsSubmitting(true);
const response = await fetch(`/api/polls/${currentPoll.id}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionIndex: selectedOption }),
});
if (response.ok) {
const updatedPoll: Poll = await response.json();
setCurrentPoll(updatedPoll);
setHasVoted(true);
}
setIsSubmitting(false);
}
function getPercentage(votes: number): number {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}
return (
<div className="space-y-3">
{currentPoll.options.map((option, index) => {
const percentage = getPercentage(currentPoll.votes[index]);
const isSelected = selectedOption === index;
return (
<button
key={index}
onClick={() => !hasVoted && setSelectedOption(index)}
disabled={hasVoted}
className={`w-full text-left p-4 rounded-lg border-2 transition-all relative overflow-hidden ${
hasVoted
? "border-gray-200 cursor-default"
: isSelected
? "border-purple-500 bg-purple-50"
: "border-gray-200 hover:border-purple-300 cursor-pointer"
}`}
>
{hasVoted && (
<div
className="absolute inset-0 bg-purple-100 transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
)}
<div className="relative flex justify-between items-center">
<div className="flex items-center gap-3">
{!hasVoted && (
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
isSelected
? "border-purple-500 bg-purple-500"
: "border-gray-300"
}`}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
)}
<span className="font-medium">{option}</span>
</div>
{hasVoted && (
<span className="text-sm font-semibold text-purple-700">
{percentage}% ({currentPoll.votes[index]} stemmen)
</span>
)}
</div>
</button>
);
})}
{!hasVoted && (
<button
onClick={handleVote}
disabled={selectedOption === null || isSubmitting}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors mt-4"
>
{isSubmitting ? "Bezig met stemmen..." : "Stem!"}
</button>
)}
{hasVoted && (
<div className="text-center pt-4">
<p className="text-green-600 font-medium mb-2">
Bedankt voor je stem!
</p>
<p className="text-sm text-gray-500">
Totaal: {totalVotes} stemmen
</p>
<button
onClick={() => router.push("/")}
className="mt-4 text-purple-600 hover:underline text-sm"
>
Terug naar alle polls
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Poll } from "@/types";
export const polls: Poll[] = [
{
id: "1",
question: "Wat is de beste code editor?",
options: ["VS Code", "Cursor", "Vim", "WebStorm"],
votes: [12, 25, 5, 3],
},
{
id: "2",
question: "Wat is de beste programmeertaal?",
options: ["TypeScript", "Python", "Rust", "Go"],
votes: [18, 15, 8, 4],
},
{
id: "3",
question: "Welk framework heeft de toekomst?",
options: ["Next.js", "Remix", "Astro", "SvelteKit"],
votes: [22, 6, 10, 7],
},
];
let nextId = 4;
export function getPolls(): Poll[] {
return polls;
}
export function getPollById(id: string): Poll | undefined {
return polls.find((poll) => poll.id === id);
}
export function createPoll(question: string, options: string[]): Poll {
const newPoll: Poll = {
id: String(nextId++),
question,
options,
votes: new Array(options.length).fill(0),
};
polls.push(newPoll);
return newPoll;
}
export function votePoll(
pollId: string,
optionIndex: number
): Poll | undefined {
const poll = polls.find((p) => p.id === pollId);
if (!poll || optionIndex < 0 || optionIndex >= poll.options.length) {
return undefined;
}
poll.votes[optionIndex]++;
return poll;
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest): NextResponse {
const start = Date.now();
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
const response = NextResponse.next();
response.headers.set("x-request-time", String(Date.now() - start));
return response;
}
export const config = {
matcher: ["/api/:path*", "/poll/:path*"],
};

View File

@@ -0,0 +1,11 @@
export interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
export interface CreatePollBody {
question: string;
options: string[];
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

Some files were not shown because too many files have changed in this diff Show More